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

Puts one quiet line above the prompt with your 5-hour and 7-day limits, the context window and the session cost.
/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%.
5h ━━┊─── 32% ↻ 2h 10m 7d ━━━━┊─ 91% ↻ 3d 22h context ━━──── 41% ≈$1.23
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.━━┊─── meters; as it narrows, the bars go first, then the resets and the cost.full. Context turns amber from 70%.s) appears. It compacts the conversation through the same engine call /compact makes.~): 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.The line sits beneath other mods' rows without swallowing them, and steps aside while a survey is open.
/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).~full at 15:40 (local time, with the weekday when it is not today).~N turns left until auto-compaction. A Compact now button compacts and then shows the result, for example Compacted 152k → 38k.There are no plugin options.
≈. Pace is drawn only for windows with a known length (5-hour and 7-day).summary count, not the token-count API). It is read only while the Usage pane draws, at most every 20 seconds.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.
The interface is English. Set MODZ_LANG=tr in the env block of ~/.claude/settings.json for Turkish.
hooks/register.tsx 293 lines1import { 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}
293hooks/band.tsx 58 lines1import { 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}
58hooks/breakdown.ts 48 lines1import 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}
48hooks/context.ts 57 lines1import 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))
57hooks/history.ts 39 lines1import 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}
39hooks/hub.ts 33 lines1import { 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
33hooks/lang.ts 24 lines1// 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)
24hooks/model.ts 74 lines1import { 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}
74hooks/pane.tsx 225 lines1import 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}
225hooks/pane-motion.ts 5 lines1import { 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()
5hooks/same.ts 15 lines1// 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}
15hooks/pin.ts 48 lines1// 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