SLOPSHOPPER

dock

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

newbandguardcommandstatusprompt
v0.1.1MITupdated 2026-10-22luytbq/dotfiles/.claude/mods/dock
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dock
› 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 › /dock ⎿ dock: Dock hidden. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

dock

A band above the Claude Code prompt that holds tabs, one kind of information per tab. Built for the terminal.

  • Usage: the 5 hour and weekly plan limits (% used, time to reset) and the context window (% used, free space, Messages / Tools / Other). Read from Anthropic's usage service about every 2 minutes, shared across chats, and from each reply in between.
  • Limit: caps per turn on the final answer's lines and on tool calls. Tool calls past the cap are denied by the harness, subagents included; the line cap is an instruction added to the system prompt. Use the stepper to change a cap (it turns the cap on) and the switch to turn it on or off.

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.

Source 6 files
hooks/register.tsx 124 lines
1import { 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}
124
hooks/tab.ts 42 lines
1import 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
42
hooks/tabs/limit.tsx 143 lines
1import { 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}
143
hooks/tabs/usage.tsx 436 lines
1import { 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}
436
hooks/widgets.tsx 137 lines
1import 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}
137
types/index.d.ts 34 lines
1export 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