Tabbed band above the prompt: plan usage and context, answer and tool-call limits

A band above the Claude Code prompt that holds tabs, one kind of information per tab. Built for the terminal.
Each tab label carries a short summary and an icon: a dim check when all is well, a yellow triangle when it needs attention (a limit or the context getting full, a cap turned on), a red cross when it is critical (90% of a limit, 80% of the context, a cap hit last turn).
Click a tab or the arrows at either end to switch. Keys, as bound in ~/.claude/keybindings.json: alt+a collapses or expands the band, alt+shift+a focuses it; in the band, left/right or Tab move between controls, Enter or Space presses one, Esc returns to the prompt. The engine's own chords (ctrl+x ctrl+a, ctrl+x tab) do the same but lose ctrl+a to a tmux prefix of C-a. Type /dock to hide or show the dock across sessions.
Adding a tab: write a module under hooks/tabs that exports a TabInfo and a handler (see hooks/tab.ts), then add it to TABS and to dispatch in hooks/register.tsx.
hooks/register.tsx 124 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PromptComposeSection, Register } from 'claude-code'
3
4import type { TabInfo, TabReplyOf, TabRequest } from './tab'
5import { LIMIT, limitTab } from './tabs/limit'
6import { USAGE, usageTab } from './tabs/usage'
7import { Rule, TabBar } from './widgets'
8
9// A new tab is a module under ./tabs: its TabInfo goes in TABS (the order drawn)
10// and its handler in a case of dispatch.
11const TABS: TabInfo[] = [USAGE, LIMIT]
12
13async function dispatch($: EngineInterface, id: string, req: TabRequest): Promise<unknown> {
14 switch (id) {
15 case USAGE.id:
16 return usageTab($, req)
17 case LIMIT.id:
18 return limitTab($, req)
19 default:
20 return undefined
21 }
22}
23
24async function ask<R extends TabRequest>($: EngineInterface, id: string, req: R): Promise<TabReplyOf<R['kind']>> {
25 return (await dispatch($, id, req)) as TabReplyOf<R['kind']>
26}
27
28const isOn = atom({ plugin: 'dock', key: 'isOn' } as const, true)
29const activeTab = atom({ plugin: 'dock', key: 'activeTab' } as const, TABS[0]!.id)
30
31const tabOf = (id: string): TabInfo => TABS.find(t => t.id === id) ?? TABS[0]!
32
33async function select($: EngineInterface, id: string): Promise<void> {
34 await update($, activeTab, () => id)
35 await $.store.set('activeTab', id)
36}
37
38async function step($: EngineInterface, delta: number): Promise<void> {
39 const index = TABS.indexOf(tabOf(await read($, activeTab)))
40 await select($, TABS[(index + delta + TABS.length) % TABS.length]!.id)
41}
42
43export const register: Register = on => {
44 on('session.start', async ($, e, next) => {
45 await $.command.register({ name: 'dock', description: 'Show or hide the dock above the prompt' })
46 const result = await next(e)
47 const savedOn = (await $.store.get('isOn')) as boolean | undefined
48 if (savedOn !== undefined) await update($, isOn, () => savedOn)
49 // a tab saved by an older build may be gone: the first tab then
50 const savedTab = (await $.store.get('activeTab')) as string | undefined
51 if (savedTab !== undefined) await update($, activeTab, () => tabOf(savedTab).id)
52 for (const tab of TABS) await ask($, tab.id, { kind: 'sessionStart' })
53 return result
54 })
55
56 on('session.measure', async ($, e, next) => {
57 const result = await next(e)
58 for (const tab of TABS) await ask($, tab.id, { kind: 'sessionMeasure' })
59 return result
60 })
61
62 on('prompt.submit', async ($, e, next) => {
63 for (const tab of TABS) await ask($, tab.id, { kind: 'promptSubmit' })
64 return next(e)
65 })
66
67 on('tool.call', async ($, e, next) => {
68 for (const tab of TABS) {
69 const deny = await ask($, tab.id, { kind: 'toolCall', e })
70 if (deny !== undefined) return { deny }
71 }
72 return next(e)
73 })
74
75 on('prompt.compose', async ($, e, next) => {
76 const composed = await next(e)
77 const added: PromptComposeSection[] = []
78 for (const tab of TABS) {
79 const section = await ask($, tab.id, { kind: 'promptCompose' })
80 if (section) added.push(section)
81 }
82 return added.length === 0 ? composed : { sections: [...composed.sections, ...added] }
83 })
84
85 on('turn.complete', async ($, e, next) => {
86 const result = await next(e)
87 for (const tab of TABS) await ask($, tab.id, { kind: 'turnComplete', e })
88 return result
89 })
90
91 on('command.run', { command: 'dock' }, async $ => {
92 const now = !(await read($, isOn))
93 await update($, isOn, () => now)
94 await $.store.set('isOn', now)
95 return { text: now ? 'Dock shown.' : 'Dock hidden.' }
96 })
97
98 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
99 if (e.props.hasSurvey || !(await read($, isOn))) return next(e)
100 // the band is shared by every plugin: what the plugins beneath draw goes under this one
101 const below = await next(e)
102 const ui = $.ui.resolve(e)
103 const { Box } = ui
104 const width = Math.max(20, e.props.bodyColumns)
105 const active = tabOf(await read($, activeTab))
106 const labels = []
107 for (const tab of TABS) {
108 const summary = await ask($, tab.id, { kind: 'summary' })
109 const attention = await ask($, tab.id, { kind: 'attention' })
110 labels.push({ ...tab, summary, attention })
111 }
112 const content = await ask($, active.id, { kind: 'render', ui, width: width - 1 })
113
114 return (
115 <Box flexDirection="column">
116 <TabBar ui={ui} tabs={labels} activeId={active.id} onSelect={id => select($, id)} onStep={delta => step($, delta)} />
117 <Rule ui={ui} width={width} />
118 <Box paddingLeft={1}>{content}</Box>
119 {below}
120 </Box>
121 )
122 })
123}
124hooks/tab.ts 42 lines1import type { ElementTable, PromptComposeSection, RenderNode, ToolCallInput, TurnCompleteInput } from 'claude-code'
2
3export type Attention = 'none' | 'warn' | 'alert'
4
5export type TabInfo = { id: string; title: string }
6
7/**
8 * What the dock asks of a tab. A tab is one exported function taking $ and a
9 * request; the dock calls it by name, as the engine only lets $ reach the
10 * plugin's own named functions.
11 *
12 * The engine takes one hook per event from a plugin, so a tab never hooks an
13 * event itself: the dock hooks each once and asks every tab in turn, after the
14 * engine has run, except toolCall, which is asked before and may deny.
15 */
16export type TabRequest =
17 | { kind: 'summary' }
18 | { kind: 'attention' }
19 | { kind: 'render'; ui: ElementTable; width: number }
20 | { kind: 'sessionStart' }
21 | { kind: 'sessionMeasure' }
22 | { kind: 'promptSubmit' }
23 | { kind: 'toolCall'; e: ToolCallInput }
24 | { kind: 'promptCompose' }
25 | { kind: 'turnComplete'; e: TurnCompleteInput }
26
27/**
28 * The answer to each request kind; a kind not listed answers nothing. summary
29 * is a few characters shown beside the title, kept current while the tab is
30 * hidden; a toolCall text denies the call with it; a promptCompose section is
31 * added at the end of the system prompt.
32 */
33export type TabReply = {
34 summary: string
35 attention: Attention
36 render: RenderNode
37 toolCall: string | undefined
38 promptCompose: PromptComposeSection | undefined
39}
40
41export type TabReplyOf<K extends TabRequest['kind']> = K extends keyof TabReply ? TabReply[K] : void
42hooks/tabs/limit.tsx 143 lines1import { atom, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, RenderNode } from 'claude-code'
3
4import type { LastTurn, Limit, Limits } from '../../types'
5import type { TabInfo, TabRequest } from '../tab'
6import { MUTED, Stepper, Switch } from '../widgets'
7
8const DEFAULTS: Limits = { lines: { value: 3, isOn: false }, tools: { value: 3, isOn: false } }
9const STORE_KEY = 'limits'
10
11const limitsAtom = atom({ plugin: 'dock', key: 'limits' } as const, DEFAULTS)
12const lastAtom = atom({ plugin: 'dock', key: 'last' } as const, null as LastTurn | null)
13
14type Which = keyof Limits
15
16function countLines(text: string): number {
17 const trimmed = text.trim()
18 return trimmed === '' ? 0 : trimmed.split('\n').length
19}
20
21function instruction(limits: Limits): string {
22 const rules: string[] = []
23 if (limits.tools.isOn) {
24 rules.push(
25 `- At most ${limits.tools.value} tool calls this turn, subagents included; the harness denies any call beyond that. ` +
26 'Spend them only on what the conclusion needs, nothing outside the request unless it fails. ' +
27 'When the budget runs out, answer with what you have and say in one line what is still unknown.',
28 )
29 }
30 if (limits.lines.isOn) {
31 rules.push(
32 `- Final answer at most ${limits.lines.value} lines: the conclusion only (for a test: pass, or fail with the reason), ` +
33 'plus ids needed to look it up again. No step-by-step narration, no tables unless they fit the limit. ' +
34 'If a decision is needed from the user, ask it in one line.',
35 )
36 }
37 return 'Answer limits set by the user for every turn until changed:\n' + rules.join('\n')
38}
39
40async function setLimit($: EngineInterface, which: Which, fn: (l: Limit) => Limit) {
41 await update($, limitsAtom, limits => ({ ...limits, [which]: fn(limits[which]) }))
42 await $.store.set(STORE_KEY, await read($, limitsAtom))
43}
44
45const short = (l: Limit) => (l.isOn ? String(l.value) : '-')
46
47// module scope: a reload starts the count over, as a new turn would
48let toolCalls = 0
49let wasDenied = false
50
51export const LIMIT: TabInfo = { id: 'limit', title: 'Limit' }
52
53export async function limitTab($: EngineInterface, req: TabRequest): Promise<unknown> {
54 switch (req.kind) {
55 case 'sessionStart': {
56 // the values carry over; every cap starts off, so a session never begins capped unseen
57 const saved = (await $.store.get(STORE_KEY)) as Limits | undefined
58 if (saved !== undefined) {
59 await update($, limitsAtom, () => ({ lines: { ...saved.lines, isOn: false }, tools: { ...saved.tools, isOn: false } }))
60 }
61 return
62 }
63 case 'promptSubmit':
64 toolCalls = 0
65 wasDenied = false
66 return
67 case 'toolCall': {
68 const { tools } = await read($, limitsAtom)
69 if (tools.isOn && toolCalls >= tools.value) {
70 wasDenied = true
71 return (
72 `dock: tool budget of ${tools.value} calls for this turn is used up. ` +
73 'Answer now with what you have; say in one line what is still unknown.'
74 )
75 }
76 toolCalls += 1
77 return undefined
78 }
79 case 'promptCompose': {
80 const limits = await read($, limitsAtom)
81 if (!limits.lines.isOn && !limits.tools.isOn) return undefined
82 return { id: 'dock:limits', text: instruction(limits), scope: 'session' }
83 }
84 case 'turnComplete': {
85 if (req.e.agentId !== undefined) return
86 const { lines } = await read($, limitsAtom)
87 const count = countLines(req.e.answer)
88 const last: LastTurn = { tools: toolCalls, lines: count, hit: wasDenied || (lines.isOn && count > lines.value) }
89 await update($, lastAtom, () => last)
90 return
91 }
92 case 'summary': {
93 const limits = await read($, limitsAtom)
94 return `L${short(limits.lines)} T${short(limits.tools)}`
95 }
96 case 'attention': {
97 const last = await read($, lastAtom)
98 if (last?.hit) return 'alert'
99 const limits = await read($, limitsAtom)
100 return limits.lines.isOn || limits.tools.isOn ? 'warn' : 'none'
101 }
102 case 'render':
103 return render($, req.ui)
104 default:
105 return undefined
106 }
107}
108
109async function render($: EngineInterface, ui: ElementTable): Promise<RenderNode> {
110 const { Box, Text } = ui
111 const limits = await read($, limitsAtom)
112 const last = await read($, lastAtom)
113
114 const row = (which: Which, label: string) => {
115 const limit = limits[which]
116 return (
117 <Box flexDirection="row" columnGap={3}>
118 <Box width={6} flexShrink={0}>
119 <Text bold>{label}</Text>
120 </Box>
121 <Stepper
122 ui={ui}
123 id={which}
124 text={String(limit.value)}
125 isOn={limit.isOn}
126 onDec={() => setLimit($, which, l => ({ value: Math.max(1, l.value - 1), isOn: true }))}
127 onInc={() => setLimit($, which, l => ({ value: l.value + 1, isOn: true }))}
128 />
129 <Switch ui={ui} id={which} isOn={limit.isOn} onToggle={() => setLimit($, which, l => ({ ...l, isOn: !l.isOn }))} />
130 </Box>
131 )
132 }
133
134 return (
135 <Box flexDirection="column">
136 {row('lines', 'Lines')}
137 {row('tools', 'Tools')}
138 <Text> </Text>
139 <Text color={MUTED}>{last === null ? 'last turn · none yet' : `last turn · ${last.lines} lines · ${last.tools} tools`}</Text>
140 </Box>
141 )
142}
143hooks/tabs/usage.tsx 436 lines1import { atom, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, RenderNode, SessionRateLimit } from 'claude-code'
3
4import type { Category, PlanLimit, Snapshot } from '../../types'
5import type { Attention, TabInfo, TabRequest } from '../tab'
6import { AMBER, Bar, GREEN, MUTED, RED, TRACK } from '../widgets'
7
8const snapshot = atom({ plugin: 'dock', key: 'snapshot' } as const, null as Snapshot | null)
9
10const HOUR = 3_600_000
11const WINDOWS: Record<string, { label: string; ms: number }> = {
12 five_hour: { label: '5H', ms: 5 * HOUR },
13 seven_day: { label: 'Weekly', ms: 7 * 24 * HOUR },
14}
15// a reset time read twice may drift by seconds: within this it is the same window
16const SAME_WINDOW_MS = 10 * 60_000
17
18const LIMIT_AMBER_AT = 75
19const LIMIT_RED_AT = 90
20const CONTEXT_AMBER_AT = 50
21const CONTEXT_RED_AT = 80
22
23const MESSAGES = '#22d3ee'
24const TOOLS = '#a78bfa'
25// Other and the autocompact buffer share one grey: both are room the chat cannot use
26const OTHER = '#4b5163'
27
28function levelOf(percent: number, amberAt: number, redAt: number): Attention {
29 const shown = Math.round(percent)
30 return shown >= redAt ? 'alert' : shown >= amberAt ? 'warn' : 'none'
31}
32const COLOR: Record<Attention, string> = { none: GREEN, warn: AMBER, alert: RED }
33
34function duration(ms: number): string {
35 const m = Math.max(0, Math.round(ms / 60_000))
36 const d = Math.floor(m / 1440)
37 const hours = Math.floor((m % 1440) / 60)
38 const min = m % 60
39 if (d > 0) return hours > 0 ? `${d}d ${hours}h` : `${d}d`
40 if (hours > 0) return min > 0 ? `${hours}h ${min}m` : `${hours}h`
41 return `${min}m`
42}
43
44function tokens(n: number): string {
45 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
46 if (n >= 1000) return `${Math.round(n / 1000)}k`
47 return `${Math.round(n)}`
48}
49
50// The usage service rate-limits per login, and the app's own Usage panel asks it on
51// the same allowance, so ask it seldom: when a chat opens, then every 2 minutes, one
52// ask for the whole app (when it was last asked, the answer and any back-off sit in
53// the shared store). Between asks the figures every reply carries keep it current.
54const PLAN_EVERY_MS = 2 * 60_000
55// timers drift: a 2-minute tick a moment early still counts as due
56const PLAN_SLACK_MS = 10_000
57// after a failed ask wait 2, then 5, then 15 minutes, or what the service says
58const PLAN_BACKOFF_MS = [120_000, 300_000, 900_000]
59const LOCAL_EVERY_MS = 3_000
60
61type Backoff = { until: number; failures: number; error: string }
62/** 'no': the 3s tick, never asks; 'due': asks if 2 minutes have passed app-wide; 'now': asks unless backed off */
63type Ask = 'no' | 'due' | 'now'
64
65class AskError extends Error {
66 constructor(
67 message: string,
68 readonly retryAfterMs?: number,
69 ) {
70 super(message)
71 }
72}
73
74// The plan's limits as the app's "Plan usage limits" panel reads them, through the
75// session's own credential (the plugin never sees it).
76async function fetchPlan($: EngineInterface): Promise<PlanLimit[] | null> {
77 const auth = await $.session.authorize()
78 // no Claude login (an API key, a gateway): there is no plan to ask about
79 if (!auth || auth.kind !== 'bearer') return null
80 const res = await $.http.fetch('https://api.anthropic.com/api/oauth/usage', {
81 auth: auth.handle,
82 headers: { 'anthropic-beta': 'oauth-2025-04-20', 'content-type': 'application/json' },
83 })
84 if (!res.ok) {
85 const header = Object.entries(res.headers ?? {}).find(([k]) => k.toLowerCase() === 'retry-after')?.[1]
86 const seconds = Number(Array.isArray(header) ? header[0] : header)
87 const retry = Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : undefined
88 throw new AskError(res.status === 429 ? 'refused (429)' : `failed (${res.status})`, retry)
89 }
90 const body = JSON.parse(res.text) as Record<string, { utilization?: number | null; resets_at?: string | null } | null>
91 const limits: PlanLimit[] = []
92 for (const kind of Object.keys(WINDOWS)) {
93 const w = body[kind]
94 if (!w || typeof w.utilization !== 'number') continue
95 limits.push({ kind, percentUsed: Math.round(w.utilization * 10) / 10, resetsAt: w.resets_at ?? undefined })
96 }
97 if (limits.length === 0) throw new AskError('answer had no limits')
98 return limits
99}
100
101type Saved = { at: number; limits: PlanLimit[] }
102
103// this chat's own reading from its replies, and when it last changed
104let live: Saved | undefined
105
106async function storeGet($: EngineInterface, key: string): Promise<unknown> {
107 try {
108 return await $.store.get(key)
109 } catch {
110 return undefined
111 }
112}
113
114async function storeSet($: EngineInterface, key: string, value: unknown): Promise<void> {
115 try {
116 await $.store.set(key, value)
117 } catch {
118 // this chat still has it
119 }
120}
121
122const sameWindow = (a: string | undefined, b: string | undefined) =>
123 a !== undefined && b !== undefined && Math.abs(Date.parse(a) - Date.parse(b)) <= SAME_WINDOW_MS
124
125// Two sources, and the newest wins: the usage service (asked seldom, shared by every
126// chat through the store) and the figures each reply carries (this chat's, stamped
127// when they last changed). A window that has reset since is dropped.
128async function limitsOf(
129 $: EngineInterface,
130 rateLimits: SessionRateLimit[],
131 now: number,
132 ask: Ask,
133): Promise<{ limits: PlanLimit[]; at?: number; error?: string }> {
134 if (rateLimits.length > 0) {
135 const limits = rateLimits.map(r => ({ kind: r.kind, percentUsed: r.percentUsed, resetsAt: r.resetsAt }))
136 if (!live || JSON.stringify(live.limits) !== JSON.stringify(limits)) live = { at: now, limits }
137 }
138 let saved = (await storeGet($, 'planSaved')) as Saved | undefined
139 let backoff = (await storeGet($, 'planBackoff')) as Backoff | undefined
140 const askedAt = ((await storeGet($, 'planAskedAt')) as number | undefined) ?? 0
141 const isFree = now >= (backoff?.until ?? 0)
142 const isDue = ask === 'now' || (ask === 'due' && now - askedAt >= PLAN_EVERY_MS - PLAN_SLACK_MS)
143 if (isDue && isFree) {
144 // claim the ask first, so the other chats see it taken and skip theirs
145 await storeSet($, 'planAskedAt', now)
146 try {
147 const plan = await fetchPlan($)
148 if (plan) {
149 saved = { at: now, limits: plan }
150 await storeSet($, 'planSaved', saved)
151 await storeSet($, 'planLimits', saved)
152 }
153 if (backoff) await storeSet($, 'planBackoff', { until: 0, failures: 0, error: '' })
154 backoff = undefined
155 } catch (error) {
156 const failures = (backoff?.failures ?? 0) + 1
157 const wait =
158 error instanceof AskError && error.retryAfterMs !== undefined
159 ? error.retryAfterMs
160 : PLAN_BACKOFF_MS[Math.min(failures - 1, PLAN_BACKOFF_MS.length - 1)]!
161 backoff = { until: now + wait, failures, error: error instanceof Error ? error.message : String(error) }
162 await storeSet($, 'planBackoff', backoff)
163 }
164 }
165 const pick = [saved, live].filter((r): r is Saved => r !== undefined).sort((x, y) => y.at - x.at)[0]
166 // newer reply figures go to the store too, so other chats get them
167 if (pick && pick === live && (!saved || live.at > saved.at)) await storeSet($, 'planSaved', live)
168 // The reply figures run behind the usage service: whole percents, seen at 18 for an
169 // hour while the service said 19.0. Use only climbs within a window, so a reply
170 // figure below the service's last answer is stale and the higher one is kept.
171 const plan = (await storeGet($, 'planLimits')) as Saved | undefined
172 const finer = (l: PlanLimit): PlanLimit => {
173 const p = plan?.limits.find(x => x.kind === l.kind)
174 return p && sameWindow(p.resetsAt, l.resetsAt) && p.percentUsed > l.percentUsed ? { ...l, percentUsed: p.percentUsed } : l
175 }
176 // a window that has reset since stays, at 0: drawn as not running
177 const limits = (pick?.limits ?? []).map(l =>
178 l.resetsAt !== undefined && Date.parse(l.resetsAt) <= now ? { kind: l.kind, percentUsed: 0 } : finer(l),
179 )
180 return { limits, at: pick?.at, error: backoff?.error || undefined }
181}
182
183// Context in three groups: Messages, Tools (System + MCP tools) and Other (the system
184// prompt, skills, memory files, agents, MCP server instructions).
185const TOOL_NAMES = new Set(['System tools', 'MCP tools'])
186type Group = 'Messages' | 'Tools' | 'Other'
187const groupOf = (name: string): Group => (name === 'Messages' ? 'Messages' : TOOL_NAMES.has(name) ? 'Tools' : 'Other')
188
189function sumGroups(categories: readonly { name: string; tokens: number; kind: string }[]): Record<Group, number> {
190 const sums: Record<Group, number> = { Messages: 0, Tools: 0, Other: 0 }
191 for (const c of categories) if (c.kind === 'used') sums[groupOf(c.name)] += c.tokens
192 return sums
193}
194
195// The free local breakdown ('summary') estimates each category from its text, which
196// reads tools ~1.3-1.6x heavy. The exact count ('full', what /context shows) costs no
197// tokens but sends one token-count request per tool and memory file, so it runs every
198// 2 minutes and its exact/estimate ratio of Tools and Other scales the 3s estimate.
199const CALIBRATE_EVERY_MS = 2 * 60_000
200let calib: Partial<Record<Group, number>> = {}
201let isCalibrating = false
202// Right after /compact there is no API reply yet, so the window's real total is unknown;
203// the exact count fills that gap until the next reply.
204let exactCount: { at: number; sums: Record<Group, number> } | undefined
205let noReplySince: number | undefined
206
207let isRefreshing = false
208// an ask that came while a refresh ran: done right after it
209let pendingAsk: Ask = 'no'
210let lastSnapshot = ''
211let hadError = false
212
213async function calibrate($: EngineInterface): Promise<void> {
214 if (isCalibrating) return
215 isCalibrating = true
216 try {
217 const exact = await $.session.usage({ breakdown: 'full' })
218 const rough = await $.session.usage({ breakdown: 'summary' })
219 const real = sumGroups(exact.context.breakdown?.categories ?? [])
220 const guess = sumGroups(rough.context.breakdown?.categories ?? [])
221 const next = { ...calib }
222 for (const g of ['Tools', 'Other'] as const) if (guess[g] > 0 && real[g] > 0) next[g] = real[g] / guess[g]
223 calib = next
224 exactCount = { at: await $.clock.now(), sums: real }
225 await storeSet($, 'calib', next)
226 lastSnapshot = ''
227 } catch (error) {
228 $.ui.log(`dock: exact count failed: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
229 } finally {
230 isCalibrating = false
231 }
232 // the usage service is asked alongside, on the same 2 minutes
233 await refresh($, 'due')
234}
235
236async function refresh($: EngineInterface, ask: Ask = 'no'): Promise<void> {
237 if (isRefreshing) {
238 if (ask === 'now' || (ask === 'due' && pendingAsk === 'no')) pendingAsk = ask
239 return
240 }
241 isRefreshing = true
242 try {
243 const now = await $.clock.now()
244 const usage = await $.session.usage({ breakdown: 'summary' })
245 const { context } = usage
246 const breakdown = context.breakdown
247 const all = (breakdown?.categories ?? []).filter(c => c.kind !== 'deferred' && c.tokens > 0)
248 const rough = sumGroups(all)
249 if (context.tokens === undefined) {
250 // no reply since the chat opened or was compacted: get the exact count once
251 if (noReplySince === undefined) {
252 noReplySince = now
253 $.clock.after(0, () => void calibrate($))
254 }
255 } else noReplySince = undefined
256 const useExact = context.tokens === undefined && exactCount !== undefined && noReplySince !== undefined && exactCount.at >= noReplySince
257 let tools = Math.round(rough.Tools * (calib.Tools ?? 1))
258 let other = Math.round(rough.Other * (calib.Other ?? 1))
259 // Messages is what the window holds beyond everything else, as /context counts it:
260 // the API's real input total less the other groups, not the local estimate
261 let messages = context.tokens !== undefined && context.tokens - tools - other > 0 ? context.tokens - tools - other : rough.Messages
262 if (useExact) ({ Messages: messages, Tools: tools, Other: other } = exactCount!.sums)
263 const used = context.tokens ?? (useExact ? messages + tools + other : (breakdown?.totalTokens ?? 0))
264 const categories: Category[] = [
265 { name: 'Messages', tokens: messages, kind: 'used' as const },
266 { name: 'Tools', tokens: tools, kind: 'used' as const },
267 { name: 'Other', tokens: other, kind: 'used' as const },
268 ...all
269 .filter(c => c.kind === 'buffer' || c.kind === 'free')
270 .map(c => ({ name: c.name, tokens: c.tokens, kind: c.kind as Category['kind'] })),
271 ].filter(c => c.tokens > 0)
272 const { limits, at: limitsAt, error: limitsError } = await limitsOf($, usage.rateLimits, now, ask)
273 const next: Snapshot = {
274 window: context.window,
275 tokens: used,
276 percent: useExact
277 ? Math.round((used / context.window) * 100)
278 : (context.percent ?? breakdown?.percentage ?? Math.round((used / context.window) * 100)),
279 categories,
280 limits,
281 ...(limitsAt !== undefined ? { limitsAt } : {}),
282 ...(limitsError ? { limitsError } : {}),
283 // the minute, so countdowns redraw even when nothing else moves
284 minute: Math.floor(now / 60_000),
285 }
286 // redraw only when something moved, not on every tick
287 const json = JSON.stringify(next)
288 if (json !== lastSnapshot) {
289 lastSnapshot = json
290 await update($, snapshot, () => next)
291 }
292 if (hadError) $.ui.status(undefined)
293 hadError = false
294 } catch (error) {
295 const message = error instanceof Error ? error.message : String(error)
296 $.ui.status(`dock: ${message.replace(/^dock: /, '')}`)
297 hadError = true
298 } finally {
299 isRefreshing = false
300 }
301 if (pendingAsk !== 'no') {
302 const queued = pendingAsk
303 pendingAsk = 'no'
304 await refresh($, queued)
305 }
306}
307
308/** both windows once there is any reading: one the reading lacks is not running */
309function windowsOf(snap: Snapshot): PlanLimit[] {
310 if (snap.limits.length === 0) return []
311 return Object.keys(WINDOWS).map(kind => snap.limits.find(l => l.kind === kind) ?? { kind, percentUsed: 0 })
312}
313
314function attentionOf(snap: Snapshot): Attention {
315 const levels = [
316 ...windowsOf(snap).map(l => levelOf(l.percentUsed, LIMIT_AMBER_AT, LIMIT_RED_AT)),
317 levelOf(snap.percent, CONTEXT_AMBER_AT, CONTEXT_RED_AT),
318 ]
319 return levels.includes('alert') ? 'alert' : levels.includes('warn') ? 'warn' : 'none'
320}
321
322export const USAGE: TabInfo = { id: 'usage', title: 'Usage' }
323
324export async function usageTab($: EngineInterface, req: TabRequest): Promise<unknown> {
325 switch (req.kind) {
326 case 'sessionStart':
327 calib = ((await storeGet($, 'calib')) as typeof calib | undefined) ?? {}
328 // a chat just opened: its limits now, unless another chat asked a moment ago
329 await refresh($, 'due')
330 // the exact count on a timer, never from the hook itself: work a hook starts and
331 // leaves running ends with its dispatch
332 $.clock.after(2_000, () => void calibrate($))
333 $.clock.every(CALIBRATE_EVERY_MS, () => void calibrate($))
334 $.clock.every(LOCAL_EVERY_MS, () => void refresh($))
335 return
336 case 'sessionMeasure':
337 case 'turnComplete':
338 await refresh($)
339 return
340 case 'summary': {
341 const snap = await read($, snapshot)
342 if (!snap) return '…'
343 const parts = [...windowsOf(snap).map(l => l.percentUsed), snap.percent].map(p => String(Math.round(p)))
344 return `${parts.join('·')}%`
345 }
346 case 'attention': {
347 const snap = await read($, snapshot)
348 return snap ? attentionOf(snap) : 'none'
349 }
350 case 'render':
351 return render($, req.ui, req.width)
352 default:
353 return undefined
354 }
355}
356
357async function render($: EngineInterface, ui: ElementTable, width: number): Promise<RenderNode> {
358 const { Box, Text } = ui
359 const snap = await read($, snapshot)
360 if (!snap) return <Text color={MUTED}>Reading usage…</Text>
361 const now = await $.clock.now()
362 const LABEL = 8
363 // label, bar, then "100%" and two gaps; the rest of the row is the note
364 const cells = Math.max(8, Math.min(24, width - LABEL - 6 - 32))
365
366 const age = snap.limitsAt === undefined ? 0 : now - snap.limitsAt
367 const windows = windowsOf(snap)
368 const stale =
369 snap.limitsError && age >= 2 * 60_000 && windows.length > 0
370 ? `usage check ${snap.limitsError} – figures from ${duration(age)} ago`
371 : snap.limitsError && windows.length === 0
372 ? `usage check ${snap.limitsError}`
373 : undefined
374
375 const pct = (p: number, color: string) => (
376 <Box width={6} flexShrink={0} justifyContent="flex-end">
377 <Text bold color={color}>{`${Math.round(p)}%`}</Text>
378 </Box>
379 )
380
381 const limitRows = windows.map(l => {
382 const color = COLOR[levelOf(l.percentUsed, LIMIT_AMBER_AT, LIMIT_RED_AT)]
383 const resetsAt = l.resetsAt === undefined ? NaN : Date.parse(l.resetsAt)
384 const note = Number.isFinite(resetsAt) && l.percentUsed > 0 ? `resets in ${duration(resetsAt - now)}` : 'starts with your next message'
385 return (
386 <Box key={l.kind} flexDirection="row">
387 <Box width={LABEL} flexShrink={0}>
388 <Text bold>{WINDOWS[l.kind]?.label ?? l.kind}</Text>
389 </Box>
390 <Bar ui={ui} cells={cells} segments={[{ color, share: Math.min(100, l.percentUsed) }, { color: TRACK, share: Math.max(0, 100 - l.percentUsed) }]} />
391 {pct(l.percentUsed, color)}
392 <Box marginLeft={2} flexShrink={1}>
393 <Text color={MUTED} wrap="truncate-end">{note}</Text>
394 </Box>
395 </Box>
396 )
397 })
398
399 const group = (name: string) => snap.categories.find(c => c.kind === 'used' && c.name === name)?.tokens ?? 0
400 const buffer = snap.categories.filter(c => c.kind === 'buffer').reduce((sum, c) => sum + c.tokens, 0)
401 const free = Math.max(0, snap.window - snap.tokens - buffer)
402 const contextColor = COLOR[levelOf(snap.percent, CONTEXT_AMBER_AT, CONTEXT_RED_AT)]
403
404 return (
405 <Box flexDirection="column">
406 {stale ? <Text color={AMBER} wrap="wrap">{stale}</Text> : null}
407 {windows.length === 0 && !stale ? <Text color={MUTED}>Usage limits show after the first reply.</Text> : null}
408 {limitRows}
409 <Box flexDirection="row">
410 <Box width={LABEL} flexShrink={0}>
411 <Text bold>Context</Text>
412 </Box>
413 <Bar
414 ui={ui}
415 cells={cells}
416 segments={[
417 { color: MESSAGES, share: group('Messages') },
418 { color: TOOLS, share: group('Tools') },
419 { color: OTHER, share: group('Other') + buffer },
420 { color: TRACK, share: free },
421 ]}
422 />
423 {pct(snap.percent, contextColor)}
424 <Box marginLeft={2} flexShrink={1}>
425 <Text color={MUTED} wrap="truncate-end">{`${tokens(free)} free of ${tokens(snap.window)}`}</Text>
426 </Box>
427 </Box>
428 <Box flexDirection="row" marginLeft={LABEL} columnGap={2}>
429 <Text color={MESSAGES}>{`Messages ${tokens(group('Messages'))}`}</Text>
430 <Text color={TOOLS}>{`Tools ${tokens(group('Tools'))}`}</Text>
431 <Text color={MUTED}>{`Other ${tokens(group('Other'))}`}</Text>
432 </Box>
433 </Box>
434 )
435}
436hooks/widgets.tsx 137 lines1import type { ElementTable, RenderNode } from 'claude-code'
2
3import type { Attention } from './tab'
4
5export const MUTED = '#8b90a0'
6export const GREEN = '#4ade80'
7export const AMBER = '#fbbf24'
8export const RED = '#f87171'
9export const TRACK = '#2d3140'
10export const ACCENT = '#5b6ee1'
11export const CHIP = '#3a3f4b'
12export const WHITE = '#ffffff'
13
14// the empty part of a bar: lighter than TRACK, which is near the terminal's own background
15export const RAIL = '#4b5163'
16
17// no icon while all is well: a mark on every tab would drown the one that matters
18const ICON: Record<Exclude<Attention, 'none'>, { glyph: string; color: string }> = {
19 warn: { glyph: '▲', color: AMBER },
20 alert: { glyph: '✖', color: RED },
21}
22
23export type TabLabel = { id: string; title: string; summary: string; attention: Attention }
24
25/**
26 * Tabs as the question dialog draws them: the active one under a solid accent
27 * block, every Text inside it given the same background so the block has no gaps.
28 */
29export function TabBar(props: {
30 ui: ElementTable
31 tabs: TabLabel[]
32 activeId: string
33 onSelect: (id: string) => void
34 onStep: (delta: number) => void
35}): RenderNode {
36 const { Box, Button, Text } = props.ui
37 return (
38 <Box flexDirection="row">
39 <Button key="dock-prev" plain onPress={() => props.onStep(-1)}>
40 <Text color={MUTED}>◂</Text>
41 </Button>
42 <Text> </Text>
43 {props.tabs.map(tab => {
44 const isActive = tab.id === props.activeId
45 const icon = tab.attention === 'none' ? undefined : ICON[tab.attention]
46 const bg = isActive ? ACCENT : undefined
47 // The pointer inverts a Button: every Text turns one color under it first, so
48 // the inverted tab is one solid block, not a patchwork of its colors
49 const hover = { color: isActive ? WHITE : ACCENT, dimColor: false }
50 return (
51 <Box key={`dock-tabbox-${tab.id}`} flexShrink={0}>
52 <Button key={`dock-tab-${tab.id}`} plain onPress={() => props.onSelect(tab.id)}>
53 <Text backgroundColor={bg} hover={hover}> </Text>
54 {icon ? (
55 <Text backgroundColor={bg} color={icon.color} hover={hover}>
56 {`${icon.glyph} `}
57 </Text>
58 ) : null}
59 <Text backgroundColor={bg} color={isActive ? WHITE : undefined} bold={isActive} dimColor={!isActive} hover={hover}>
60 {tab.title}
61 </Text>
62 <Text backgroundColor={bg} color={isActive ? WHITE : MUTED} hover={hover}>{` ${tab.summary} `}</Text>
63 </Button>
64 </Box>
65 )
66 })}
67 <Text> </Text>
68 <Button key="dock-next" plain onPress={() => props.onStep(1)}>
69 <Text color={MUTED}>▸</Text>
70 </Button>
71 </Box>
72 )
73}
74
75export function Rule(props: { ui: ElementTable; width: number }): RenderNode {
76 const { Text } = props.ui
77 return <Text color={TRACK}>{'─'.repeat(Math.max(1, props.width))}</Text>
78}
79
80/** a thin bar: each share as a run of ━ in its color, what is left as a dim ─ rail */
81export function Bar(props: { ui: ElementTable; cells: number; segments: { color: string; share: number }[] }): RenderNode {
82 const { Box, Text } = props.ui
83 const total = props.segments.reduce((sum, s) => sum + s.share, 0) || 1
84 const runs: { color: string; width: number }[] = []
85 let used = 0
86 for (const seg of props.segments) {
87 const width = Math.min(props.cells - used, Math.round((seg.share / total) * props.cells))
88 if (width <= 0) continue
89 used += width
90 const last = runs[runs.length - 1]
91 if (last && last.color === seg.color) last.width += width
92 else runs.push({ color: seg.color, width })
93 }
94 if (used < props.cells) runs.push({ color: TRACK, width: props.cells - used })
95 return (
96 <Box flexDirection="row" width={props.cells} flexShrink={0}>
97 {runs.map(run =>
98 run.color === TRACK ? <Text color={RAIL}>{'─'.repeat(run.width)}</Text> : <Text color={run.color}>{'━'.repeat(run.width)}</Text>,
99 )}
100 </Box>
101 )
102}
103
104/** ‹ value › with the value on a grey chip */
105export function Stepper(props: {
106 ui: ElementTable
107 id: string
108 text: string
109 isOn: boolean
110 onDec: () => void
111 onInc: () => void
112}): RenderNode {
113 const { Box, Button, Text } = props.ui
114 return (
115 <Box flexDirection="row" flexShrink={0}>
116 <Button key={`${props.id}-dec`} plain onPress={props.onDec}>
117 <Text color={MUTED}>‹</Text>
118 </Button>
119 <Text> </Text>
120 <Text backgroundColor={CHIP} bold={props.isOn} dimColor={!props.isOn}>{` ${props.text.padStart(2)} `}</Text>
121 <Text> </Text>
122 <Button key={`${props.id}-inc`} plain onPress={props.onInc}>
123 <Text color={MUTED}>›</Text>
124 </Button>
125 </Box>
126 )
127}
128
129export function Switch(props: { ui: ElementTable; id: string; isOn: boolean; onToggle: () => void }): RenderNode {
130 const { Button, Text } = props.ui
131 return (
132 <Button key={`${props.id}-toggle`} plain onPress={props.onToggle}>
133 {props.isOn ? <Text color={GREEN}>━━● ON </Text> : <Text color={MUTED}>○━━ OFF</Text>}
134 </Button>
135 )
136}
137types/index.d.ts 34 lines1export type Category = { name: string; tokens: number; kind: 'used' | 'free' | 'buffer' }
2export type PlanLimit = { kind: string; percentUsed: number; resetsAt?: string }
3export type Snapshot = {
4 window: number
5 tokens: number
6 percent: number
7 categories: Category[]
8 limits: PlanLimit[]
9 /** when the limit figures were read */
10 limitsAt?: number
11 /** why the usage service last refused, while it is backed off */
12 limitsError?: string
13 /** the minute it was taken in: redraws countdowns at least once a minute */
14 minute?: number
15}
16
17/** value: the cap when on; isOn false keeps value for when it is turned back on */
18export type Limit = { value: number; isOn: boolean }
19export type Limits = { lines: Limit; tools: Limit }
20/** hit: a tool call was denied, or the answer ran past an active line cap */
21export type LastTurn = { tools: number; lines: number; hit: boolean }
22
23declare module 'claude-code' {
24 interface PluginState {
25 dock: {
26 isOn: boolean
27 activeTab: string
28 snapshot: Snapshot | null
29 limits: Limits
30 last: LastTurn | null
31 }
32 }
33}
34