One line under the prompt: the model and its effort, how full the context is, the plan's 5-hour and 7-day use with their resets, and what other mods put on it…

<img alt="claude-modpack: eight Claude Code mods, each measured on real tasks before it shipped, built to work together. statusline, smart-compact, delegate, lsp-plus, cells, eta, seek, route." src="assets/banner-light.svg" width="880">
<h3>Install in Claude Code</h3>
/plugin install modpack --marketplace HarzerHeribert/claude-modpack
Answer y, then run /modpack:setup to set everything up step by step.<br> Prefer doing it by hand? SETUP.md has every step.
Eight free add-ons that work on their own. One command installs them, and there is nothing new to learn.<br> Every number on this page was measured, including where it doesn't help.
Install · What you get · Honest numbers · The site: ideas and evidence · Lab notes · Questions
<img alt="Animated: the cost per job with and without the pack, as bars that grow: in a long session $0.44 against $0.29 (−33%; measured twice, −26% one day and −40% the next), bigger jobs in a fresh session $0.55 against $0.51 (−8%), quick questions in a fresh session $0.23 against $0.24 (+2%, about even: +5% one day, −4% the next); then four numbers: −33% cost per job in a long session, −21% steps for the same work, −22% cost of a feature a cheaper model builds from your plan, 224 of 224 results correct." src="assets/numbers.gif" width="880">
Every time Claude takes a step (reads a file, makes an edit, runs the tests), it reads your whole conversation again. That re-reading is most of what a session costs, and it grows the longer you work. So the cheapest step is the one Claude never has to take.
The pack takes out steps that need no thinking. Several edits and their test run as one step. A search for a name in your code comes back with the exact answer. And when you approve a plan, a cheaper model does the building while Claude checks the result. The work comes out the same; Claude just gets there in fewer round trips.
<img alt="A real Claude Code session: Claude starts the stress tests in the background; above the prompt a row shows them with a bar, a percentage and a finish time, climbing from 8% to 100%; under the prompt the status line shows the model, the context, the plan's 5-hour and weekly use and a green ready dot." src="assets/demo-band.gif" width="880">
One line under the prompt: the model, how full the conversation is, and how much of your plan's 5-hour and weekly limits you have used, with when each one resets. Long jobs get a progress bar and a finish time above the prompt. And when you step away, your session is kept ready, or tidied up before it goes cold, whichever costs less; the line tells you which.
These are the statusline, eta and smart-compact mods.
<img alt="A real Claude Code session: asked to add two docstrings and run the whole test suite, Claude runs one cell; an orange card shows the read, the two edits, the diff and the test run live, and the tests pass: 1990 passed." src="assets/demo-cells.gif" width="880">
Claude makes several edits and runs the tests as one step, and you watch each part happen in a live card. Every edit still goes through Claude Code's own edit tool: you see each change, and /rewind undoes it.
That's the cells mod.
<img alt="A real Claude Code session: in plan mode Claude plans a new click.Duration parameter type; the plan is approved; Claude hands it to a Sonnet worker, whose orange card shows its steps live with a row above the prompt (hand-off 1, sonnet medium, requests and cost); then Claude checks the diff and reports: the new type works and the full suite passes." src="assets/demo-delegate.gif" width="880">
When you approve a plan, Claude hands the building to Sonnet, a cheaper model, and checks its work before reporting back. In our tests that made a feature 22% cheaper and new tests 26% cheaper, with every result correct (corrected 16 Oct: worker costs were undercounted; it was 27% and 33%). You can watch the helper work in its card, and /delegate shows every step it took.
That's the delegate mod.
A small, fast classifier reads each prompt you send. Only when it is feature or test work is Claude told to hand it straight to delegate's worker: the worker reads the code and builds, delegate checks the result itself, and Claude reports after two steps of its own. On a TypeScript feature that made the job 51% cheaper and 42% faster than the pack alone, with every result correct (corrected 16 Oct: worker costs were undercounted; it was 57%); everything else stays with Claude. The classifier is an outside service (TypeSafe's Jev or ScaleDown), so this is off until you turn it on and give it a key: from then on, the start of each prompt goes to it, in the US, to be classified.
That's the route mod.
When Claude searches your code for a name, it gets the exact list of places that use it, from the same engine your editor uses, instead of reading through search results. Renames and "who calls this?" questions take fewer steps. New in 0.5.0: Go, Kotlin and Ruby code, shell scripts and Terraform. Run /lsp-plus:setup once in your project to turn it on.
That's the lsp-plus mod.
Before Claude changes anything, it looks around your code, and everything it reads stays in the conversation, read again with every later step. Beside the first prompt you send in a session, and again as the session grows, the pack passes on your preference to look around in few, lean steps: the searches it can already name in one go, the lines that matter rather than whole files, nothing read twice. Against the pack without it, that made jobs 12% cheaper in a fresh session, 16% on a TypeScript library and 10 to 16% in long sessions, at the same quality. It runs on your machine and sends nothing anywhere.
That's the seek mod.
<img alt="A real Claude Code session: /mods lists the pack's mods (recorded with the six of 0.4.0, before route and seek) and opens a pane of cards; the status line is turned off and disappears from under the prompt, then turned on again." src="assets/demo-mods.gif" width="880">
/mods lists the eight and turns any of them on or off, right away, with a click or by typing, such as /mods statusline off. When something can't fully work on your computer, the pack tells you once, with the command that fixes it. When another plugin's permission or notification hook hangs (Warp's does outside the Warp terminal), the pack stops waiting for it after 10 seconds, so Claude Code can't freeze, and tells you which one it was. It keeps itself up to date (or only lets you know, if you prefer).
That's the modpack plugin, which installs the other eight.
In Claude Code, type:
/plugin install modpack --marketplace HarzerHeribert/claude-modpack
Answer y to add the marketplace, then pick where to install it. That installs all eight. Then, in the project you work in:
/modpack:setup
It checks your machine, asks once what you want on (route stays off unless you say yes), sets each mod up in order, including the language servers for the languages your project uses, and checks that each one works. Done. /mods shows what's on.
By hand instead: SETUP.md has every step.
Each mod installs on its own the same way, for example /plugin install smart-compact --marketplace HarzerHeribert/claude-modpack. lsp-plus, delegate and cells build on Claude Code's LSP tool, which needs a language server for each language: Language servers lists them.
We measured the pack on Claude Opus 5.5 doing seven real jobs in an open-source project (click), from a one-line lookup to building a feature, on two days, 13 and 15 October. Every result was checked by hidden tests. The same jobs ran with the pack and without it, and we compared what they cost at API prices.
| Cost per job | Without | With the pack | |
|---|---|---|---|
| In a long session (Claude has already read the whole project) | $0.44 | $0.29 | −33% |
| Bigger jobs in a long session (a feature, tests, a rename) | $0.91 | $0.57 | −37% |
| In a fresh session | $0.37 | $0.35 | −4% |
| Bigger jobs in a fresh session | $0.55 | $0.51 | −8%, and 15% faster |
| Quick questions in a fresh session | $0.23 | $0.24 | +2%, about even |
<img alt="Cost per task, plain Claude Code against the modpack, in a fresh session and in a session already holding 216k tokens." src="assets/cost-light.svg" width="880">
<img alt="Model requests per task, plain Claude Code against the modpack: 3.2 against 5.6 in a session already holding 216k tokens." src="assets/requests-light.svg" width="880">
<img alt="Cost per task, Opus alone against a plan handed to a worker: feature work 22% less, tests 26% less." src="assets/delegate-light.svg" width="880">
How we measured, every run's record, and the scripts to run it all again are in benchmarks/, starting with SUITE.md. What we tried that did not make it, a 10,000-star code graph among it, is in the lab notes (as Markdown). Not measured: what smart-compact saves you while you're away (worked out from prices, not tested) and Windows.
Will Claude's work get worse? No. In every run we graded, 730 in all, the result was correct with the pack and without, and every one was graded again for this release. The pack changes how many steps Claude takes to get there, and in our tests the results came out the same.
Is it safe? It's open source (MIT), so every line is here to read. Code that Claude runs inside a cell runs in a sandbox with no network access. Edits go through Claude Code's own edit tool, so your usual permission prompts and /rewind still apply. The pack sends nothing anywhere except a once-a-day check on GitHub for a new release, which you can turn off, and, only if you turn route on, the start of each prompt to the classifier you chose (TypeSafe or ScaleDown).
Does the pack add anything to my prompts? Your prompt itself is never changed. Beside it, Claude can get a short note in your name: seek's setting to look around in few, lean steps (with the first prompt of a session, and again after a compaction), and, only if you turn route on, the order to hand feature or test work to the worker; delegate gives the same order when you approve a plan of 20 lines or more. A subagent Claude starts gets cells' and seek's settings at the end of its task, since your prompts never reach it. /mods seek off and /mods route off stop theirs; subagents: false in cells or seek stops theirs for subagents.
I'm on a Pro or Max plan. Does this help me? You don't pay per job, but every step counts against your 5-hour and weekly limits. We measured cost at API prices, not plan usage, so we can't promise a number. Fewer, lighter steps should leave more of your limits, and the status line shows exactly where you stand.
Does it work in the desktop app? Yes, cards, progress and the status line included. One limit: in auto mode the desktop app doesn't let an add-on start a helper model, so plans aren't handed off there (turn route off there too); in manual mode, or in the terminal, they are. Windows hasn't been tested yet.
How do I turn it off? /mods switches each part off or on, right away. /plugin removes them.
Were any of these numbers wrong? Yes, the hand-off ones. On 16 October we found that our bench had counted a worker's output only as each of its responses began, so every saving that involved a worker came out too large: a planned feature was −27% and is −22%. They are corrected here, the old and new number side by side in CORRECTION-1016.md. The pack-against-plain numbers had no workers and stand.
What does it cost? Nothing. It's free and open source.
One line under the prompt, drawn in Claude Code's own hint row so the mode indicator and the status share it:
⏵⏵ auto mode on Opus 5.5 xhigh 412k ━━━━━━────── 5h 34% ↺2h14 7d 12% ↺3d ● ready · cold in 44m
$.statusline.set({ id, priority, parts, short?, side? }) and lists statusline among its dependencies. A part is text, a countdown ({ until, prefix?, warnMs? }, counted by the line) or a spinner ({ spin: true }), so a mod sends a segment when it changes, not every second. $.statusline.remove({ id }) takes it off./rc) from the desktop app, the web or the phone gets no mod drawing and no status text as of Claude Code 2.1.296.| Option | Default | |
|---|---|---|
enabled | true | false turns the mod off; /mods does it for all of the pack. |
placement | line | tail: appended dim to Claude Code's hint. band: above the prompt. off. |
limits | always | half: a plan window only once it is half used. off: never. |
animate | true | The highlight along the gauge. |
An idle session's prompt cache expires after its TTL: 1 hour on a subscription, 5 minutes in overage or on an API key. The next prompt then writes the whole context to the cache again, at 1.25x (5 min) or 2x (1 h) the input price. For a 400k-token Opus 5.5 session on the 1-hour cache:
| Option | Cost |
|---|---|
| Let it expire, re-cache on return | $3.20 |
| One keep-alive (re-read the cached prefix; a read resets the TTL) | $0.09 |
| Compact before it expires | ~$0.87, plus detail lost to the summary |
A few minutes before the cache expires (2.5 min on the 1-hour TTL, 45 s on the 5-minute one), the smart strategy:
compactPenaltyUsd. It stops after maxIdleHours.minSavingsUsd of re-cache cost, or below minCompactTokens of context, it never compacts.It never compacts while the prompt holds a draft or agents are running; it sends a keep-alive to hold the cache instead. If the machine sleeps past the deadline, it marks the cache expired and does nothing. A failed action is retried twice, 20 s apart. The TTL is detected from the plan and corrected from the cache hits and misses it observes.
On statusline's line it says whether the session is ready to prompt or has gone cold: ● ready, · cold in m:ss (amber in the last five minutes), · compacts in … when an idle compaction is near, ○ cold, or what it is doing (compacting, keeping warm, paused). /cache shows the live figures and plan; /cache pause, /cache resume and /cache keepalive control it.
| Option | Default | |
|---|---|---|
enabled | true | false turns the mod off; /mods does it for all of the pack. |
strategy | smart | compact: compact at the first expiry. keepalive: never compact. off: status line only. |
cacheTtl | auto | 1h or 5m to override detection. |
minSavingsUsd | 0.25 | Below this re-cache cost, the cache is left to expire. |
compactPenaltyUsd | 0.5 | Higher keeps the cache warm longer before compacting. |
minCompactTokens | 100000 | Never compacts a smaller context. |
maxIdleHours | 6 | Stops keep-alives after this long idle. |
compactInstructions | empty | Passed to idle compactions, like /compact <text>. |
Prices are list prices, so on a subscription they are API-equivalent estimates, not a bill.
Every request of the main model re-reads its whole context: at 600k tokens on Opus 5.5 that is about $0.12 a step before any work is done. A worker reads only its own small context. Claude calls hand_off with a task that stands alone, its kind (read: changes no file; mechanical: the task spells out every change; build: something is left to work out), the symbols or file:line places it touches (focus), and for edits a check command. The kind picks the worker, so Claude has no model to weigh: Haiku at low effort for read and mechanical work, Sonnet at medium for build work.
read, with Edit and Write for mechanical and build. The call waits for it inside the tool call (one request of the main model) and returns the report with the evidence; with background it returns at once and the report arrives as a message. A follow-up within 50 minutes goes to the same worker, its reading still in its context.~/.claude/mods/delegate/log.jsonl; /delegate sums it up and opens a side pane. While a worker runs, its card is drawn like a cell (the task, a row per step, the state and cost), and with eta loaded it is a row above the prompt too.Measured on 120 Opus runs, all correct: a feature or tests cost 23–35% less handed off; lookups, traces, explanations and small edits cost more. Opus does not hand off unless told to, so ask for it as an instruction: "hand the tests to a worker". An order the mod gives works as well as yours (8 of 8 runs, 22–26% less), so when you approve a plan of 20 lines or more, delegate gives that order: plan in plan mode, approve, and a Sonnet worker builds it. Corrected 16 Oct: worker costs were undercounted; these were 27–37% and 27–33%.
A verified hand-off (0.3.0). The worker ends its report with a VERIFY block: the command that runs only its new tests, and what it left open. d
hooks/register.tsx 430 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register, Timer } from 'claude-code'
3
4import type { StatuslineArtifact, StatuslineSegment, StatuslineWorld } from '../types'
5import { isPagePublish, parsePublish, shortUrl, titleOf, upsert } from './artifacts'
6import {
7 COLORS,
8 PLAIN_SEPARATOR,
9 SEPARATOR,
10 artifactsLabel,
11 buildSegments,
12 buildUncalm,
13 condenseHint,
14 fit,
15 isMoving,
16 plain,
17 rowLead,
18 width,
19} from './line'
20import type { LimitsShown, Segment } from './line'
21
22type Engine = EngineInterface
23type Placement = 'line' | 'tail' | 'band' | 'off'
24type Options = { placement: Placement; limits: LimitsShown; animate: boolean }
25
26const INITIAL_WORLD: StatuslineWorld = { isWorking: false, window: 200_000, limits: [] }
27
28const SEGMENTS = atom({ plugin: 'statusline', key: 'segments' } as const, {} as Record<string, StatuslineSegment>)
29const WORLD = atom({ plugin: 'statusline', key: 'world' } as const, INITIAL_WORLD)
30const ARTIFACTS = atom({ plugin: 'statusline', key: 'artifacts' } as const, [] as StatuslineArtifact[])
31const ARTIFACTS_OPEN = atom({ plugin: 'statusline', key: 'isArtifactsOpen' } as const, false)
32
33/** The glint's frame rate while Claude works; a turning or blinking segment's. */
34const GLINT_MS = 50
35const MOVE_MS = 125
36
37function pick<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
38 return typeof value === 'string' && (allowed as readonly string[]).includes(value) ? (value as T) : fallback
39}
40
41function readOptions(o: PluginOptions): Options {
42 return {
43 placement: pick<Placement>(o.placement, ['line', 'tail', 'band', 'off'], 'line'),
44 limits: pick<LimitsShown>(o.limits, ['always', 'half', 'off'], 'always'),
45 animate: o.animate !== false,
46 }
47}
48
49let options: Options = readOptions({})
50/** The permission mode, as hooks last reported it: the engine labels the hint's row with it. */
51let permissionMode: string | undefined
52let frames: Timer | undefined
53let framesMs = 0
54let mirrored = false
55let lastMirror = 0
56
57function setWorld($: Engine, change: (w: StatuslineWorld) => Partial<StatuslineWorld>) {
58 return update($, WORLD, w => ({ ...w, ...change(w) }))
59}
60
61/** Redraws at the rate something on the line moves at: the glint, a spinner or a blink; else not at all. */
62async function animate($: Engine) {
63 const now = await $.clock.now()
64 const world = await read($, WORLD)
65 const given = Object.values(await read($, SEGMENTS))
66 const want = options.placement === 'off' ? 0 : world.isWorking && options.animate ? GLINT_MS : given.some(s => isMoving(s, now)) ? MOVE_MS : 0
67 if (want === framesMs) return
68 frames?.cancel()
69 frames = want > 0 ? $.clock.every(want, () => $.ui.invalidate('ui.render')) : undefined
70 framesMs = want
71}
72
73/** A segment as another mod gave it, checked: an id, a priority, parts of text, countdowns or spinners. */
74function isSegment(s: unknown): s is StatuslineSegment {
75 if (s === null || typeof s !== 'object') return false
76 const seg = s as Record<string, unknown>
77 const isPart = (p: unknown) =>
78 p !== null &&
79 typeof p === 'object' &&
80 (typeof (p as { text?: unknown }).text === 'string' || typeof (p as { until?: unknown }).until === 'number' || (p as { spin?: unknown }).spin === true)
81 return (
82 typeof seg.id === 'string' &&
83 seg.id !== '' &&
84 typeof seg.priority === 'number' &&
85 Array.isArray(seg.parts) &&
86 seg.parts.every(isPart) &&
87 (seg.calm === undefined || typeof seg.calm === 'boolean') &&
88 (seg.short === undefined || (Array.isArray(seg.short) && seg.short.every(isPart)))
89 )
90}
91
92async function refreshWorld($: Engine, isDeep: boolean) {
93 const usage = await $.session.usage(isDeep ? { breakdown: 'summary' } : undefined)
94 const model = await $.session.model().catch(() => undefined)
95 // Before any turn says which effort it ran at, the configured one; the same for the permission
96 // mode until a hook reports it (a reload starts the module over).
97 const settings = isDeep ? await $.settings.read().catch(() => ({}) as Record<string, unknown>) : {}
98 const configured = settings.effortLevel
99 const defaultMode = (settings.permissions as { defaultMode?: unknown } | undefined)?.defaultMode
100 if (permissionMode === undefined && typeof defaultMode === 'string') permissionMode = defaultMode
101 const breakdown = usage.context.breakdown
102
103 await setWorld($, w => ({
104 ...(w.model === undefined && model ? { model } : {}),
105 ...(w.effort === undefined && typeof configured === 'string' ? { effort: configured } : {}),
106 // After a reload or a resume the gauge has a size before any response.
107 ...(w.contextTokens === undefined && usage.context.tokens !== undefined ? { contextTokens: usage.context.tokens } : {}),
108 window: usage.context.window,
109 limits: usage.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt === undefined ? undefined : Date.parse(l.resetsAt) })),
110 ...(breakdown === undefined ? {} : { autoCompactAt: breakdown.isAutoCompactEnabled ? breakdown.autoCompactThreshold : undefined }),
111 }))
112}
113
114/** Records a page the Artifact tool published, titled by the page itself. */
115async function notePublish($: Engine, input: Record<string, unknown>, text: string, at: number | undefined) {
116 if (!isPagePublish(input)) return
117 const published = parsePublish(text)
118 if (published === undefined) return
119 const html = typeof input.file_path === 'string' ? await $.fs.read(input.file_path).catch(() => undefined) : undefined
120 const title = titleOf(html, input.file_path, input.title)
121 await update($, ARTIFACTS, list => {
122 const before = list.find(a => a.url === published.url)
123 // A reload finds the transcript's publishes oldest first; never let an older one replace a newer.
124 if (before !== undefined && before.version > published.version) return list
125 return upsert(list, { ...published, title, updatedAt: at ?? before?.updatedAt })
126 })
127}
128
129/** Pages published before this load (a resume, a reload), read back from the transcript. */
130async function recoverArtifacts($: Engine) {
131 const messages = await $.session.messages()
132 for (const m of messages) {
133 for (const use of m.toolUses) {
134 if (use.tool === 'Artifact' && !use.isError && use.text) await notePublish($, use.input, use.text, undefined)
135 }
136 }
137}
138
139function ago(ms: number | undefined, now: number): string {
140 if (ms === undefined) return 'earlier'
141 const m = Math.round((now - ms) / 60_000)
142 if (m < 1) return 'just now'
143 if (m < 60) return `${m}m ago`
144 const h = Math.round(m / 60)
145 return h < 48 ? `${h}h ago` : `${Math.round(h / 24)}d ago`
146}
147
148async function segmentsFor($: Engine, isWorking: boolean, columns: number): Promise<Segment[]> {
149 const now = await $.clock.now()
150 const world = await read($, WORLD)
151 const given = Object.values(await read($, SEGMENTS))
152 return buildSegments({ ...world, isWorking }, given, { now, isWorking, columns, limits: options.limits })
153}
154
155/** What the desktop app's line draws: other mods' segments that are not calm. */
156async function desktopSegments($: Engine, isWorking: boolean, columns: number): Promise<Segment[]> {
157 const now = await $.clock.now()
158 const given = Object.values(await read($, SEGMENTS))
159 return buildUncalm(given, { now, isWorking, columns, limits: options.limits })
160}
161
162/** Where no line is drawn (the phone, the IDE), the same line as the session's status text. */
163async function mirror($: Engine) {
164 const surfaces = await $.session.surfaces().catch(() => [] as string[])
165 const isRemote = options.placement !== 'off' && surfaces.some(s => s === 'mobile' || s === 'vscode')
166 const world = await read($, WORLD)
167 const artifacts = isRemote ? await read($, ARTIFACTS) : []
168 const line = isRemote ? plain(fit(await segmentsFor($, world.isWorking, 160), 160)) : ''
169 const text = artifacts.length > 0 ? [artifactsLabel(artifacts.length), line].filter(Boolean).join(PLAIN_SEPARATOR) : line
170 if (text) {
171 $.ui.status(text)
172 mirrored = true
173 } else if (mirrored) {
174 $.ui.status(undefined)
175 mirrored = false
176 }
177}
178
179/** Once a second: countdowns on the line move, and the mirror follows every fifteen. */
180async function tick($: Engine) {
181 const now = await $.clock.now()
182 const given = Object.values(await read($, SEGMENTS))
183 if (given.some(s => s.parts.some(p => 'until' in p))) $.ui.invalidate('ui.render')
184 await animate($)
185 if (now - lastMirror >= 15_000) {
186 lastMirror = now
187 await mirror($).catch(() => undefined)
188 }
189}
190
191export const register: Register = (on, rawOptions) => {
192 options = readOptions(rawOptions)
193 frames = undefined
194 framesMs = 0
195
196 // `$.statusline` for the other mods. The bottom answers nothing; the hooks below are what does.
197 on('engine.create', async ($, e, next) => {
198 const built = await next(e)
199 return {
200 ...built,
201 statusline: {
202 set: async () => ({ isShown: false }),
203 remove: async () => ({ isRemoved: false }),
204 },
205 }
206 })
207
208 // Off: nothing drawn. `$.statusline` stays above, answering that nothing is shown, so the mods that write on it do not fail.
209 if (rawOptions.enabled === false) return
210
211 on('statusline.set', async ($, e) => {
212 if (!isSegment(e)) return { value: { isShown: false } }
213 await update($, SEGMENTS, s => ({ ...s, [e.id]: e }))
214 await animate($)
215 return { value: { isShown: options.placement !== 'off' } }
216 })
217
218 on('statusline.remove', async ($, e) => {
219 const id = typeof e.id === 'string' ? e.id : ''
220 const had = id in (await read($, SEGMENTS))
221 if (had) {
222 await update($, SEGMENTS, s => {
223 const { [id]: _, ...rest } = s
224 return rest
225 })
226 }
227 return { value: { isRemoved: had } }
228 })
229
230 on('session.start', async ($, e, next) => {
231 await refreshWorld($, true).catch(() => undefined)
232 await recoverArtifacts($).catch(() => undefined)
233 $.clock.every(1_000, () => void tick($))
234 return next(e)
235 })
236
237 on('classic.SessionStart', async ($, e, next) => {
238 if (e.permission_mode !== undefined) permissionMode = e.permission_mode
239 if (e.context_tokens) await setWorld($, () => ({ contextTokens: e.context_tokens, ...(e.model ? { model: e.model } : {}) }))
240 return next(e)
241 }).catch(($, e, next) => next(e))
242
243 on('session.end', { reason: 'clear' }, async ($, e, next) => {
244 await setWorld($, () => ({ contextTokens: undefined }))
245 return next(e)
246 })
247
248 on('turn.start', async ($, e, next) => {
249 await setWorld($, () => ({ isWorking: true }))
250 await animate($)
251 return next(e)
252 })
253
254 on('turn.step', async function* ($, e, next) {
255 const result = yield* next(e)
256 if (e.agentId === undefined && result.usage !== null) {
257 const u = result.usage
258 await setWorld($, () => ({
259 model: u.model,
260 contextTokens: u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens,
261 ...(e.effort !== undefined ? { effort: String(e.effort) } : {}),
262 }))
263 }
264 return result
265 })
266
267 on('turn.complete', async ($, e, next) => {
268 const result = await next(e)
269 if (e.agentId !== undefined) return result
270 await setWorld($, () => ({ isWorking: false }))
271 await animate($)
272 await refreshWorld($, false).catch(() => undefined)
273 return result
274 })
275
276 // The plan's windows move between turns too (other sessions use them): the engine says when.
277 on('session.measure', async ($, e, next) => {
278 await setWorld($, () => ({
279 window: e.context.window,
280 limits: e.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt === undefined ? undefined : Date.parse(l.resetsAt) })),
281 }))
282 return next(e)
283 })
284
285 on('session.compact', async ($, e, next) => {
286 const result = await next(e)
287 if (e.agentId !== undefined || e.trigger === 'precompute' || result.skip !== undefined) return result
288 await setWorld($, () => ({ contextTokens: undefined }))
289 // The window was re-read: the threshold may move with it.
290 await refreshWorld($, true).catch(() => undefined)
291 return result
292 }).catch(($, e, next) => next(e))
293
294 on('tool.call', { tool: 'Artifact' }, async ($, e, next) => {
295 const r = await next(e)
296 if (r.deny === undefined && !r.isError && r.text) {
297 await notePublish($, e as unknown as Record<string, unknown>, r.text, await $.clock.now()).catch(() => undefined)
298 }
299 return r
300 }).catch(($, e, next) => next(e))
301
302 // The mode labels the hint's row; every classic hook reports it, so the line knows its room.
303 on('classic.UserPromptSubmit', async ($, e, next) => {
304 if (e.permission_mode !== undefined) permissionMode = e.permission_mode
305 return next(e)
306 }).catch(($, e, next) => next(e))
307
308 on('classic.Stop', async ($, e, next) => {
309 if (e.permission_mode !== undefined) permissionMode = e.permission_mode
310 return next(e)
311 }).catch(($, e, next) => next(e))
312
313 /** Where the line goes: the desktop app draws no hint row (tried 13 Oct), so there it takes the band above the prompt. */
314 const inBand = (surface: string) => options.placement === 'band' || (options.placement !== 'off' && surface === 'desktop')
315
316 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
317 if ((options.placement !== 'line' && options.placement !== 'tail') || inBand(e.surface)) return next(e)
318
319 const columns = e.viewport?.columns ?? 100
320 const segments = await segmentsFor($, e.props.isWorking, columns)
321
322 if (options.placement === 'tail') {
323 const shown = fit(segments, columns - width(e.props.hint) - 4)
324 if (shown.length === 0) return next(e)
325 return next({ ...e, props: { ...e.props, tail: ` ${plain(shown)}` } })
326 }
327
328 const artifacts = await read($, ARTIFACTS)
329 const isOpen = await read($, ARTIFACTS_OPEN)
330 const hint = condenseHint(e.props.hint, { dropArtifacts: artifacts.length > 0 })
331 const pill = artifacts.length > 0 ? `${artifactsLabel(artifacts.length)} ${isOpen ? '▾' : '▸'}` : ''
332 // The engine draws its indent, mode label ("⏵⏵ auto mode on") and a " · " before this tree, on the
333 // same row. The tree is exactly as wide as what is left: width 100% pushed that dot onto a row of
334 // its own, and the engine's row does not hand a growing tree its free width.
335 const room = Math.max(10, columns - rowLead(permissionMode) - 1)
336 const leftWidth = Math.min(width(hint) + width(pill) + (pill && hint ? 2 : 0), 40)
337 const shown = fit(segments, room - leftWidth - 4)
338 const { Box, Text, Button } = $.ui.resolve(e)
339
340 // With nothing left of the line, the engine's " · " after its mode label would lead nowhere: the
341 // tree starts two cells earlier and covers the dot with blanks.
342 const isBare = !pill && !hint && permissionMode !== 'default'
343 return (
344 <Box flexDirection="row" justifyContent="space-between" width={isBare ? room + 2 : room} marginLeft={isBare ? -2 : 0}>
345 {isBare ? <Text>{' '}</Text> : null}
346 <Box flexShrink={1} marginRight={3}>
347 {pill ? <Button key="artifacts" plain dimColor label={pill} onPress={() => void update($, ARTIFACTS_OPEN, v => !v)} /> : null}
348 {pill && hint ? <Text color={COLORS.faint}>{' '}</Text> : null}
349 <Text color={COLORS.label} wrap="truncate-end">
350 {hint}
351 </Text>
352 </Box>
353 <Box flexShrink={0} marginRight={1}>
354 {shown.flatMap((s, i) => [
355 ...(i > 0 ? [<Text color={COLORS.faint}>{SEPARATOR}</Text>] : []),
356 ...s.parts.map(p =>
357 p.tone === 'glint' ? (
358 <Text color={COLORS.glint} bold>
359 {p.text}
360 </Text>
361 ) : (
362 <Text color={COLORS[p.tone]}>{p.text}</Text>
363 ),
364 ),
365 ])}
366 </Box>
367 </Box>
368 )
369 })
370
371 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
372 if (e.props.hasSurvey) return next(e)
373
374 const published = await read($, ARTIFACTS)
375 const isOpen = await read($, ARTIFACTS_OPEN)
376 // The desktop app shows the essentials itself: there only other mods' segments that are not calm, no pill.
377 const isDesktop = e.surface === 'desktop' && options.placement !== 'off'
378 const artifacts = isOpen && !isDesktop ? published : []
379 const isBand = inBand(e.surface)
380 // Where the band carries the line, the published pages' pill comes with it: there is no hint row to hold it.
381 const pill = isBand && !isDesktop && published.length > 0 ? `${artifactsLabel(published.length)} ${isOpen ? '▾' : '▸'}` : ''
382 const room = e.props.bodyColumns - (pill ? width(pill) + 2 : 0)
383 const segments = isDesktop ? fit(await desktopSegments($, e.props.isWorking, room), room) : isBand ? fit(await segmentsFor($, e.props.isWorking, room), room) : []
384 if (artifacts.length === 0 && segments.length === 0 && !pill) return next(e)
385
386 const now = await $.clock.now()
387 const { Box, Text, Button, Link } = $.ui.resolve(e)
388 const below = await next(e)
389
390 return (
391 <Box flexDirection="column">
392 {artifacts.length > 0 ? (
393 <Box key="artifacts" flexDirection="column">
394 <Box flexDirection="row" justifyContent="space-between">
395 <Text color={COLORS.label}>{artifactsLabel(artifacts.length)} published this session</Text>
396 <Button key="close-artifacts" plain dimColor label="close" onPress={() => void update($, ARTIFACTS_OPEN, () => false)} />
397 </Box>
398 {artifacts.map(a => (
399 <Box key={a.url} flexDirection="row">
400 <Text>{' '}</Text>
401 <Link href={a.url} label={a.title} />
402 <Text color={COLORS.faint}>{` v${a.version} · ${ago(a.updatedAt, now)} `}</Text>
403 <Text color={COLORS.label} wrap="truncate-end">
404 {shortUrl(a.url)}
405 </Text>
406 </Box>
407 ))}
408 </Box>
409 ) : null}
410 {segments.length > 0 || pill ? (
411 <Box key="segments" flexDirection="row" justifyContent={pill ? 'space-between' : 'flex-end'}>
412 {pill ? <Button key="artifacts" plain dimColor label={pill} onPress={() => void update($, ARTIFACTS_OPEN, v => !v)} /> : null}
413 <Box flexDirection="row">
414 {segments.flatMap((s, i) => [
415 ...(i > 0 ? [<Text color={COLORS.faint}>{SEPARATOR}</Text>] : []),
416 ...s.parts.map(p => (
417 <Text color={COLORS[p.tone]} bold={p.tone === 'glint'}>
418 {p.text}
419 </Text>
420 )),
421 ])}
422 </Box>
423 </Box>
424 ) : null}
425 {below ?? null}
426 </Box>
427 )
428 })
429}
430hooks/artifacts.ts 38 lines1// The pages this session published with the Artifact tool, read from what the
2// tool answered, for the status line's artifacts pill and its list.
3
4import type { StatuslineArtifact } from '../types'
5
6/** What a publish answers: the page's link and its version. */
7export function parsePublish(text: string): { url: string; version: number } | undefined {
8 const url = text.match(/\bat (https:\/\/claude\.ai\/(?:code\/)?artifact\/[\w-]+)/)?.[1]
9 if (url === undefined) return undefined
10 return { url, version: Number(text.match(/\(Version (\d+)/)?.[1] ?? 1) }
11}
12
13/** Whether an Artifact call published a page (not an asset upload, a read or a list). */
14export function isPagePublish(input: Record<string, unknown>): boolean {
15 const action = input.action ?? 'publish'
16 return action === 'publish' && input.asset !== true
17}
18
19const ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'", apos: "'", nbsp: ' ' }
20
21/** A page's own `<title>`, else the title the call gave, else its file's name. */
22export function titleOf(html: string | undefined, filePath: unknown, given: unknown): string {
23 const own = html?.match(/<title[^>]*>([^<]{1,160})<\/title>/i)?.[1]?.trim()
24 if (own) return own.replace(/&(amp|lt|gt|quot|#39|apos|nbsp);/g, (_, k: string) => ENTITIES[k] ?? _)
25 if (typeof given === 'string' && given.trim()) return given.trim()
26 const base = typeof filePath === 'string' ? (filePath.split('/').pop() ?? '').replace(/\.(html?|md)$/i, '') : ''
27 return base || 'artifact'
28}
29
30/** Puts a publish first, replacing the same page's earlier entry; keeps twenty. */
31export function upsert(list: readonly StatuslineArtifact[], a: StatuslineArtifact): StatuslineArtifact[] {
32 return [a, ...list.filter(x => x.url !== a.url)].slice(0, 20)
33}
34
35export function shortUrl(url: string): string {
36 return url.replace(/^https:\/\//, '')
37}
38hooks/line.ts 303 lines1// The status line as data: segments of toned parts, fitted to a width by
2// priority. Drawing them (a tree, or a plain tail) is register.tsx's job.
3//
4// The line's own segments say what matters while working: the model and its
5// effort, how full the context is, and how much of the plan's 5-hour and
6// 7-day windows is used. Other mods add theirs through `$.statusline.set`
7// (smart-compact's "● ready · cold in 44m" at the right edge, where the eye
8// lands); a part of theirs may count down or turn, and is resolved here.
9//
10// One palette, from the person's theme: grey labels, the Claude accent for
11// what is live, warning and error only for what needs attention.
12
13import type { Color } from 'claude-code'
14
15import type { StatuslinePart, StatuslineSegment, StatuslineTone, StatuslineWorld } from '../types'
16
17export type Tone = StatuslineTone | 'glint'
18
19export type Part = { text: string; tone: Tone }
20
21/** `short` is what the segment says when the full form does not fit, before it is dropped. */
22export type Segment = { id: string; priority: number; parts: Part[]; short?: Part[] }
23
24export type LimitsShown = 'always' | 'half' | 'off'
25
26export const COLORS: Record<Tone, Color> = {
27 accent: 'claude',
28 good: 'success',
29 text: 'text',
30 // In Claude Code's themes "inactive" is the readable grey and "subtle" the dim one (dark theme:
31 // 246 and 239 of 256): labels read in the first, separators and the empty gauge sit back in the second.
32 label: 'inactive',
33 faint: 'subtle',
34 warn: 'warning',
35 alert: 'error',
36 glint: 'text',
37}
38
39/** Between groups on the drawn line: space, not punctuation. */
40export const SEPARATOR = ' '
41/** Between groups where the line is one plain string (web, mobile). */
42export const PLAIN_SEPARATOR = ' · '
43/** With `limits: half`, a plan window shows from this share used. */
44export const LIMITS_FROM = 50
45
46const SPIN = ['◐', '◓', '◑', '◒'] as const
47
48export type Moment = { now: number; isWorking: boolean; columns: number; limits: LimitsShown }
49
50function part(text: string, tone: Tone): Part {
51 return { text, tone }
52}
53
54export function width(text: string): number {
55 return [...text].length
56}
57
58export function segmentWidth(s: Segment): number {
59 return s.parts.reduce((sum, p) => sum + width(p.text), 0)
60}
61
62function severity(fraction: number): Tone {
63 return fraction >= 0.95 ? 'alert' : fraction >= 0.8 ? 'warn' : 'accent'
64}
65
66export function tokens(n: number): string {
67 if (n < 1_000) return `${n}`
68 if (n < 100_000) return `${(n / 1_000).toFixed(n < 10_000 ? 1 : 0).replace(/\.0$/, '')}k`
69 if (n < 1_000_000) return `${Math.round(n / 1_000)}k`
70 return `${(n / 1_000_000).toFixed(2).replace(/0$/, '')}M`
71}
72
73/** `8:00` under ten minutes, then `44m`, then `2h14`. */
74export function duration(ms: number): string {
75 const s = Math.max(0, Math.round(ms / 1000))
76 if (s < 600) return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
77 const m = Math.round(s / 60)
78 if (m < 60) return `${m}m`
79 const h = Math.floor(m / 60)
80 return m % 60 === 0 ? `${h}h` : `${h}h${String(m % 60).padStart(2, '0')}`
81}
82
83/** How long until a reset: minutes and hours as `duration` says them, days past two. */
84export function span(ms: number): string {
85 const days = ms / 86_400_000
86 return days >= 2 ? `${Math.round(days)}d` : duration(ms)
87}
88
89/**
90 * A line gauge with half-cell steps. While the model works a glint sweeps
91 * along it, which is what the 20 fps redraw is for.
92 */
93export function gauge(fraction: number, cells: number, tone: Tone, glintAt?: number): Part[] {
94 const halves = Math.round(Math.min(1, Math.max(0, fraction)) * cells * 2)
95 const out: Part[] = []
96
97 for (let i = 0; i < cells; i += 1) {
98 const filled = Math.min(2, Math.max(0, halves - i * 2))
99 const glyph = filled === 2 ? '━' : filled === 1 ? '╸' : '─'
100 const isGlint = glintAt !== undefined && Math.abs(i - glintAt) < 1
101 const cellTone: Tone = isGlint ? (filled > 0 ? 'glint' : 'label') : filled > 0 ? tone : 'faint'
102 const last = out.at(-1)
103
104 if (last && last.tone === cellTone) {
105 last.text += glyph
106 } else {
107 out.push(part(glyph, cellTone))
108 }
109 }
110
111 return out
112}
113
114function contextSegment(world: StatuslineWorld, m: Moment): Segment | undefined {
115 if (world.contextTokens === undefined) return undefined
116 const limit = world.autoCompactAt ?? world.window
117 const fraction = limit > 0 ? world.contextTokens / limit : 0
118 const cells = m.columns >= 110 ? 12 : 8
119 const tone = severity(fraction)
120 const glintAt = m.isWorking ? ((m.now % 1600) / 1600) * (cells + 4) - 2 : undefined
121
122 const size = part(`${tokens(world.contextTokens)} `, tone === 'accent' ? 'label' : tone)
123 return {
124 id: 'context',
125 priority: 1,
126 parts: [size, ...gauge(fraction, cells, tone, glintAt)],
127 short: [{ ...size, text: size.text.trim() }],
128 }
129}
130
131/**
132 * The plan's windows: how much is used and when each resets. Quiet while under half, plain
133 * text past it, warning from 80%. Narrow lines drop the reset first, then the window.
134 */
135function limitSegments(world: StatuslineWorld, m: Moment): Segment[] {
136 const out: Segment[] = []
137 const order = (kind: string) => (kind === 'five_hour' ? 0 : kind === 'seven_day' ? 1 : 2)
138
139 for (const limit of [...world.limits].sort((a, b) => order(a.kind) - order(b.kind))) {
140 const label = limit.kind === 'five_hour' ? '5h' : limit.kind === 'seven_day' ? '7d' : '$'
141 const isPlan = limit.kind === 'five_hour' || limit.kind === 'seven_day'
142 // A spend cap matters once it runs warm; the plan's windows as the person asked.
143 const hidden = isPlan ? m.limits === 'off' || (m.limits === 'half' && limit.percentUsed < LIMITS_FROM) : limit.percentUsed < 70
144 if (hidden) continue
145
146 const fraction = limit.percentUsed / 100
147 const tone: Tone = fraction >= 0.8 ? severity(fraction) : fraction >= 0.5 ? 'text' : 'label'
148 const short = [part(`${label} `, 'label'), part(`${Math.round(limit.percentUsed)}%`, tone)]
149 const parts = [...short]
150 if (limit.resetsAt !== undefined && limit.resetsAt > m.now) parts.push(part(` ↺${span(limit.resetsAt - m.now)}`, 'faint'))
151
152 const priority = limit.kind === 'five_hour' ? 2 : limit.kind === 'seven_day' ? 3 : 4
153 out.push({ id: `limit-${limit.kind}`, priority, parts, short: parts.length > short.length ? short : undefined })
154 }
155
156 return out
157}
158
159/** "Opus 5.5" from an id, a `/model` name or an alias; the alias itself, capitalised, when it names no version. */
160export function modelLabel(model: string): string {
161 const id = model.toLowerCase().replace(/[.\s]/g, '-')
162 const m = /(opus|sonnet|haiku|fable)-(\d+)(?:-(\d{1,2})(?!\d))?/.exec(id)
163 const name = (word: string) => word[0]!.toUpperCase() + word.slice(1)
164 if (m) return m[3] !== undefined ? `${name(m[1]!)} ${m[2]}.${m[3]}` : `${name(m[1]!)} ${m[2]}`
165 const alias = /^(opus|sonnet|haiku|fable)/.exec(id)?.[1]
166 return alias ? name(alias) : model
167}
168
169const EFFORT_TONES: Record<string, Tone> = { low: 'faint', medium: 'label', high: 'text', xhigh: 'accent', max: 'accent' }
170
171/** The model and its effort, the effort coloured by how hard it works. */
172function modelSegment(world: StatuslineWorld): Segment | undefined {
173 if (world.model === undefined) return undefined
174 const parts = [part(modelLabel(world.model), 'label')]
175 if (world.effort !== undefined) parts.push(part(` ${world.effort}`, EFFORT_TONES[world.effort] ?? 'label'))
176 return { id: 'model', priority: 5, parts }
177}
178
179/** A part another mod gave, as it reads now: a countdown counted, a spinner turned, a blink blinked. */
180export function resolvePart(p: StatuslinePart, now: number): Part {
181 if ('spin' in p) return part(SPIN[Math.floor(now / 125) % SPIN.length] ?? '◐', p.tone)
182 if ('until' in p) {
183 const left = p.until - now
184 return part(`${p.prefix ?? ''}${duration(left)}`, p.warnMs !== undefined && left < p.warnMs ? 'warn' : p.tone)
185 }
186 const isOff = p.blink !== undefined && now >= p.blink.from && now < p.blink.to && Math.floor(now / 500) % 2 === 0
187 return part(p.text, isOff ? 'faint' : p.tone)
188}
189
190function resolveSegment(s: StatuslineSegment, now: number): Segment {
191 return {
192 id: s.id,
193 priority: s.priority,
194 parts: s.parts.map(p => resolvePart(p, now)),
195 short: s.short?.map(p => resolvePart(p, now)),
196 }
197}
198
199/** Whether a segment moves by itself: a spinner, or a blink under way. */
200export function isMoving(s: StatuslineSegment, now: number): boolean {
201 return s.parts.some(p => 'spin' in p || ('blink' in p && p.blink !== undefined && now >= p.blink.from && now < p.blink.to))
202}
203
204/**
205 * Every segment, in the order they read: other mods' `start` segments, the model and its effort,
206 * the context, the plan windows, then other mods' `end` segments, the most important rightmost.
207 * Narrow terminals lose the model first, then the windows, then the gauge.
208 */
209export function buildSegments(world: StatuslineWorld, given: readonly StatuslineSegment[], m: Moment): Segment[] {
210 const byPriority = (a: StatuslineSegment, b: StatuslineSegment) => b.priority - a.priority
211 const start = given.filter(s => s.side === 'start').sort(byPriority)
212 const end = given.filter(s => s.side !== 'start').sort(byPriority)
213 return [
214 ...start.map(s => resolveSegment(s, m.now)),
215 modelSegment(world),
216 contextSegment(world, m),
217 ...limitSegments(world, m),
218 ...end.map(s => resolveSegment(s, m.now)),
219 ].filter((s): s is Segment => s !== undefined)
220}
221
222/** Only what other mods gave and is not calm, in the order they read: what the desktop app's line draws. */
223export function buildUncalm(given: readonly StatuslineSegment[], m: Moment): Segment[] {
224 return buildSegments({ isWorking: m.isWorking, window: 0, limits: [] }, given.filter(s => s.calm !== true), m)
225}
226
227/** The artifacts pill's text. */
228export function artifactsLabel(n: number): string {
229 return `${n} artifact${n === 1 ? '' : 's'}`
230}
231
232/**
233 * Makes the segments fit in `room`: the least important first says its short form, if it has
234 * one, and is dropped only when that does not fit either.
235 */
236export function fit(segments: readonly Segment[], room: number): Segment[] {
237 const kept = [...segments]
238 const total = () => kept.reduce((sum, s) => sum + segmentWidth(s), 0) + Math.max(0, kept.length - 1) * width(SEPARATOR)
239
240 while (kept.length > 0 && total() > room) {
241 const worst = kept.reduce((a, b) => (b.priority >= a.priority ? b : a))
242 const at = kept.indexOf(worst)
243 if (worst.short) {
244 kept[at] = { ...worst, parts: worst.short, short: undefined }
245 } else {
246 kept.splice(at, 1)
247 }
248 }
249
250 return kept
251}
252
253export function plain(segments: readonly Segment[], separator = PLAIN_SEPARATOR): string {
254 return segments.map(s => s.parts.map(p => p.text).join('')).join(separator)
255}
256
257/** The width of the drawn line's segments, separators included. */
258export function drawnWidth(segments: readonly Segment[]): number {
259 return segments.reduce((sum, s) => sum + segmentWidth(s), 0) + Math.max(0, segments.length - 1) * width(SEPARATOR)
260}
261
262/**
263 * The engine's own hint, kept to what matters: the permission mode's name
264 * without its key help, and warnings such as `ctrl+c again to exit`. Key help
265 * the person knows by heart goes: `? for shortcuts`, `esc to interrupt`,
266 * `← for agents`.
267 */
268export function condenseHint(hint: string, options: { dropArtifacts?: boolean } = {}): string {
269 return (
270 hint
271 // The engine's artifacts pill ("⧉2", or "⧉ <title>"), when the line draws its own.
272 .replace(options.dropArtifacts ? /⧉[^·]*/g : /$^/g, '')
273 .replace(/\s*\((?:shift\+tab|⇧⇥)[^)]*\)/gi, '')
274 .replace(/\s+mode on\b/gi, '')
275 .replace(/\?\s*for shortcuts/gi, '')
276 .replace(/\besc to interrupt\b/gi, '')
277 .replace(/←\s*for agents/gi, '')
278 .replace(/(\s*·\s*){2,}/g, ' · ')
279 .replace(/^\s*·\s*|\s*·\s*$/g, '')
280 .replace(/\s{2,}/g, ' ')
281 .trim()
282 // Nothing but separators left: nothing.
283 .replace(/^[\s·•|]*$/, '')
284 )
285}
286
287/**
288 * Cells the engine takes on the hint's row before this tree: its indent, the mode label
289 * ("⏵⏵ auto mode on") and the " · " after it. The default mode draws no label; an unknown one is
290 * given room for a long label, so the line falls short of the edge rather than past it.
291 */
292export function rowLead(mode: string | undefined): number {
293 const labels: Record<string, string> = {
294 auto: '⏵⏵ auto mode on',
295 acceptEdits: '⏵⏵ accept edits on',
296 plan: '⏸ plan mode on',
297 bypassPermissions: '⏵⏵ bypass permissions on',
298 }
299 if (mode === 'default') return 2
300 const label = mode === undefined ? undefined : labels[mode]
301 return 2 + (label === undefined ? 24 : width(label)) + 3
302}
303types/index.d.ts 83 lines1/** How a part reads: the line's palette, from the person's theme. */
2export type StatuslineTone = 'accent' | 'good' | 'text' | 'label' | 'faint' | 'warn' | 'alert'
3
4/** Text in a tone; with `blink`, it blinks between those two times (ms since the epoch). */
5export type StatuslineText = { text: string; tone: StatuslineTone; blink?: { from: number; to: number } }
6
7/** The time left until `until` (ms since the epoch), as `8:00`, `44m` or `2h14`; warn-toned in its last `warnMs`. */
8export type StatuslineCountdown = { until: number; tone: StatuslineTone; prefix?: string; warnMs?: number }
9
10/** A spinner that turns while it is shown. */
11export type StatuslineSpinner = { spin: true; tone: StatuslineTone }
12
13export type StatuslinePart = StatuslineText | StatuslineCountdown | StatuslineSpinner
14
15/**
16 * A segment another mod puts on the line. `id` is the mod's and stays the same
17 * (`smart-compact:ready`). A lower `priority` stays longer on a narrow line: the
18 * line's own are the context 1, the 5-hour window 2, the 7-day window 3, a spend
19 * cap 4, the model 5. `short` is what it says before it is dropped. `side: 'end'`
20 * (the default) sits at the right edge, where the eye lands; `'start'` before the model.
21 * `calm` marks a routine state: drawn where the line has room for everything, left out
22 * where the host shows the essentials itself (the desktop app).
23 */
24export type StatuslineSegment = {
25 id: string
26 priority: number
27 parts: StatuslinePart[]
28 short?: StatuslinePart[]
29 side?: 'start' | 'end'
30 calm?: boolean
31}
32
33/** What other mods call: `$.statusline.set(segment)`, `$.statusline.remove({ id })`. */
34export type Statusline = {
35 /** Puts the segment on the line, replacing the one with its id. */
36 set: (segment: StatuslineSegment) => Promise<{ isShown: boolean }>
37 /** Takes the segment with this id off the line. */
38 remove: (args: { id: string }) => Promise<{ isRemoved: boolean }>
39}
40
41export type StatuslineLimit = { kind: string; percentUsed: number; resetsAt?: number }
42
43/** What the line draws of the session itself. */
44export type StatuslineWorld = {
45 isWorking: boolean
46 /** The main loop's model: the last response's, else the session's. */
47 model?: string
48 /** The effort of the last turn, or the configured one before any. */
49 effort?: string
50 /** Prompt tokens of the main thread's last request. */
51 contextTokens?: number
52 window: number
53 /** Where the engine auto-compacts; absent when it does not. */
54 autoCompactAt?: number
55 limits: StatuslineLimit[]
56}
57
58/** A page this session published with the Artifact tool, as the line's pill lists it. */
59export type StatuslineArtifact = {
60 url: string
61 title: string
62 version: number
63 /** When it was last published; absent for one found in the transcript after a reload. */
64 updatedAt?: number
65}
66
67declare module 'claude-code' {
68 interface EngineInterface {
69 statusline: Statusline
70 }
71 interface PluginState {
72 statusline: {
73 /** What other mods put on the line, by id. */
74 segments: Record<string, StatuslineSegment>
75 world: StatuslineWorld
76 /** Newest first. */
77 artifacts: StatuslineArtifact[]
78 /** The artifacts list is open in the band above the prompt. */
79 isArtifactsOpen: boolean
80 }
81 }
82}
83