SLOPSHOPPER

hud-pro

One quiet line at the bottom of the band, terminal and desktop: 5h limit with reset, 7d, context and session ≈cost; /hud full shows details

newpanebandcommandtoasttimer
★ 2v?MITupdated 2026-10-16partalemre/MODZ/mods/hud-pro
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hud-pro
│ ┃ hud-pro ✕ › fix the failing auth test and add an audit log call │ ┃ Limits 1 reading · session ≈$0.42 │ ┃ ⏺ Read(src/auth.ts) │ ┃ 5h ━━────── 31% ⎿ Read 6 lines │ ┃ context ━━━━──── 49% ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Context breakdown 97.4k / 200k ⏺ Bash(bun test) │ ┃ No breakdown yet. ⎿ 3 pass, 1 fail │ ┃ │ ┃ [ Compact now ] ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /hud │ ⎿ hud-pro: HUD hidden. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · hud-pro
Limits 1 reading · session ≈$0.42 5h ━━────── 31% context ━━━━──── 49% Context breakdown 97.4k / 200k No breakdown yet. [ Compact now ]
README

hud-pro

Puts one quiet line above the prompt with your 5-hour and 7-day limits, the context window and the session cost.

Install

/plugin marketplace add partalemre/MODZ
/plugin install hud-pro@modz

Or in one step: /plugin install hud-pro --marketplace partalemre/MODZ.

The MODZ panel (modz) shows its line here, for example 5h 44% · 7d 17%. The tone turns amber when the 5-hour window passes 80% and red from 95%.

What you see

5h ━━┊─── 32% ↻ 2h 10m   7d ━━━━┊─ 91% ↻ 3d 22h   context ━━──── 41%   ≈$1.23
  • Desktop: one picture line with a pill for each limit and for the session cost: a label (5-hour), a thin meter with a pace tick (where you would be if the window were spent evenly), the value, and the time left until it resets. Hovering shows details. In a narrow band the 7-day reset drops first, then the meters shorten, then the labels, and last the meters, the 5-hour reset and the cost.
  • Terminal: the same information as one line of text. A wide band shows ━━┊─── meters; as it narrows, the bars go first, then the resets and the cost.
  • Colour: neutral by default. A window 10 points ahead of its pace turns amber, and from 95% red, with its reset kept even in the narrowest band. At 100% the value reads full. Context turns amber from 70%.
  • Compact: when context is at 70% or more and Claude is idle, a small Compact button (s) appears. It compacts the conversation through the same engine call /compact makes.
  • Estimates (marked ~): when the 5-hour window is on course to fill within the hour, its pill adds the time it fills, for example ~full 15:40. When the context is five turns or fewer from compacting, its pill adds ~3 turns. Nothing is shown without a basis.
  • Toast: when a plan limit passes 90%, one short toast per window says so, with when it resets.

The line sits beneath other mods' rows without swallowing them, and steps aside while a survey is open.

Commands

  • /hud hides or shows the line (remembered across sessions).
  • /hud full opens a Usage pane: stat tiles for the 5-hour window, 7-day window, context and session cost, each with a pace marker and a sparkline of the last 96 readings, plus the last turn's token counts (input, output, cache, requests).
  • The 5-hour window, when it fills before it resets at its recent rate, says when: ~full at 15:40 (local time, with the weekday when it is not today).
  • A Context breakdown card: one bar of the window by category (system prompt, tools, MCP tools, memory files, skills, messages…), the four largest named with their tokens and the rest as "Other", then the free space and the compaction reserve. Under it, a sparkline of the context at the end of the last 12 turns and ~N turns left until auto-compaction. A Compact now button compacts and then shows the result, for example Compacted 152k → 38k.

Settings

There are no plugin options.

Limits

  • Rate limits are reported only on a subscription. With an API key the line shows context and cost.
  • The cost is the engine's own session total, shown with ≈. Pace is drawn only for windows with a known length (5-hour and 7-day).
  • A fill time needs at least 3 readings spread over 10 minutes within the last hour, read as a least-squares rate; a flat or falling window, or one that resets first, has none. The 7-day window never gets one: an hour's rate says little about a week.
  • The turns forecast is the average growth per turn since the last compaction, from at least 3 turns. It counts up to the auto-compaction point once the Usage pane has read the breakdown, and to the full window before that.
  • The breakdown is Claude Code's own local estimate (the summary count, not the token-count API). It is read only while the Usage pane draws, at most every 20 seconds.
  • An idle line refreshes once a minute; nothing else runs.

Uses no model tokens of its own: it only reads the usage figures Claude Code already measures. Compact and Compact now run only when you press them and cost what /compact costs.

Language

The interface is English. Set MODZ_LANG=tr in the env block of ~/.claude/settings.json for Turkish.

Source 30 files
hooks/register.tsx 293 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { bandRow } from './band'
5import type { ContextView } from './breakdown'
6import { compactedOf, forecastsFrom, freshBreakdown, keepBreakdown, keepLast, reasonOf } from './context'
7import { addSample, isHistory } from './history'
8import { EMPTY_HUB, HUB_PANE, hubOf, isOwnOpen } from './hub'
9import { setLang } from './lang'
10import { gaugeViews } from './model'
11import type { Forecasts } from './model'
12import { paneTree, withHome } from './pane'
13import type { CardData } from './pane'
14import { motion } from './pane-motion'
15import { isSame } from './same'
16import { pinned } from './pin'
17import { isOffStage, shownOf } from './stage'
18import { EMPTY_TURN, onTurns } from './turns-hook'
19import { newNotices, toUsage } from './usage'
20import { words } from './words'
21import type { HudUsage } from '../types'
22
23const PANE = 'hud-pro'
24const HIDDEN_KEY = 'hud-pro.hidden'
25const HISTORY_KEY = 'hud-pro.history'
26// Reset countdowns move by the minute, so the idle line redraws once a minute.
27const REFRESH_MS = 60_000
28
29const usageAtom = atom({ plugin: 'hud-pro', key: 'usage' } as const, null)
30const turnAtom = atom({ plugin: 'hud-pro', key: 'turn' } as const, EMPTY_TURN)
31const lastTurnAtom = atom({ plugin: 'hud-pro', key: 'lastTurn' } as const, null)
32const hiddenAtom = atom({ plugin: 'hud-pro', key: 'isHidden' } as const, false)
33const historyAtom = atom({ plugin: 'hud-pro', key: 'history' } as const, [])
34const compactingAtom = atom({ plugin: 'hud-pro', key: 'isCompacting' } as const, false)
35const contextTurnsAtom = atom({ plugin: 'hud-pro', key: 'contextTurns' } as const, [])
36const lastCompactAtom = atom({ plugin: 'hud-pro', key: 'lastCompact' } as const, null)
37const hubAtom = atom({ plugin: 'hud-pro', key: 'hub' } as const, EMPTY_HUB)
38const liveAtom = atom({ plugin: 'hud-pro', key: 'isLive' } as const, false)
39
40const noticed = new Set<string>()
41// What the minute clock last moved on screen: the gauges' countdowns, pace marks and estimates.
42let shownGauges: unknown = null
43
44async function record($: EngineInterface, fresh: HudUsage): Promise<void> {
45  const nowMs = await $.clock.now()
46  const before = await read($, historyAtom)
47  const after = addSample(before, fresh, nowMs)
48  if (after === before) return
49  await update($, historyAtom, () => after)
50  await $.store.set(HISTORY_KEY, after)
51}
52
53// The MODZ card: written once at the start, then only when what it shows changes.
54async function writeHub($: EngineInterface, usage: HudUsage, isFirst = false): Promise<void> {
55  const fresh = hubOf(usage)
56  const current = await read($, hubAtom)
57  if (!isFirst && current.detail === fresh.detail && current.tone === fresh.tone) return
58  const at = await $.clock.now()
59  await update($, hubAtom, () => ({ pane: HUB_PANE, ...fresh, at }))
60}
61
62async function noticeHigh($: EngineInterface, fresh: HudUsage): Promise<void> {
63  for (const text of newNotices(fresh, { nowMs: await $.clock.now(), noticed })) $.ui.toast(text)
64}
65
66async function forecastsOf($: EngineInterface, usage: HudUsage | null): Promise<Forecasts> {
67  return forecastsFrom({ history: await read($, historyAtom), turns: await read($, contextTurnsAtom), usage })
68}
69
70// The window by category while the pane draws: the local estimate, read again once it is stale.
71async function breakdownView($: EngineInterface): Promise<ContextView | null> {
72  const nowMs = await $.clock.now()
73  const fresh = freshBreakdown(nowMs)
74  if (fresh !== undefined) return fresh
75  try {
76    return keepBreakdown(nowMs, (await $.session.usage({ breakdown: 'summary' })).context.breakdown)
77  } catch {
78    $.ui.log('hud-pro: context breakdown failed', { to: 'debug' })
79
80    return keepLast(nowMs)
81  }
82}
83
84// Compacts as /compact does, through the engine's own call. A press's own work is abandoned when the
85// press ends, so a timer starts it in a dispatch of its own that lasts until it ends; the buttons
86// hide until then. A refusal (a turn running) says why in one toast.
87function compactNow($: EngineInterface): void {
88  void update($, compactingAtom, () => true)
89  $.clock.after(1, () =>
90    $.session
91      .compact()
92      .then(async result => {
93        const done = compactedOf(result, await $.clock.now())
94        if (done !== null) await update($, lastCompactAtom, () => done)
95      })
96      .catch((error: unknown) => $.ui.toast(words().compactFailed(reasonOf(error))))
97      .finally(() => update($, compactingAtom, () => false)),
98  )
99}
100
101async function toggle($: EngineInterface): Promise<{ text: string }> {
102  await update($, hiddenAtom, isHidden => !isHidden)
103  const isHidden = await read($, hiddenAtom)
104  await $.store.set(HIDDEN_KEY, isHidden)
105
106  return { text: isHidden ? words().hidden : words().shown }
107}
108
109// Placed, the pane takes a running MODZ hub's panel, so the hub and every other pane step aside (kit/stage.ts).
110// A failed note of that is only logged: the pane is open either way.
111async function reportShown($: EngineInterface): Promise<void> {
112  try {
113    if ((await $.state.get({ plugin: 'modz', key: 'isOn' })).value !== true) return
114    await $.state.set({ plugin: 'hud-pro', key: 'shown' }, shownOf(HUB_PANE, await $.clock.now()))
115  } catch {
116    $.ui.log('hud-pro: could not tell the hub the pane is shown', { to: 'debug' })
117  }
118}
119
120// Opens the pane, or brings it forward when it is open already; never closes it.
121async function openPane($: EngineInterface): Promise<void> {
122  motion.reset()
123  const opened = await $.ui.open({ id: PANE, title: words().paneTitle, focus: true, closeOnEscape: true })
124  if (opened.isPlaced) await reportShown($)
125}
126
127async function runCommand($: EngineInterface, args: string): Promise<{ text?: string }> {
128  if (args.trim().toLowerCase() !== 'full') return toggle($)
129  await openPane($)
130
131  return {}
132}
133
134// The minute clock moves only the gauges' countdowns, pace marks and estimates; a minute that moves
135// none of them redraws nothing, since every redraw also hands out new press handles.
136async function redrawIfMoved($: EngineInterface): Promise<void> {
137  const usage = await read($, usageAtom)
138  const gauges = usage === null ? null : gaugeViews(usage, await $.clock.now(), await forecastsOf($, usage))
139  if (isSame(gauges, shownGauges)) return
140  shownGauges = gauges
141  $.ui.invalidate('ui.render')
142}
143
144async function setup($: EngineInterface): Promise<void> {
145  const current = toUsage(await $.session.usage())
146  await update($, usageAtom, () => current)
147  shownGauges = gaugeViews(current, await $.clock.now(), await forecastsOf($, current))
148  await writeHub($, current, true)
149  await update($, hiddenAtom, () => false)
150  if ((await $.store.get(HIDDEN_KEY)) === true) await update($, hiddenAtom, () => true)
151  const saved = await $.store.get(HISTORY_KEY)
152  if (isHistory(saved)) await update($, historyAtom, () => saved)
153  $.clock.every(REFRESH_MS, () => void redrawIfMoved($))
154  await $.command.register({ name: 'hud', description: words().commandDescription })
155}
156
157type On = Parameters<Register>[0]
158
159function onUsage(on: On) {
160  on('session.start', async ($, e, next) => {
161    // The interface language (kit/lang.ts). Spelled out: the engine admits an env read by a literal name only.
162    setLang(await $.env.get('MODZ_LANG').catch(() => undefined))
163    await setup($).catch(() => $.ui.log('hud-pro: start failed', { to: 'debug' }))
164
165    return next(e)
166  })
167
168  on('session.measure', async ($, e, next) => {
169    try {
170      const fresh = toUsage(e)
171      await update($, usageAtom, () => fresh)
172      await writeHub($, fresh)
173      await noticeHigh($, fresh)
174      await record($, fresh)
175    } catch {
176      $.ui.log('hud-pro: measure failed', { to: 'debug' })
177    }
178
179    return next(e)
180  })
181}
182
183// The MODZ main panel asks for this pane by writing `{ pane: 'usage' }` to `modz.open`. modz's
184// contract is not this mod's to import, so its address stays untyped here.
185function onOpen(on: On) {
186  on('state.set', { plugin: 'modz', key: 'open' } as any, async ($: EngineInterface, e: any, next: any) => {
187    const done = await next(e)
188    if (isOwnOpen(e.value)) await openPane($)
189
190    return done
191  }).catch(($: EngineInterface, e: any, next: any) => next(e))
192  // Another pane took the hub's one panel: this one steps aside rather than wait as a tab.
193  on('state.set', { plugin: 'modz', key: 'stage' } as any, async ($: EngineInterface, e: any, next: any) => {
194    const done = await next(e)
195    if (isOffStage(e.value, HUB_PANE)) await $.ui.close({ id: PANE })
196
197    return done
198  }).catch(($: EngineInterface, e: any, next: any) => next(e))
199}
200
201// The way back to the MODZ hub: written to this mod's own `home` value, which the hub hears. A press
202// never throws out of the handler, so a failed write is logged and the pane stays as it is.
203async function goHome($: EngineInterface): Promise<void> {
204  try {
205    await $.state.set({ plugin: 'hud-pro', key: 'home' }, { at: await $.clock.now() })
206  } catch {
207    $.ui.log('hud-pro: could not go back to the hub', { to: 'debug' })
208  }
209}
210
211async function cardData($: EngineInterface): Promise<CardData> {
212  return {
213    view: await breakdownView($),
214    turns: await read($, contextTurnsAtom),
215    lastCompact: await read($, lastCompactAtom),
216    isCompacting: await read($, compactingAtom),
217    compact: () => compactNow($),
218  }
219}
220
221function onRender(on: On) {
222  on('command.run', { command: 'hud' }, async ($, e) => {
223    try {
224      return await runCommand($, e.args)
225    } catch {
226      return { text: words().commandFailed }
227    }
228  })
229
230  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
231    const turn = await read($, turnAtom)
232    const ui = $.ui.resolve(e)
233    const isHubOn = (await $.state.get({ plugin: 'modz', key: 'isOn' })).value === true
234    const usage = await read($, usageAtom)
235    const tree = paneTree(ui, {
236      usage,
237      history: await read($, historyAtom),
238      turn: turn.requests > 0 ? turn : await read($, lastTurnAtom),
239      columns: e.props.bodyColumns,
240      nowMs: await $.clock.now(),
241      card: await cardData($),
242      isLive: await read($, liveAtom),
243      // The breakdown read first, so the forecast counts up to the compaction point it names.
244      forecasts: await forecastsOf($, usage),
245      ...(e.surface !== 'terminal' && 'Svg' in ui ? { svg: ui.Svg } : {}),
246    })
247
248    return pinned(e, isHubOn ? withHome(ui, { tree, goHome: () => void goHome($) }) : tree)
249  })
250}
251
252function onBand(on: On) {
253  // The bottom row of the band, right above the prompt: what the plugins beneath drew stays above it.
254  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
255    const theirs = await next(e)
256    try {
257      const usage = await read($, usageAtom)
258      if (e.props.hasSurvey || usage === null || (await read($, hiddenAtom))) return theirs
259      const ui = $.ui.resolve(e)
260      const row = bandRow(ui, {
261        usage,
262        columns: e.props.bodyColumns,
263        nowMs: await $.clock.now(),
264        forecasts: await forecastsOf($, usage),
265        isDesktop: e.surface !== 'terminal' && 'Svg' in ui,
266        canCompact: !e.props.isWorking && !(await read($, compactingAtom)),
267        compact: () => compactNow($),
268      })
269      if (row === null) return theirs
270      const { Box } = ui
271
272      return (
273        <Box flexDirection="column">
274          {theirs}
275          {pinned(e, row)}
276        </Box>
277      )
278    } catch {
279      $.ui.log('hud-pro: band render failed', { to: 'debug' })
280
281      return theirs
282    }
283  })
284}
285
286export const register: Register = on => {
287  onUsage(on)
288  onTurns(on)
289  onOpen(on)
290  onRender(on)
291  onBand(on)
292}
293
hooks/band.tsx 58 lines
1import { CONTEXT, CONTEXT_WARN } from './format'
2import { lineTree } from './line'
3import type { Ui } from './line'
4import { gaugeViews } from './model'
5import type { Forecasts } from './model'
6import { lineSvg } from './svg-line'
7import { words } from './words'
8import type { HudUsage } from '../types'
9
10// The HUD's row of the band: the line (Svg on the desktop, text on the terminal) and, once the
11// context is full enough for it to pay, a small "Compact" button beside it.
12
13export type BandInput = {
14  usage: HudUsage
15  columns: number
16  nowMs: number
17  forecasts: Forecasts
18  isDesktop: boolean
19  // False while a turn runs or a compaction is under way: the button hides then.
20  canCompact: boolean
21  compact: () => void
22}
23
24const PX_PER_COLUMN = 6.4
25// The room the button and its gap take beside the desktop line.
26const BUTTON_PX = 104
27export const COMPACT_HOTKEY = 's'
28
29function desktopLine(ui: Ui, input: BandInput, reserve: number) {
30  const { Svg } = ui
31  const drawn = lineSvg({
32    gauges: gaugeViews(input.usage, input.nowMs, input.forecasts),
33    costUsd: input.usage.costUsd,
34    maxWidth: input.columns * PX_PER_COLUMN - reserve,
35  })
36
37  return drawn === null ? null : <Svg source={drawn.source} alt={drawn.alt} width={drawn.width} height={drawn.height} />
38}
39
40export function bandRow(ui: Ui, input: BandInput) {
41  const { Box, Button } = ui
42  const context = input.usage.gauges.find(gauge => gauge.id === CONTEXT)
43  const showsCompact = input.canCompact && context !== undefined && context.percent >= CONTEXT_WARN
44  const line = input.isDesktop
45    ? desktopLine(ui, input, showsCompact ? BUTTON_PX : 0)
46    : lineTree(ui, { gauges: gaugeViews(input.usage, input.nowMs, input.forecasts), costUsd: input.usage.costUsd, columns: input.columns })
47  if (line === null && !showsCompact) return null
48
49  return (
50    <Box key="hud-pro-row" flexDirection="row" alignItems="center" columnGap={2}>
51      {line}
52      {showsCompact && (
53        <Button key="compact" label={words().compact} hotkey={COMPACT_HOTKEY} onPress={input.compact} {...(input.isDesktop ? {} : { plain: true as const })} />
54      )}
55    </Box>
56  )
57}
58
hooks/breakdown.ts 48 lines
1import type { ContextCategory } from 'claude-code'
2
3import { categoryLabel, words } from './words'
4
5// The context card's bar: the window by category, as /context counts it, in a few named parts.
6// The four largest used rows keep their names, the rest fold into "Other"; then the free space and
7// the compaction reserve. Rows on demand (`deferred`) sit outside the window and are left out.
8
9export const TOP_CATEGORIES = 4
10
11export type SegmentKind = 'used' | 'other' | 'free' | 'reserve'
12export type Segment = { label: string; tokens: number; kind: SegmentKind }
13
14export type ContextView = {
15  segments: Segment[]
16  // The bar's whole: every segment together, so each reads as a share of the window.
17  total: number
18  usedTokens: number
19  // Where auto-compaction runs, when it is on; the "~N turns" forecast counts up to it.
20  compactAt: number | null
21}
22
23type BreakdownLike = { categories: readonly ContextCategory[]; autoCompactThreshold?: number; isAutoCompactEnabled: boolean }
24
25const sumOf = (rows: readonly { tokens: number }[]): number => rows.reduce((sum, row) => sum + row.tokens, 0)
26
27function tailSegments(categories: readonly ContextCategory[]): Segment[] {
28  const free = sumOf(categories.filter(row => row.kind === 'free'))
29  const reserve = sumOf(categories.filter(row => row.kind === 'buffer'))
30
31  return [
32    ...(free > 0 ? [{ label: words().freeSpace, tokens: free, kind: 'free' as const }] : []),
33    ...(reserve > 0 ? [{ label: words().reserve, tokens: reserve, kind: 'reserve' as const }] : []),
34  ]
35}
36
37export function contextView(breakdown: BreakdownLike): ContextView | null {
38  const used = breakdown.categories.filter(row => row.kind === 'used' && row.tokens > 0).sort((left, right) => right.tokens - left.tokens)
39  const top = used.slice(0, TOP_CATEGORIES).map(row => ({ label: categoryLabel(row.name), tokens: row.tokens, kind: 'used' as const }))
40  const rest = sumOf(used.slice(TOP_CATEGORIES))
41  const segments = [...top, ...(rest > 0 ? [{ label: words().other, tokens: rest, kind: 'other' as const }] : []), ...tailSegments(breakdown.categories)]
42  const total = sumOf(segments)
43  if (total <= 0) return null
44  const threshold = breakdown.isAutoCompactEnabled ? breakdown.autoCompactThreshold : undefined
45
46  return { segments, total, usedTokens: sumOf(used), compactAt: threshold ?? null }
47}
48
hooks/context.ts 57 lines
1import type { SessionCompactResult, SessionContextBreakdown } from 'claude-code'
2
3import { contextView } from './breakdown'
4import type { ContextView } from './breakdown'
5import { turnsLeftOf } from './forecast'
6import type { Forecasts } from './model'
7import type { HudCompacted, HudSample, HudUsage } from '../types'
8
9// The context card's data, kept apart from the engine calls (which register.tsx makes). The
10// breakdown is the engine's local estimate (`summary`, never `full`, which would count with the
11// token-count API); it costs some work, so the pane reads it at most once per BREAKDOWN_REFRESH_MS.
12
13const BREAKDOWN_REFRESH_MS = 20_000
14
15let cached: { at: number; view: ContextView | null } | null = null
16// Where auto-compaction runs, once a breakdown has said; the turns forecast counts up to it.
17let compactAt: number | null = null
18
19// The kept view while it is fresh; undefined when it is time to read the breakdown again.
20export function freshBreakdown(nowMs: number): ContextView | null | undefined {
21  return cached !== null && nowMs - cached.at < BREAKDOWN_REFRESH_MS ? cached.view : undefined
22}
23
24// Keeps what a read brought: its view, or none when the engine had no breakdown to give.
25export function keepBreakdown(nowMs: number, breakdown: SessionContextBreakdown | undefined): ContextView | null {
26  const view = breakdown === undefined ? null : contextView(breakdown)
27  cached = { at: nowMs, view }
28  if (view !== null) compactAt = view.compactAt
29
30  return view
31}
32
33// A failed read keeps the last view until the next try.
34export function keepLast(nowMs: number): ContextView | null {
35  cached = { at: nowMs, view: cached?.view ?? null }
36
37  return cached.view
38}
39
40export type ForecastInput = { history: readonly HudSample[]; turns: readonly number[]; usage: HudUsage | null }
41
42// The turns forecast counts up to the compaction point once known, else to the whole window.
43export function forecastsFrom(input: ForecastInput): Forecasts {
44  const limit = compactAt ?? input.usage?.context?.window ?? null
45
46  return { history: input.history, turnsLeft: limit === null ? null : turnsLeftOf(input.turns, limit) }
47}
48
49// What a compaction came to; null when a hook vetoed it (the engine says why in its own line).
50export function compactedOf(result: SessionCompactResult, at: number): HudCompacted | null {
51  if (result.skip !== undefined) return null
52
53  return { before: result.tokensBefore ?? null, after: result.tokensAfter ?? null, at }
54}
55
56export const reasonOf = (error: unknown): string => (error instanceof Error ? error.message : String(error))
57
hooks/history.ts 39 lines
1import type { HudSample, HudUsage } from '../types'
2
3export const HISTORY_MAX = 96
4// A flat reading still lands once in a while so the history keeps covering recent time.
5const IDLE_SAMPLE_MS = 300_000
6
7function percentsOf(usage: HudUsage | null): Record<string, number> {
8  const percents: Record<string, number> = {}
9  for (const gauge of usage?.gauges ?? []) percents[gauge.id] = gauge.percent
10
11  return percents
12}
13
14function isSame(left: HudSample, right: HudSample): boolean {
15  const keys = new Set([...Object.keys(left.percents), ...Object.keys(right.percents)])
16
17  return left.cost === right.cost && [...keys].every(key => left.percents[key] === right.percents[key])
18}
19
20export function addSample(history: HudSample[], usage: HudUsage, nowMs: number): HudSample[] {
21  const sample: HudSample = { t: nowMs, percents: percentsOf(usage), ...(usage.costUsd === null ? {} : { cost: usage.costUsd }) }
22  const last = history[history.length - 1]
23  if (last !== undefined && isSame(last, sample) && nowMs - last.t < IDLE_SAMPLE_MS) return history
24
25  return [...history, sample].slice(-HISTORY_MAX)
26}
27
28export function seriesOf(history: HudSample[], id: string): number[] {
29  return history.map(sample => sample.percents[id]).filter((value): value is number => value !== undefined)
30}
31
32export function costSeries(history: HudSample[]): number[] {
33  return history.map(sample => sample.cost).filter((value): value is number => value !== undefined)
34}
35
36export function isHistory(value: unknown): value is HudSample[] {
37  return Array.isArray(value) && value.every(item => typeof item === 'object' && item !== null && 'percents' in item && 't' in item)
38}
39
hooks/hub.ts 33 lines
1import { FIVE_HOUR, labelOf, valueText } from './format'
2import type { HubCard, HubTone, HudUsage } from '../types'
3
4// The MODZ main panel's view of this mod (DESIGN.md §9): a one-line card under `hub`, and the
5// open request it writes to `modz.open` when its row is pressed.
6
7export const HUB_PANE = 'usage'
8const SEVEN_DAY = 'seven_day'
9const HUB_GAUGES = [FIVE_HOUR, SEVEN_DAY] as const
10const WARN_PERCENT = 80
11const BAD_PERCENT = 95
12
13export const EMPTY_HUB: HubCard = { pane: HUB_PANE, detail: '', tone: 'idle', at: 0 }
14
15function hubTone(fivePercent: number): HubTone {
16  if (fivePercent >= BAD_PERCENT) return 'bad'
17
18  return fivePercent >= WARN_PERCENT ? 'warn' : 'idle'
19}
20
21// "5s %44 · 7g %17"; the 5-hour window alone sets the tone, as it is the one that stops work.
22export function hubOf(usage: HudUsage | null): Pick<HubCard, 'detail' | 'tone'> {
23  const gauges = HUB_GAUGES.flatMap(id => usage?.gauges.filter(gauge => gauge.id === id) ?? [])
24  const detail = gauges.map(gauge => `${labelOf(gauge.id)} ${valueText(gauge.percent)}`).join(' · ')
25  const five = gauges.find(gauge => gauge.id === FIVE_HOUR)?.percent ?? 0
26
27  return { detail, tone: hubTone(five) }
28}
29
30// Whether a `modz.open` write asks for this mod's pane.
31export const isOwnOpen = (value: unknown): boolean =>
32  typeof value === 'object' && value !== null && (value as { pane?: unknown }).pane === HUB_PANE
33
hooks/lang.ts 24 lines
1// MODZ kit — the interface language: English, or Turkish when the person sets MODZ_LANG=tr (the "env" of
2// ~/.claude/settings.json). One value per mod: its register file reads the variable at session.start with
3// `setLang(await $.env.get('MODZ_LANG'))` (the engine only admits an env read whose name is a literal), and every
4// drawing reads it back through `pick` and `upper`.
5export type Lang = 'en' | 'tr'
6
7let current: Lang = 'en'
8
9export const langOf = (value: unknown): Lang => (typeof value === 'string' && /^tr/i.test(value.trim()) ? 'tr' : 'en')
10
11export function setLang(value: unknown): Lang {
12  current = langOf(value)
13
14  return current
15}
16
17export const lang = (): Lang => current
18
19// The current language's entry of a table that fills in both: `pick({ en: 'Open', tr: 'Aç' })`.
20export const pick = <T>(table: Readonly<Record<Lang, T>>): T => table[current]
21
22// Capitals for eyebrows and labels: Turkish keeps its dotted İ, English never gets one.
23export const upper = (text: string): string => text.toLocaleUpperCase(current)
24
hooks/model.ts 74 lines
1import { clockText, FULL_WARN_MS, fullAtOf, pointsOf, TURNS_WARN } from './forecast'
2import { CONTEXT, FIVE_HOUR, labelOf, longLabelOf, paceOf, toneOf, untilReset } from './format'
3import type { Tone } from './theme'
4import { words } from './words'
5import type { HudGauge, HudSample, HudUsage } from '../types'
6
7// One gauge as every drawing reads it: labels, value, where its pace is, its tone and reset.
8export type GaugeView = {
9  id: string
10  short: string
11  long: string
12  percent: number
13  pace: number | null
14  tone: Tone
15  reset: string
16  // The estimate the pane reads ("~full at 15:40", "~12 turns left"), and the shorter one the HUD
17  // line reads only once it is near; '' without a basis.
18  forecast: string
19  alert: string
20}
21
22// What the estimates are read from: the readings so far and the context's turns left.
23export type Forecasts = { history: readonly HudSample[]; turnsLeft: number | null }
24
25type Estimate = Pick<GaugeView, 'forecast' | 'alert'>
26
27const NO_ESTIMATE: Estimate = { forecast: '', alert: '' }
28
29function estimateOf(gauge: HudGauge, nowMs: number, forecasts: Forecasts): Estimate {
30  if (gauge.id === CONTEXT) {
31    const turns = forecasts.turnsLeft
32    if (turns === null) return NO_ESTIMATE
33
34    return { forecast: words().turnsLeft(turns), alert: turns <= TURNS_WARN ? words().turnsShort(turns) : '' }
35  }
36  // An hour's burn says little about a week: only the 5-hour window gets a fill time.
37  if (gauge.id !== FIVE_HOUR) return NO_ESTIMATE
38  const fullAt = fullAtOf({ points: pointsOf(forecasts.history, gauge.id), percent: gauge.percent, resetsAt: gauge.resetsAt, nowMs })
39  if (fullAt === null) return NO_ESTIMATE
40  const time = clockText(fullAt, nowMs)
41
42  return { forecast: words().fullAt(time), alert: fullAt - nowMs <= FULL_WARN_MS ? words().fullAtShort(time) : '' }
43}
44
45export function gaugeViews(usage: HudUsage, nowMs: number, forecasts?: Forecasts): GaugeView[] {
46  return usage.gauges.map(gauge => {
47    const pace = paceOf(gauge, nowMs)
48
49    return {
50      id: gauge.id,
51      short: labelOf(gauge.id),
52      long: longLabelOf(gauge.id),
53      percent: gauge.percent,
54      pace,
55      tone: toneOf({ id: gauge.id, percent: gauge.percent, pace }),
56      reset: untilReset(gauge.resetsAt, nowMs),
57      ...(forecasts === undefined ? NO_ESTIMATE : estimateOf(gauge, nowMs, forecasts)),
58    }
59  })
60}
61
62// Which resets a narrowing line keeps: every one, the 5-hour one, or only a critical window's.
63export type Resets = 'all' | 'five' | 'critical'
64
65// A window at or past 95 %: it keeps its reset to the last and is the line's one accent.
66export const isCritical = (gauge: GaugeView): boolean => gauge.tone === 'danger'
67
68export function hasReset(gauge: GaugeView, resets: Resets): boolean {
69  if (gauge.reset === '') return false
70  if (resets === 'all' || isCritical(gauge)) return true
71
72  return resets === 'five' && gauge.id === FIVE_HOUR
73}
74
hooks/pane.tsx 225 lines
1import type { RenderElement } from 'claude-code'
2
3import { AHEAD_POINTS, cachePercent, compact, CONTEXT, costText, percentText, resetText, sparkText, valueText } from './format'
4import { costSeries, seriesOf } from './history'
5import { contextCard } from './context-card'
6import type { CardView, PaneSvg } from './context-card'
7import { HomeButton } from './home'
8import { barTree, toneText } from './line'
9import type { Ui } from './line'
10import { gaugeViews } from './model'
11import type { Forecasts, GaugeView } from './model'
12import { motion } from './pane-motion'
13import { tilesSvg } from './svg-tiles'
14import type { Tile } from './svg-tiles'
15import { words } from './words'
16import type { HudContext, HudSample, HudTurn, HudUsage } from '../types'
17
18// `/hud full`: stat tiles with rings and sparklines on the desktop, rows with text sparklines
19// on the terminal; then the context card, and the turn's tokens under both.
20
21// What the context card reads beyond the pane's own figures.
22export type CardData = Pick<CardView, 'view' | 'turns' | 'lastCompact' | 'isCompacting' | 'compact'>
23
24export type PaneView = {
25  usage: HudUsage | null
26  history: HudSample[]
27  turn: HudTurn | null
28  columns: number
29  nowMs: number
30  forecasts: Forecasts
31  card: CardData
32  // A turn is running: the meters shine.
33  isLive: boolean
34  svg?: PaneSvg
35}
36
37const PX_PER_COLUMN = 6.4
38const BAR_MAX = 24
39const BAR_MIN = 8
40const ROW_CHROME = 48
41const LABEL_WIDTH = 7
42const COST_HEADROOM = 1.15
43
44function rangeText(series: readonly number[]): string {
45  if (series.length < 2) return ''
46
47  return words().range(percentText(Math.min(...series)), percentText(Math.max(...series)))
48}
49
50function freeText(context: HudContext): string {
51  const count = Math.max(0, context.window - context.tokens)
52
53  return words().free({ tokens: compact(count), count })
54}
55
56function noteOf(gauge: GaugeView, context: HudContext | null): { note: string; noteTone: Tile['noteTone'] } {
57  if (gauge.id !== CONTEXT && gauge.forecast !== '') return { note: gauge.forecast, noteTone: gauge.tone }
58  if (gauge.pace !== null) {
59    const ahead = Math.round(gauge.percent - gauge.pace)
60    if (ahead >= AHEAD_POINTS) return { note: words().ahead(ahead), noteTone: gauge.tone }
61
62    return { note: words().pace(percentText(gauge.pace)), noteTone: 'neutral' }
63  }
64  if (gauge.id === CONTEXT && gauge.tone !== 'neutral') return { note: words().timeToCompact, noteTone: gauge.tone }
65  if (gauge.id === CONTEXT && context !== null) return { note: freeText(context), noteTone: 'neutral' }
66
67  return { note: '', noteTone: 'neutral' }
68}
69
70function gaugeTile(gauge: GaugeView, view: PaneView): Tile {
71  const series = seriesOf(view.history, gauge.id)
72  const context = view.usage?.context ?? null
73  const reset = gauge.reset === '' ? '' : resetText(gauge.reset)
74  const meta = gauge.id === CONTEXT && context !== null ? `${compact(context.tokens)} / ${compact(context.window)}` : reset
75
76  return {
77    eyebrow: gauge.long,
78    meta,
79    value: valueText(gauge.percent),
80    ...noteOf(gauge, context),
81    tone: gauge.tone,
82    // The pace tick at whole points, as its note reads it: the clock moves it every few seconds otherwise.
83    arc: { percent: gauge.percent, pace: gauge.pace === null ? null : Math.round(gauge.pace) },
84    series,
85    seriesMax: 100,
86    caption: rangeText(series) || words().oneReading,
87  }
88}
89
90function costTile(usd: number, view: PaneView): Tile {
91  const series = costSeries(view.history)
92  const turn = view.turn
93  const turnTokens = turn === null ? 0 : turn.input + turn.output
94
95  return {
96    eyebrow: words().sessionCost,
97    meta: '',
98    value: costText(usd),
99    note: turn === null ? '' : words().lastTurnTokens({ tokens: compact(turnTokens), count: turnTokens }),
100    noteTone: 'neutral',
101    tone: 'neutral',
102    arc: null,
103    series,
104    seriesMax: Math.max(...series, usd) * COST_HEADROOM,
105    caption: series.length < 2 ? words().thisSession : `${words().thisSession} · ${words().readings(series.length)}`,
106  }
107}
108
109// What a tile shows without the clock: a reset's countdown, the pace tick and a pace note move with time alone,
110// and redraw the tiles without the sweep a real change gets.
111function stillTile(tile: Tile, gauge: GaugeView | undefined) {
112  const isCountdown = gauge !== undefined && gauge.reset !== ''
113  const isPaceNote = gauge !== undefined && gauge.pace !== null && tile.note !== gauge.forecast
114
115  return { ...tile, meta: isCountdown ? '' : tile.meta, note: isPaceNote ? '' : tile.note, arc: tile.arc?.percent ?? null }
116}
117
118function summaryText(view: PaneView): string {
119  const cost = view.usage?.costUsd ?? null
120
121  return [words().readings(view.history.length), cost === null ? '' : words().sessionAt(costText(cost))].filter(part => part !== '').join(' · ')
122}
123
124function turnText(turn: HudTurn): string {
125  return words().turn({ input: compact(turn.input), output: compact(turn.output), cache: percentText(cachePercent(turn)), requests: turn.requests })
126}
127
128function terminalRow(ui: Ui, gauge: GaugeView, view: PaneView) {
129  const { Box, Text } = ui
130  const width = Math.max(BAR_MIN, Math.min(BAR_MAX, view.columns - ROW_CHROME))
131  const series = seriesOf(view.history, gauge.id)
132  // An estimate takes the range's place, so the row keeps its length.
133  const forecast = gauge.id === CONTEXT ? '' : gauge.forecast
134  const extras = [forecast, gauge.reset === '' ? '' : words().resetsIn(gauge.reset), forecast === '' ? rangeText(series) : ''].filter(part => part !== '')
135
136  return (
137    <Box key={`gauge-${gauge.id}`} flexDirection="row" columnGap={1}>
138      <Text dimColor>{gauge.short.padEnd(LABEL_WIDTH)}</Text>
139      {barTree(ui, { gauge, width })}
140      {toneText(ui, { text: valueText(gauge.percent), tone: gauge.tone })}
141      {extras.length > 0 && <Text dimColor>{`· ${extras.join(' · ')}`}</Text>}
142      {series.length > 1 && <Text dimColor>{sparkText(series, 100)}</Text>}
143    </Box>
144  )
145}
146
147function cardOf(view: PaneView, gauges: readonly GaugeView[], width?: number): CardView {
148  const forecast = gauges.find(gauge => gauge.id === CONTEXT)?.forecast ?? ''
149
150  return {
151    ...view.card,
152    isLive: view.isLive,
153    forecast,
154    context: view.usage?.context ?? null,
155    columns: view.columns,
156    ...(width === undefined || view.svg === undefined ? {} : { width, svg: view.svg }),
157  }
158}
159
160function terminalBody(ui: Ui, view: PaneView, gauges: readonly GaugeView[]) {
161  const { Box, Text } = ui
162
163  return (
164    <Box flexDirection="column">
165      <Box flexDirection="row" justifyContent="space-between">
166        <Text bold>{words().limits}</Text>
167        <Text dimColor>{summaryText(view)}</Text>
168      </Box>
169      <Box flexDirection="column" marginTop={1}>
170        {gauges.map(gauge => terminalRow(ui, gauge, view))}
171      </Box>
172      {contextCard(ui, cardOf(view, gauges))}
173    </Box>
174  )
175}
176
177function body(ui: Ui, view: PaneView, usage: HudUsage) {
178  const { Box } = ui
179  const gauges = gaugeViews(usage, view.nowMs, view.forecasts)
180  if (view.svg === undefined) return terminalBody(ui, view, gauges)
181  const tiles = [...gauges.map(gauge => gaugeTile(gauge, view)), ...(usage.costUsd === null ? [] : [costTile(usage.costUsd, view)])]
182  const spec = { title: words().limits, meta: summaryText(view), tiles, maxWidth: view.columns * PX_PER_COLUMN }
183  const drawn = tilesSvg({ ...spec, isLive: view.isLive })
184  const shows = { ...spec, tiles: tiles.map((tile, index) => stillTile(tile, gauges[index])) }
185  const Svg = view.svg
186
187  return (
188    <Box flexDirection="column">
189      <Svg key="hud-tiles" {...motion.pic('hud-tiles', drawn, { shows })} />
190      {contextCard(ui, cardOf(view, gauges, drawn.width))}
191    </Box>
192  )
193}
194
195export function paneTree(ui: Ui, view: PaneView) {
196  const { Box, Text } = ui
197  const usage = view.usage
198  if (usage === null || (usage.gauges.length === 0 && usage.costUsd === null)) {
199    return <Text dimColor>{words().noReadings}</Text>
200  }
201
202  return (
203    <Box flexDirection="column">
204      {body(ui, view, usage)}
205      {view.turn !== null && view.turn.requests > 0 && (
206        <Box marginTop={1}>
207          <Text dimColor>{turnText(view.turn)}</Text>
208        </Box>
209      )}
210    </Box>
211  )
212}
213
214// While the MODZ hub runs, the pane opens with its way back to it.
215export function withHome(ui: Ui, spec: { tree: RenderElement; goHome: () => void }) {
216  const { Box } = ui
217
218  return (
219    <Box flexDirection="column">
220      {HomeButton(ui, spec.goHome)}
221      {spec.tree}
222    </Box>
223  )
224}
225
hooks/pane-motion.ts 5 lines
1import { paneMotion } from './kit/moving'
2
3// The usage pane's drawings on the desktop (kit/moving.ts): reset as the pane opens, so they cascade in each time.
4export const motion = paneMotion()
5
hooks/same.ts 15 lines
1// MODZ kit — structural equality for plain state values (JSON-like: objects, arrays, primitives).
2// A `$.state` write redraws every reader, and every redraw hands out new press handles, so a
3// value that did not change is not written again.
4export function isSame(a: unknown, b: unknown): boolean {
5  if (Object.is(a, b)) return true
6  if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false
7  if (Array.isArray(a) !== Array.isArray(b)) return false
8  const left = a as Record<string, unknown>
9  const right = b as Record<string, unknown>
10  const keys = Object.keys(left)
11  if (keys.length !== Object.keys(right).length) return false
12
13  return keys.every(key => Object.prototype.hasOwnProperty.call(right, key) && isSame(left[key], right[key]))
14}
15
hooks/pin.ts 48 lines
1// MODZ kit — pinned press handles. Every element the JSX runtime builds gets a fresh handle, and the
2// host releases a drawing's old handles the moment it redraws. A desktop holds the tree it last
3// painted, so a click that lands just after any redraw (a state write, a focus flip, a poll) names a
4// released handle and the engine drops it ("ui_press not handled"). The host keeps a handle alive
5// across redraws when the new tree carries the same one, so each element here keeps the first handle
6// its site and key ever got: every tree a surface still holds stays pressable, with the latest closure.
7import type { RenderElement } from 'claude-code'
8
9export type PinSite = { surface: string; component?: string; requestId?: string }
10
11type Node = { type?: string; props?: { key?: unknown }; press?: { plugin: string; handle: number }; children?: unknown[] }
12
13// Far above the runtime's own counter, which starts at 1 and climbs by one per element drawn.
14const FIRST_PINNED = 1_000_000_000
15const pins = new Map<string, number>()
16
17function handleFor(id: string): number {
18  let handle = pins.get(id)
19  if (handle === undefined) {
20    handle = FIRST_PINNED + pins.size
21    pins.set(id, handle)
22  }
23
24  return handle
25}
26
27function pinNode(node: unknown, ctx: { site: string; seen: Map<string, number> }): unknown {
28  if (typeof node !== 'object' || node === null) return node
29  const el = node as Node
30  const children = el.children?.map(child => pinNode(child, ctx))
31  const withKids = children === undefined ? el : { ...el, children }
32  // Only this plugin's own elements: the runtime names the plugin of every tree `next` returned.
33  if (el.press?.plugin !== '' || typeof el.props?.key !== 'string') return withKids
34  const base = `${ctx.site}\u0000${el.type}\u0000${el.props.key}`
35  const nth = ctx.seen.get(base) ?? 0
36  ctx.seen.set(base, nth + 1)
37
38  return { ...withKids, press: { plugin: '', handle: handleFor(`${base}\u0000${nth}`) } }
39}
40
41// Return a render hook's tree through this: `return pinned(e, tree)`.
42export function pinned<T extends RenderElement | null | undefined>(site: PinSite, tree: T): T {
43  if (tree === null || tree === undefined) return tree
44  const ctx = { site: `${site.surface}\u0000${site.component ?? ''}\u0000${site.requestId ?? ''}`, seen: new Map<string, number>() }
45
46  return pinNode(tree, ctx) as T
47}
48