A vim-style status bar in the Claude Code footer: editor mode, permission mode, model, effort, git state, usage and cursor; numbers the draft's lines and keeps…

A vim-style status bar for Claude Code's footer. It also numbers the draft's lines and keeps the prompt box taller.
──────────────────────────────────────────────────────────────────────────────────────────
1 Refactor the status line
2 and add tests
──────────────────────────────────────────────────────────────────────────────────────────
NORMAL Auto · Claude Opus 5.5 · max main +94 -64 19.7K (2%) · $0.13 Ln 2, Col 13 · 100%
The prompt box stays as Claude Code draws it. The bar takes the footer's first row, over Claude Code's own permission mark, which it names after the badge; a blank row under it keeps it off the screen's last row.
claude plugin marketplace add thefuga/claude-x
claude plugin install statusline@claude-x
Neither is needed: without vim the bar has no command line, and nothing here reads syntax.
/tui fullscreen). Only there can a mod draw outside its own rows. Under the default renderer, or below 64 columns, the status line keeps to the two rows under the prompt.CLAUDE_CODE_TMUX_TRUECOLOR=1 (and tmux's RGB terminal feature on) to get the exact ones./theme picks, light or dark./config → Editor mode → vim gives the badge its NORMAL and VISUAL.In /config, or under pluginConfigs."statusline@claude-x".options in the settings (statusline@inline when loaded from a folder):
| Option | Default | What it does |
|---|---|---|
lineNumbers | on | Numbers the draft's lines over the prompt box's two-cell gutter. |
minLines | 1 | Keeps the prompt box at least this many rows tall. |
expandedLines | 100 | How tall /expand makes the box; the default is as tall as Claude Code lets it grow. |
git | on | The branch and the lines changed, before the usage. |
| Segment | Source |
|---|---|
| Editor mode | The -- INSERT -- / -- VISUAL -- marker of Claude Code's vim editor; NORMAL without one. The default editor has no modes, so it reads INSERT. After a leading ! it reads SHELL (SHELL NORMAL, SHELL VISUAL in the vim editor's other modes). |
| Permission mode | How wide Claude Code draws its own mark for it: see "The permission mode" below. Manual, Accept edits, Plan, Auto or Bypass. |
| Model | $.session.model(), then the PostModelSwitch hook event. |
| Effort | /effort <level>, the /effort picker, and what each tool call and turn end report. A new session shows none until one of those, unless effortLevel is set. |
| Cursor | The draft each prompt.edit leaves, and $.prompt.read() on a timer while the box holds one. The percentage is vim's: how far down the draft the cursor's line is. |
| Usage | session.measure: context tokens, how full the window is, and the session's cost. Claude Code's own labels (focus, memory paused) stand before it. |
| Git | Before the usage: the branch HEAD is on (detached@<commit> on none) after a Nerd Font icon, and the lines added and deleted in the tracked files since the last commit, staged or not (git diff --numstat HEAD), a count of zero left out. Read every five seconds and after each tool call, with opencode.vim's commands; nothing outside a repository. The git option turns it off. |
Segments drop out as the terminal narrows: the provider first, then Claude, the effort and the model. On the right the usage goes first, then the git counts, then the branch is cut short; they take at most half the bar.
/expand is opencode.vim's compose toggle (:expand in the vim mod's command line). It makes the box stand as tall as Claude Code lets it until a prompt is sent, which puts it back, or until it is run again. That is half the screen less five rows: 15 on a 40-row screen, 45 on a 100-row one. The expandedLines option stops it lower (never below minLines). As with minLines, the rows added are drawn by the mod, not lines of the draft, and only in the fullscreen renderer. Bind a key to it:
{ "context": "Chat", "bindings": { "ctrl+x x": "command:expand" } }
The engine gives a mod two sites in the footer and none in the prompt box, which it draws itself: a rule, the draft's rows, a rule. Everything is drawn from the left-hand site, the footer's first row, and each piece rests on something the mod works out:
wrap.ts, a port of the wrapping Claude Code uses), so that each number lands on the row its line starts on.minLines and /expand add rows of the footer's own under the draft: the box's rule is blanked and drawn again under them.$.ui.status) takes a row between the box and the footer. The mod watches for those and places the numbers above them.When any of that does not add up, less is drawn rather than something wrong. A draft the layout has no rules for (an emoji, CJK, a tab) is not numbered. And while Claude Code takes the footer's first row back for a line of its own (Press Ctrl-C again to exit, a paste it offers to expand), it draws none of the mod's tree, so the footer is Claude Code's own until the line is gone.
Claude Code tells a mod nothing of the permission mode: no call reads it, no event follows shift+tab, and the hint a mod is handed has the mode cut out of it. What the mod can see is how much of the row the engine's mark takes, since the mod's own tree gets the rest. A strip of no height in that tree (measure.tsx, a Client) reports how wide it was laid out; each mode's mark has its own width, so the width names the mode, and the bar draws its name over the mark.
Two marks a cell apart can be left the same room once the row has rounded its cells, so at each terminal width the mod asks for the few cells more that leave every mark a room of its own (tuningOf). A width that fits no mode, or a mode the engine's own line contradicts, is not named: the mark keeps its slot at the head of the bar instead.
The reading rests on how one version of Claude Code lays its footer out, so it is believed per version: on the ones it was checked on (VERIFIED in format.ts), and on any other once it has read a mode known another way. A session starts in the mode the settings name (permissions.defaultMode, the manual one without it) unless the command line asks for another, so the first reading after the start is held against that; and the engine names the mode each prompt goes out under (UserPromptSubmit), so the reading at that moment is held against it. After a Claude Code update, then, the first session names the mode as soon as its strip is measured, and one started in another mode (--permission-mode) waits for its first prompt. A version it once read wrong on stays unnamed, and shows the mark.
None of this is a documented layout. If a Claude Code update moves the prompt, the bar may land in the wrong place until the mod is fixed for it.
esc to interrupt, ← for agents) are covered by the bar.hooks/register.tsx 800 lines1import { atom, read, update } from 'claude-code'
2import type {
3 EngineInterface,
4 PromptBox,
5 Register,
6 RenderSurface,
7 RenderViewport,
8 SessionContextUsage,
9 SessionCost,
10 Timer,
11} from 'claude-code'
12
13import type { Box, Git, Usage } from '../types'
14import {
15 EDGE,
16 EFFORT_ENTRY,
17 GUTTER,
18 MIN_OVERLAID_COLUMNS,
19 NO_USAGE,
20 ORIGIN,
21 UNPLACED,
22 announcedEfforts,
23 boxRowsOf,
24 contradicts,
25 draftOf,
26 editorMode,
27 effortLevel,
28 fitBlock,
29 fitLeft,
30 fitLine,
31 fitRight,
32 isBelieved,
33 isInserting,
34 minRowsOf,
35 expandedRowsOf,
36 readingOf,
37 sharesMark,
38 standsIn,
39 startModeOf,
40 transcriptPath,
41 verdictsOf,
42} from './format'
43import { BRANCH, COMMIT, DIFFSTAT, GIT_ENV, branchOf, diffstatOf } from './git'
44import { StatusBlock, StatusNote, StatusRow } from './view'
45import { layOut } from './wrap'
46
47const POLL_MS = 100
48// The vim editor's normal mode changes the draft with no event at all, so there it is read each frame or two.
49const FAST_POLL_MS = 33
50const SETTLE_MS = 150
51const WIDE = 120
52// Claude Code 2.1.289 tells the band a few columns fewer than the screen with nothing docked; a pane
53// docked beside the transcript takes many more.
54const DOCKED = 10
55const TALL = 40
56// Where the verdicts on the permission label are kept between sessions.
57const VERDICTS = 'label-verdicts'
58// How long after the start the mark's first reading still stands for the mode the session started
59// in. It comes within a second; past this the mode may have been changed.
60const START_MS = 5000
61// The slash command that stands the box at its expanded height, for a key to be bound to
62// (`command:expand`); `:expand` in the vim mod's command line runs it too.
63const EXPAND = 'expand'
64// How often the working copy's git state is read besides after each tool call, as opencode.vim reads
65// it, and how long one git command may take.
66const GIT_MS = 5000
67const GIT_TIMEOUT_MS = 1500
68
69const input = atom({ plugin: 'statusline', key: 'input' } as const, ORIGIN)
70const box = atom({ plugin: 'statusline', key: 'box' } as const, UNPLACED)
71const isBoxPlain = atom({ plugin: 'statusline', key: 'isBoxPlain' } as const, true)
72const pins = atom({ plugin: 'statusline', key: 'pins' } as const, [])
73const mode = atom({ plugin: 'statusline', key: 'mode' } as const, 'INSERT')
74const reading = atom({ plugin: 'statusline', key: 'reading' } as const, null)
75const isLabelBelieved = atom({ plugin: 'statusline', key: 'isLabelBelieved' } as const, false)
76const model = atom({ plugin: 'statusline', key: 'model' } as const, '')
77const effort = atom({ plugin: 'statusline', key: 'effort' } as const, null)
78const isVim = atom({ plugin: 'statusline', key: 'isVim' } as const, false)
79const transcript = atom({ plugin: 'statusline', key: 'transcript' } as const, null)
80const usage = atom({ plugin: 'statusline', key: 'usage' } as const, NO_USAGE)
81const git = atom({ plugin: 'statusline', key: 'git' } as const, null)
82const isExpanded = atom({ plugin: 'statusline', key: 'isExpanded' } as const, false)
83// The vim mod's command line, where it is installed: what is typed, what it said last, and its
84// completions. Read here and drawn in the bar; only that mod writes them.
85const command = atom({ plugin: 'vim', key: 'command' } as const, null)
86const echo = atom({ plugin: 'vim', key: 'echo' } as const, null)
87const menu = atom({ plugin: 'vim', key: 'menu' } as const, null)
88
89let poll: { timer: Timer; ms: number } | undefined
90let settle: Timer | undefined
91let columns = WIDE
92let prompt: PromptBox = { text: '', cursor: 0 }
93let edits = 0
94// The editor's mode as last published, for the syntax mod to read.
95let seenMode: string | undefined
96// Whether the footer shows the git state, the state last drawn, and a read of it under way, with
97// whether another was asked for meanwhile.
98let hasGit = true
99let seenGit: string | undefined
100let gitRead: { isAgain: boolean } | undefined
101let band: { maxRows: number; height: number } | undefined
102// The plugins with a status line pinned under the prompt: a row each, between its rule and the footer.
103let pinned = new Set<string>()
104// The permission mode the engine's mark was last read as, believed or not, and whether the session
105// has just started with the mark yet to be read.
106let marked: string | null = null
107let isStarting = false
108let engine: string | undefined
109let seenPrompt: string | undefined
110let seenBox: string | undefined
111// What keeps the draft's lines from being numbered, each learned on its own.
112let isGutterTaken = false
113let hasStatusLine = false
114let seenPlain: boolean | undefined
115let seenEffort: string | null | undefined
116let seenReading: string | undefined
117let seenBelieved: boolean | undefined
118// The session the mod last started for, and the timer that reads the git state.
119let session: string | undefined
120let gitTimer: Timer | undefined
121
122// Nothing here is worth failing a hook over: what cannot be read stays as it was drawn.
123const quietly = async ($: EngineInterface, label: string, work: Promise<unknown>) => {
124 try {
125 await work
126 } catch (error) {
127 $.ui.log(`${label}: ${String(error)}`, { to: 'debug' })
128 }
129}
130
131const measured = (context: SessionContextUsage, cost: SessionCost | undefined): Usage => ({
132 tokens: context.tokens ?? null,
133 percent: context.percent ?? null,
134 usd: cost?.usd ?? null,
135})
136
137// Whether the rows laid out here are the rows the engine drew the box in: what is drawn beside the
138// draft's text goes by the first and is only safe while the second agrees. The engine's count and
139// the draft arrive apart, so a difference is only believed once both have settled; and a draft that
140// is laid out here and still stands in other rows means the rows around it are not the known ones.
141const verify = async ($: EngineInterface, isSettled: boolean) => {
142 if (band === undefined) {
143 return
144 }
145
146 const under = pinned.size
147 const rows = boxRowsOf(band.height, band.maxRows, under)
148 const laid = layOut(prompt.text, columns - GUTTER - EDGE)
149 const isSame = laid !== null && standsIn(band.height, band.maxRows, under, laid.length)
150
151 if (!isSame && !isSettled) {
152 verifySoon($)
153
154 return
155 }
156
157 const next: Box = { rows, under, isAligned: isSame }
158 const seen = JSON.stringify(next)
159
160 if (seen !== seenBox) {
161 seenBox = seen
162 await update($, box, () => next)
163 }
164}
165
166const verifySoon = ($: EngineInterface) => {
167 settle?.cancel()
168 settle = $.clock.after(SETTLE_MS, () => {
169 void quietly($, 'rows', verify($, true))
170 })
171}
172
173const setPrompt = async ($: EngineInterface, next: PromptBox) => {
174 prompt = next
175
176 if (next.text === '') {
177 poll?.timer.cancel()
178 poll = undefined
179 }
180
181 const seen = `${next.cursor}:${next.text}`
182
183 if (seen === seenPrompt) {
184 return
185 }
186
187 seenPrompt = seen
188 await update($, input, () => draftOf(next.text, next.cursor))
189
190 verifySoon($)
191}
192
193const syncBox = async ($: EngineInterface) => {
194 const before = edits
195 const now = await $.prompt.read()
196
197 // A keystroke landed while the box was being read: what its hook saw is the newer.
198 if (before !== edits) {
199 return
200 }
201
202 await setPrompt($, now)
203}
204
205// History recall, completion and the vim editor's normal mode all change the draft without raising
206// `prompt.edit`, so the box is read on a timer for as long as it holds one.
207const watchBox = ($: EngineInterface, ms: number) => {
208 if (poll?.ms === ms) {
209 return
210 }
211
212 poll?.timer.cancel()
213 poll = {
214 ms,
215 timer: $.clock.every(ms, () => {
216 void quietly($, 'cursor', syncBox($))
217 }),
218 }
219}
220
221// A draw cannot write, so the mode read off the hint is written on the next tick: the syntax mod
222// reads it to color a shell command as one.
223const publishMode = ($: EngineInterface, label: string) => {
224 if (label === seenMode) {
225 return
226 }
227
228 seenMode = label
229 $.clock.after(0, () => {
230 void quietly($, 'mode', update($, mode, () => label))
231 })
232}
233
234// The band above the prompt is drawn by nobody here; what it is told is how tall the prompt stands.
235const noteBand = ($: EngineInterface, maxRows: number, height: number) => {
236 band = { maxRows, height }
237 $.clock.after(0, () => {
238 void quietly($, 'rows', verify($, false))
239 })
240}
241
242// The numbers go by a box that stands as the engine draws it for the main conversation: two cells
243// of gutter beside the draft, and between the box and the footer only the rows other plugins pin.
244const publishPlain = ($: EngineInterface) => {
245 const isPlain = !isGutterTaken && !hasStatusLine
246
247 if (isPlain === seenPlain) {
248 return
249 }
250
251 seenPlain = isPlain
252 $.clock.after(0, () => {
253 void quietly($, 'box', update($, isBoxPlain, () => isPlain))
254 })
255}
256
257// An agent's transcript in view puts the agent's name in the gutter, and a pane docked beside the
258// transcript leaves the prompt narrower than it is laid out here.
259const noteGutter = ($: EngineInterface, isTaken: boolean) => {
260 isGutterTaken = isTaken
261 publishPlain($)
262}
263
264// A status line command draws what it prints between the box and the footer, however many rows.
265const syncStatusLine = async ($: EngineInterface) => {
266 hasStatusLine = (await $.settings.read()).statusLine !== undefined
267 publishPlain($)
268}
269
270// Another plugin pinned a status line, or took one down: a row under the prompt's rule either way.
271// The engine lays the prompt out again for it, which is when the rows are checked.
272const notePin = ($: EngineInterface, plugin: string, isUp: boolean) => {
273 if (isUp === pinned.has(plugin)) {
274 return
275 }
276
277 pinned = new Set(isUp ? [...pinned, plugin] : [...pinned].filter(name => name !== plugin))
278 const names = [...pinned]
279 $.clock.after(0, () => {
280 void quietly($, 'pins', update($, pins, () => names))
281 })
282 verifySoon($)
283}
284
285const loadPins = async ($: EngineInterface) => {
286 pinned = new Set([...(await read($, pins)), ...pinned])
287}
288
289// The strip in the footer's first row says how wide the engine laid it out.
290const noteReading = async ($: EngineInterface, data: unknown) => {
291 const next = readingOf(data)
292 const seen = JSON.stringify(next)
293
294 if (next !== null && seen !== seenReading) {
295 seenReading = seen
296 await update($, reading, () => next)
297 }
298}
299
300const setBelieved = async ($: EngineInterface, isOn: boolean) => {
301 if (isOn !== seenBelieved) {
302 seenBelieved = isOn
303 await update($, isLabelBelieved, () => isOn)
304 }
305}
306
307// The label is read off how the engine lays its own mark out, which another version may do another
308// way. So each version is believed on its record: what was checked when this was written, and since
309// then what the label read as a session started in a known mode, and each time a prompt went out
310// under a mode the engine named.
311const syncBelieved = async ($: EngineInterface) => {
312 const { version } = await $.session.version()
313 engine = version
314 await setBelieved($, isBelieved(version, verdictsOf(await $.store.get(VERDICTS))))
315}
316
317// A prompt is sent under a mode the engine names: the one moment the label can be held against it.
318// A mode changed in the same breath is drawn a moment later, so a difference is looked at twice.
319const judgeLabel = async ($: EngineInterface, said: string) => {
320 if (marked !== null && marked !== said) {
321 await $.clock.sleep(SETTLE_MS)
322 }
323
324 const version = engine
325 const found = marked
326
327 if (version === undefined || found === null) {
328 return
329 }
330
331 if (sharesMark(found, said)) {
332 await setBelieved($, false)
333
334 return
335 }
336
337 const verdicts = verdictsOf(await $.store.get(VERDICTS))
338 const isTrue = found === said && verdicts[version] !== false
339
340 if (verdicts[version] !== isTrue) {
341 await $.store.set(VERDICTS, { ...verdicts, [version]: isTrue })
342 }
343
344 await setBelieved($, isTrue)
345}
346
347// A session starts in the mode its settings name unless the command line asks for another. So a
348// first reading of that mode, which the engine's own line does not contradict, is as good as a
349// prompt sent under it, on a version with no verdict yet; any other is left for a prompt to judge.
350const confirmStart = async ($: EngineInterface, found: string, hint: string) => {
351 if (found !== startModeOf(await $.settings.read()) || contradicts(hint, found)) {
352 return
353 }
354
355 const { version } = await $.session.version()
356 const verdicts = verdictsOf(await $.store.get(VERDICTS))
357
358 if (verdicts[version] === undefined) {
359 await $.store.set(VERDICTS, { ...verdicts, [version]: true })
360 await setBelieved($, true)
361 }
362}
363
364// The first reading since the session started is held against the mode it started in, on the next
365// tick: a draw cannot write.
366const noteFirstReading = ($: EngineInterface, found: string | null, hint: string) => {
367 if (!isStarting || found === null) {
368 return
369 }
370
371 isStarting = false
372 $.clock.after(0, () => {
373 void quietly($, 'label', confirmStart($, found, hint))
374 })
375}
376
377// Every tool call reports the effort, so only a change is written, and drawn.
378const setEffort = async ($: EngineInterface, level: string | null) => {
379 if (level !== seenEffort) {
380 seenEffort = level
381 await update($, effort, () => level)
382 }
383}
384
385const syncModel = async ($: EngineInterface) => {
386 const id = await $.session.model()
387
388 await update($, model, () => id)
389}
390
391const syncVim = async ($: EngineInterface) => {
392 const rows = await $.config.list()
393 const isOn = rows.some(row => row.key === 'editor' && row.value === 'vim')
394
395 await update($, isVim, () => isOn)
396}
397
398const syncUsage = async ($: EngineInterface) => {
399 const { context, cost } = await $.session.usage()
400
401 await update($, usage, () => measured(context, cost))
402}
403
404// Until a turn reports the effort it ran at, the configured one is the best there is to show.
405const seedEffort = async ($: EngineInterface) => {
406 if ((await read($, effort)) !== null) {
407 return
408 }
409
410 const configured =
411 effortLevel(await $.env.get('CLAUDE_CODE_EFFORT_LEVEL')) ?? effortLevel((await $.settings.read()).effortLevel)
412
413 if (configured !== null) {
414 await setEffort($, configured)
415 }
416}
417
418// A classic hook event names the transcript; before the first one, its usual place is tried.
419const findTranscript = async ($: EngineInterface) => {
420 const held = await read($, transcript)
421
422 if (held !== null) {
423 return held
424 }
425
426 const home = await $.env.get('HOME')
427 const configDirectory = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? (home === undefined ? undefined : `${home}/.claude`)
428
429 if (configDirectory === undefined) {
430 return null
431 }
432
433 const guess = transcriptPath(configDirectory, await $.session.root(), await $.session.id())
434
435 if (!(await $.fs.exists(guess))) {
436 return null
437 }
438
439 await update($, transcript, () => guess)
440
441 return guess
442}
443
444const readAnnounced = async ($: EngineInterface) => {
445 const path = await findTranscript($)
446
447 if (path === null) {
448 return []
449 }
450
451 const { stdout } = await $.process.run(['grep', '-a', '-o', '-E', EFFORT_ENTRY, path])
452
453 return announcedEfforts(stdout)
454}
455
456// The picker `/effort` opens answers nothing a hook can read: what it set is the row it leaves in
457// the transcript a moment later, and a cancelled one leaves none.
458const adoptAnnounced = async ($: EngineInterface, known: number) => {
459 for (const wait of [100, 400, 1500]) {
460 await $.clock.sleep(wait)
461 const levels = await readAnnounced($)
462 const last = levels.at(-1)
463
464 if (levels.length > known && last !== undefined) {
465 await setEffort($, last)
466
467 return
468 }
469 }
470}
471
472// The session's transcript, which classic hook events name: where `/effort`'s picker leaves its level.
473const adoptSession = async ($: EngineInterface, path: string) => {
474 if ((await read($, transcript)) !== path) {
475 await update($, transcript, () => path)
476 }
477}
478
479// One git command; null where git is missing, refuses to run or takes too long.
480const runGit = async ($: EngineInterface, argv: readonly string[]) => {
481 try {
482 return await $.process.run(argv, { env: GIT_ENV, timeoutMs: GIT_TIMEOUT_MS })
483 } catch {
484 return null
485 }
486}
487
488// The branch and the counts, read in the session's folder as opencode.vim reads them; null outside
489// a repository. A repository with no commit yet has its branch and no counts.
490const readGit = async ($: EngineInterface): Promise<Git | null> => {
491 const onBranch = await runGit($, BRANCH)
492 const onCommit = onBranch !== null && onBranch.exitCode !== 0 ? await runGit($, COMMIT) : null
493 const branch = branchOf(onBranch?.exitCode === 0 ? onBranch.stdout : null, onCommit?.exitCode === 0 ? onCommit.stdout : null)
494
495 if (branch === null) {
496 return null
497 }
498
499 const diff = await runGit($, DIFFSTAT)
500
501 return { branch, ...(diff?.exitCode === 0 ? diffstatOf(diff.stdout) : { additions: 0, deletions: 0 }) }
502}
503
504// Asks while a read is under way come to one more read once it is done.
505const syncGit = async ($: EngineInterface) => {
506 if (gitRead !== undefined) {
507 gitRead.isAgain = true
508
509 return
510 }
511
512 const reading = { isAgain: true }
513 gitRead = reading
514
515 try {
516 while (reading.isAgain) {
517 reading.isAgain = false
518 const next = await readGit($)
519 const seen = JSON.stringify(next)
520
521 if (seen !== seenGit) {
522 seenGit = seen
523 await update($, git, () => next)
524 }
525 }
526 } finally {
527 gitRead = undefined
528 }
529}
530
531const boot = async ($: EngineInterface) => {
532 await Promise.all([
533 quietly($, 'pins', loadPins($)),
534 quietly($, 'label', syncBelieved($)),
535 quietly($, 'model', syncModel($)),
536 quietly($, 'editor', syncVim($)),
537 quietly($, 'status line', syncStatusLine($)),
538 quietly($, 'effort', seedEffort($)),
539 quietly($, 'usage', syncUsage($)),
540 quietly($, 'cursor', syncBox($)),
541 quietly($, 'expand', $.command.register({ name: EXPAND, description: 'Make the prompt taller, or back to its height', immediate: true })),
542 quietly($, 'git', hasGit ? syncGit($) : update($, git, () => null)),
543 ])
544
545 // Files change between tool calls too, from an editor or a terminal of the person's own.
546 if (hasGit) {
547 gitTimer ??= $.clock.every(GIT_MS, () => {
548 void quietly($, 'git', syncGit($))
549 })
550 }
551}
552
553// Another session opened in the same process (`/resume`, `/clear`): the mod stays loaded, but the
554// values it draws from are the new session's, all unset. What it last wrote is forgotten, so that
555// everything is written again rather than taken for already there.
556const switchSession = async ($: EngineInterface, id: string) => {
557 const isSwitch = session !== undefined && session !== id
558 session = id
559
560 if (!isSwitch) {
561 return
562 }
563
564 seenGit = undefined
565 seenPrompt = undefined
566 seenBox = undefined
567 seenPlain = undefined
568 seenEffort = undefined
569 seenReading = undefined
570 seenBelieved = undefined
571 seenMode = undefined
572 await boot($)
573}
574
575export const register: Register = (on, options) => {
576 const minRows = minRowsOf(options.minLines)
577 const expandedRows = expandedRowsOf(options.expandedLines, minRows)
578 hasGit = options.git !== false
579
580 // Only the fullscreen terminal lets a site draw outside itself, and only there is the box laid out
581 // as the block expects.
582 const overlays = (surface: RenderSurface, viewport: RenderViewport | undefined) =>
583 surface === 'terminal' &&
584 viewport?.isFullscreen === true &&
585 viewport.columns >= MIN_OVERLAID_COLUMNS
586
587 on('session.start', async ($, e, next) => {
588 const started = await next(e)
589 void boot($)
590
591 return started
592 })
593
594 on('classic.SessionStart', ($, e, next) => {
595 // The process starts with this session, in a mode known without a prompt until the person has
596 // had the time to change it.
597 if (e.source === 'startup' && marked === null) {
598 isStarting = true
599 $.clock.after(START_MS, () => {
600 isStarting = false
601 })
602 }
603
604 void quietly($, 'session', switchSession($, e.session_id))
605 void quietly($, 'session', adoptSession($, e.transcript_path))
606 void quietly($, 'model', syncModel($))
607 void quietly($, 'usage', syncUsage($))
608 void quietly($, 'cursor', syncBox($))
609
610 return next(e)
611 })
612
613 on('classic.UserPromptSubmit', ($, e, next) => {
614 void quietly($, 'session', adoptSession($, e.transcript_path))
615 void quietly($, 'model', syncModel($))
616
617 if (e.agent_id === undefined && e.permission_mode !== undefined) {
618 void quietly($, 'label', judgeLabel($, e.permission_mode))
619 }
620
621 // A prompt that was composed tall is sent, and the box goes back to its height.
622 if (e.agent_id === undefined) {
623 void quietly($, 'expand', update($, isExpanded, () => false))
624 }
625
626 return next(e)
627 })
628
629 on('classic.PostToolUse', ($, e, next) => {
630 const level = effortLevel(e.effort?.level)
631
632 if (e.agent_id === undefined && level !== null) {
633 void quietly($, 'effort', setEffort($, level))
634 }
635
636 // A tool call may have changed files, an agent's as well as the session's own.
637 if (hasGit) {
638 void quietly($, 'git', syncGit($))
639 }
640
641 return next(e)
642 })
643
644 // A model that takes no effort reports none here, which clears the one shown.
645 on('classic.Stop', ($, e, next) => {
646 void quietly($, 'effort', setEffort($, effortLevel(e.effort?.level)))
647 void quietly($, 'session', adoptSession($, e.transcript_path))
648
649 return next(e)
650 })
651
652 on('classic.PostModelSwitch', ($, e, next) => {
653 void quietly($, 'model', update($, model, () => e.to_model))
654
655 return next(e)
656 })
657
658 on('session.measure', ($, e, next) => {
659 void quietly($, 'usage', update($, usage, () => measured(e.context, e.cost)))
660
661 return next(e)
662 })
663
664 on('config.set', { key: 'editor' }, async ($, e, next) => {
665 const set = await next(e)
666 void quietly($, 'editor', syncVim($))
667
668 return set
669 })
670
671 // A settings file changed under the session. The hooks are asked before the engine takes the
672 // change up, so what it may have changed is read a moment after they have answered.
673 on('classic.ConfigChange', async ($, e, next) => {
674 const answered = await next(e)
675 $.clock.after(SETTLE_MS, () => {
676 void quietly($, 'editor', syncVim($))
677 void quietly($, 'status line', syncStatusLine($))
678 })
679
680 return answered
681 })
682
683 // `/expand`, which a key can be bound to as `command:expand`.
684 on('command.run', { command: EXPAND }, async $ => {
685 await update($, isExpanded, held => !held)
686
687 return {}
688 })
689
690 on('command.run', { command: 'effort' }, async ($, e, next) => {
691 const asked = effortLevel(e.args)
692 const known = asked === null ? (await readAnnounced($).catch(() => [])).length : 0
693 const ran = await next(e)
694 void quietly($, 'effort', asked === null ? adoptAnnounced($, known) : setEffort($, asked))
695
696 return ran
697 })
698
699 // Every keystroke passes here with the draft it leaves: where the cursor is.
700 on('prompt.edit', async ($, e, next) => {
701 const edited = await next(e)
702 edits += 1
703 void quietly($, 'cursor', setPrompt($, { text: edited.text, cursor: edited.cursor }))
704
705 return edited
706 })
707
708 on('ui.status', ($, e, next) => {
709 notePin($, next.origin.plugin, e.text !== undefined && e.text !== '')
710
711 return next(e)
712 })
713
714 // The band above the prompt tells how tall the prompt stands. Nothing is drawn there.
715 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
716 if (e.viewport !== undefined) {
717 noteBand($, e.props.maxRows, e.viewport.rows)
718 noteGutter($, e.props.view.agentId !== undefined || e.props.bodyColumns < e.viewport.columns - DOCKED)
719 }
720
721 return next(e)
722 })
723
724 on('ui.message', ($, e, next) => {
725 void quietly($, 'reading', noteReading($, e.data))
726
727 return next(e)
728 })
729
730 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
731 // The engine's own line while it waits for a second ctrl+c or ctrl+d.
732 if (e.props.hint.startsWith('Press ')) {
733 return next(e)
734 }
735
736 const [vim, id, level, at, spent, typed, answer, offered, repository, expanded] = await Promise.all([
737 read($, isVim),
738 read($, model),
739 read($, effort),
740 read($, input),
741 read($, usage),
742 read($, command),
743 read($, echo),
744 read($, menu),
745 read($, git),
746 read($, isExpanded),
747 ])
748 const width = e.viewport?.columns ?? WIDE
749 const label = editorMode(e.props.hint, vim)
750 // The badge names the command line while it is open, as a vim status line does.
751 const shown = typed === null ? label : 'COMMAND'
752 publishMode($, label)
753
754 if (e.props.isDraft) {
755 watchBox($, vim && !isInserting(label) ? FAST_POLL_MS : POLL_MS)
756 }
757
758 if (e.surface !== 'terminal' || !overlays(e.surface, e.viewport)) {
759 return StatusRow($.ui.resolve(e), fitLeft({ columns: width, mode: shown, model: id, effort: level }), fitLine(width, shown, typed, answer), offered)
760 }
761
762 const [stands, measured, believed, plain] = await Promise.all([read($, box), read($, reading), read($, isLabelBelieved), read($, isBoxPlain)])
763 const block = fitBlock({
764 columns: width,
765 // The band is drawn again when the screen's height changes, which this row is not.
766 height: band?.height ?? e.viewport?.rows ?? TALL,
767 hint: e.props.hint,
768 draft: at,
769 mode: shown,
770 model: id,
771 effort: level,
772 box: stands,
773 reading: measured,
774 usage: spent,
775 isNumbered: options.lineNumbers !== false && plain,
776 minRows: plain ? (expanded ? expandedRows : minRows) : 1,
777 isBelieved: believed,
778 command: typed,
779 echo: answer,
780 menu: offered,
781 git: repository,
782 })
783
784 marked = block.read
785 noteFirstReading($, block.read, e.props.hint)
786
787 return StatusBlock($.ui.resolve(e), block)
788 })
789
790 on('ui.render', { component: 'SessionMode' }, async ($, e) => {
791 const width = e.viewport?.columns ?? WIDE
792 const [at, spent, repository] = await Promise.all([read($, input), read($, usage), read($, git)])
793 const { cursor, usage: used, git: branch } = fitRight(width, e.props.modes, at, spent, repository)
794 const note = [cursor, branch, used].filter(text => text !== '').join(' · ')
795
796 // The block has the cursor, the git state and the usage; the engine's own labels keep this site.
797 return StatusNote($.ui.resolve(e), overlays(e.surface, e.viewport) ? e.props.modes.join(' & ') : note)
798 })
799}
800hooks/format.ts 581 lines1import type { Box, Cursor, Draft, Echo, Git, Menu, Reading, Usage } from '../types'
2import { MAX_LENGTH, cursorIn, layOut } from './wrap'
3import type { Row } from './wrap'
4
5// The editor's mode, the permission mode where the bar names it (`''` where it does not), the model
6// and the effort.
7export type Segments = { mode: string; permission: string; model: string; provider: string; effort: string }
8
9export type Right = { cursor: string; usage: string; git: string }
10
11// The git part of the bar as drawn: the branch after its icon, and each count that is
12// not zero.
13export type GitPart = { branch: string; added: string; deleted: string }
14
15// A number drawn over the box's gutter: the line's, and whether the cursor is on that line.
16export type LineNumber = { label: string; isCurrent: boolean }
17
18// What the bar says after its badge in place of the rest of its left half: the command line while it is
19// open, with a cell after it for its cursor, or what the last command answered.
20export type Line = { text: string; hasCursor: boolean; isWarning: boolean }
21
22// The completions as drawn just above the command line, as vim's popup menu stands over its own:
23// each row's name and description, cut to the menu's cells, and which row is picked.
24export type MenuRow = { name: string; description: string; isPicked: boolean }
25
26export type MenuBlock = { rows: MenuRow[]; nameWidth: number; width: number }
27
28
29// What the left-hand site draws in the fullscreen terminal: the status bar on the footer's first row
30// and a blank row under it, the line numbers over the prompt box's gutter, and the rows added under a
31// box shorter than it is to stand. `under` is the rows other plugins pinned between the box and the
32// footer. `slot` is the cells kept clear at the head of the bar for the engine's own permission mark,
33// where the bar does not name the mode itself, and `read` the mode the mark's width says it is,
34// believed or not.
35export type Block = {
36 columns: number
37 tuning: number
38 bar: Segments & { cursor: string }
39 slot: number
40 // The command line, which stands after the badge in place of the rest of the bar's left half.
41 line: Line | null
42 menu: MenuBlock | null
43 // The git state and the usage, at the head of the bar's right half, before the cursor.
44 git: GitPart | null
45 usage: string
46 // The blank cells between the bar's two halves, drawn so that they cover the engine's mark.
47 gap: number
48 // The line numbers drawn over the box's gutter, one entry for each row the box shows, from the
49 // top: null on a row that carries a line on, and null in all where the gutter is left as the
50 // engine draws it.
51 numbers: readonly (LineNumber | null)[] | null
52 // The rows drawn under a box shorter than it is to stand, so that it reads as that tall: its own
53 // rule blanked, blank rows of the footer's under it, and a rule on the last of them.
54 pad: number
55 under: number
56 read: string | null
57}
58
59// One permission mode as Claude Code marks it at the head of the footer: a symbol, the mode's name and
60// ` on`, this many cells in all.
61export type Permission = { mode: string; label: string; cells: number; mark: string }
62
63type Size = 'full' | 'compact' | 'tiny'
64
65type Facts = { columns: number; mode: string; model: string; effort: string | null }
66
67type Named = Omit<Facts, 'columns'> & { permission: string }
68
69export const ORIGIN: Draft = { line: 1, column: 1, percent: 100, text: '', offset: 0 }
70
71export const UNPLACED: Box = { rows: null, under: 0, isAligned: false }
72
73export const NO_USAGE: Usage = { tokens: null, percent: null, usd: null }
74
75// Where `/effort` announced the level it set, as `grep -o -E` cuts it out of a transcript's rows.
76export const EFFORT_ENTRY = '"content":"<local-command-stdout>Set effort level to [a-z]+'
77
78// The fullscreen prompt as Claude Code lays it out: the draft starts two cells in, and the footer
79// keeps two cells clear of each edge of the screen.
80export const GUTTER = 2
81export const EDGE = 2
82
83// The narrowest terminal the block is drawn in.
84export const MIN_OVERLAID_COLUMNS = 64
85
86// `dontAsk` cannot be cycled to, is marked as wide as `auto`, and would read as it.
87export const PERMISSIONS: readonly Permission[] = [
88 { mode: 'plan', label: 'Plan', cells: 14, mark: '⏸ plan mode on' },
89 { mode: 'auto', label: 'Auto', cells: 15, mark: '⏵⏵ auto mode on' },
90 { mode: 'default', label: 'Manual', cells: 16, mark: '⏸ manual mode on' },
91 { mode: 'acceptEdits', label: 'Accept edits', cells: 18, mark: '⏵⏵ accept edits on' },
92 { mode: 'bypassPermissions', label: 'Bypass', cells: 24, mark: '⏵⏵ bypass permissions on' },
93]
94
95// The ` · ` the engine draws after its mark, and the room the widest mark takes with it.
96const SEPARATOR = 3
97export const MARK_SLOT = 24 + SEPARATOR
98
99// The versions of Claude Code the label was checked on, mode by mode and width by width.
100export const VERIFIED = ['2.1.287', '2.1.291']
101
102// The footer row is shared with the engine's own mode pill on the left.
103const PILL_COLUMNS = 24
104const RIGHT_COLUMNS: Record<Size, number> = { full: 22, compact: 14, tiny: 6 }
105
106const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max']
107
108// claude-opus-5-5[1m], us.anthropic.claude-opus-4-1-20250805-v1:0
109const FAMILY_FIRST = /claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?!\d)/
110// claude-3-5-sonnet-20241022
111const VERSION_FIRST = /claude-(\d+)(?:-(\d{1,2}))?-([a-z]+)/
112
113const capitalize = (word: string) => word.charAt(0).toUpperCase() + word.slice(1)
114
115const version = (major: string, minor: string | undefined) => (minor === undefined ? major : `${major}.${minor}`)
116
117const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max)
118
119const sizeOf = (columns: number): Size => (columns >= 80 ? 'full' : columns >= 56 ? 'compact' : 'tiny')
120
121export const modelName = (id: string, isShort = false) => {
122 const brand = isShort ? '' : 'Claude '
123 const familyFirst = FAMILY_FIRST.exec(id)
124
125 if (familyFirst !== null) {
126 const [, family = '', major = '', minor] = familyFirst
127
128 return `${brand}${capitalize(family)} ${version(major, minor)}`
129 }
130
131 const versionFirst = VERSION_FIRST.exec(id)
132
133 if (versionFirst !== null) {
134 const [, major = '', minor, family = ''] = versionFirst
135
136 return `${brand}${version(major, minor)} ${capitalize(family)}`
137 }
138
139 return id.replace(/\[[^\]]*\]$/, '')
140}
141
142export const providerName = (id: string) => {
143 if (/(^|[./])anthropic\./.test(id)) {
144 return 'Bedrock'
145 }
146
147 return id.includes('@') ? 'Vertex AI' : 'Anthropic'
148}
149
150export const effortLevel = (value: unknown) => {
151 const level = typeof value === 'string' ? value.trim().toLowerCase() : ''
152
153 return EFFORTS.includes(level) ? level : null
154}
155
156// Every level the matches of EFFORT_ENTRY name, oldest first.
157export const announcedEfforts = (matches: string) =>
158 matches.split('\n').flatMap(match => effortLevel(match.split(' ').at(-1)) ?? [])
159
160export const formatTokens = (tokens: number) => {
161 if (tokens < 1000) {
162 return String(tokens)
163 }
164
165 return tokens < 999_950 ? `${(tokens / 1000).toFixed(1)}K` : `${(tokens / 1_000_000).toFixed(1)}M`
166}
167
168const usageText = ({ tokens, percent, usd }: Usage, size: Size) => {
169 if (size === 'tiny') {
170 return `${percent ?? 0}%`
171 }
172
173 const fill = `${formatTokens(tokens ?? 0)} (${percent ?? 0}%)`
174
175 return size === 'compact' || usd === null ? fill : `${fill} · $${usd.toFixed(2)}`
176}
177
178const cursorText = ({ line, column, percent }: Cursor, size: Size) => {
179 if (size === 'full') {
180 return `Ln ${line}, Col ${column} · ${percent}%`
181 }
182
183 return size === 'compact' ? `${line}:${column} · ${percent}%` : `${line}:${column}`
184}
185
186// The engine's own labels beside the cursor (`focus`, `memory paused`) keep their place where there is room.
187const labelled = (columns: number, modes: readonly string[], cursor: Cursor) =>
188 `${columns >= 110 && modes.length > 0 ? `${modes.join(' & ')} · ` : ''}${cursorText(cursor, sizeOf(columns))}`
189
190// The footer: the bar and the blanked row under it.
191const FOOTER_ROWS = 2
192
193// The rows around the draft in the fullscreen prompt: its two rules, the notice row above them and
194// the footer.
195const AROUND = 3 + FOOTER_ROWS
196
197// The box grows with the draft until it stands five rows short of half the screen, then scrolls.
198export const rowCap = (height: number, under: number) => Math.max(3, Math.floor(height / 2) - 5 - under)
199
200// The band above the prompt is told the rows it may take: what the prompt leaves of half the screen.
201// So the rows the box is drawn in follow from it, given the rows pinned under the box. A band left
202// no rows says only that the box stands at its tallest or a row short of it, and the taller is taken;
203// too small a screen says nothing.
204export const boxRowsOf = (height: number, maxRows: number, under: number) => {
205 if (height < 16) {
206 return null
207 }
208
209 return maxRows >= 1 ? Math.floor(height / 2) - AROUND - under - maxRows : rowCap(height, under)
210}
211
212// Whether a draft laid out in `laid` rows stands in the rows the engine draws the box in.
213export const standsIn = (height: number, maxRows: number, under: number, laid: number) => {
214 const cap = rowCap(height, under)
215 const shown = Math.min(laid, cap)
216
217 return maxRows >= 1 ? boxRowsOf(height, maxRows, under) === shown : height >= 16 && shown >= cap - 1
218}
219
220// The percentage is vim's: how far down the draft the cursor's line is.
221export const locate = (text: string, offset: number): Cursor => {
222 const before = text.slice(0, offset)
223 const line = before.split('\n').length
224 const column = [...before.slice(before.lastIndexOf('\n') + 1)].length + 1
225
226 return { line, column, percent: Math.round((line / text.split('\n').length) * 100) }
227}
228
229// A draft too long to lay out keeps its cursor and drops its text.
230export const draftOf = (text: string, offset: number): Draft => ({
231 ...locate(text, offset),
232 text: text.length > MAX_LENGTH ? null : text,
233 offset,
234})
235
236// The hint leads with the vim editor's marker (`-- INSERT --`, `-- VISUAL --`, nothing in normal mode),
237// then with `! ` in shell mode; the default editor has no modes and always inserts. What is typed in
238// shell mode is a command, so there `SHELL` stands for inserting.
239export const editorMode = (hint: string, isVim: boolean) => {
240 const marker = /^-- ([A-Z ]+) -- ?/.exec(hint)
241 const mode = marker?.[1] ?? (isVim ? 'NORMAL' : 'INSERT')
242
243 if (!hint.slice(marker?.[0].length ?? 0).startsWith('! ')) {
244 return mode
245 }
246
247 return mode === 'INSERT' ? 'SHELL' : `SHELL ${mode}`
248}
249
250// Whether keys go into the draft as text, each of them an edit the engine raises.
251export const isInserting = (mode: string) => mode === 'INSERT' || mode === 'SHELL'
252
253export const truncate = (text: string, max: number) => {
254 const glyphs = [...text]
255
256 return glyphs.length <= max ? text : `${glyphs.slice(0, max - 1).join('').trimEnd()}…`
257}
258
259// The end of a text too long for its room: what is typed last is what has to show.
260const tail = (text: string, max: number) => {
261 const glyphs = [...text]
262
263 return glyphs.length <= max ? text : `…${glyphs.slice(glyphs.length - Math.max(0, max - 1)).join('')}`
264}
265
266// The command line as vim draws it, a colon and what is typed, in `room` cells with its cursor; or
267// the last answer, until it is taken down.
268export const lineOf = (command: string | null, echo: Echo | null, room: number): Line | null => {
269 if (command !== null) {
270 return { text: tail(`:${command}`, room - 1), hasCursor: true, isWarning: false }
271 }
272
273 return echo === null ? null : { text: truncate(echo.text, room), hasCursor: false, isWarning: echo.isWarning }
274}
275
276// <config>/projects/<the project's path, each character outside a-z, A-Z and 0-9 a dash>/<session>.jsonl
277export const transcriptPath = (configDirectory: string, root: string, sessionId: string) =>
278 `${configDirectory}/projects/${root.replace(/[^a-zA-Z0-9]/g, '-')}/${sessionId}.jsonl`
279
280// The cells the bar's left half takes: the badge and a cell either side of it, then each segment.
281export const columnsOf = ({ mode, permission, model, provider, effort }: Segments) =>
282 mode.length +
283 2 +
284 (permission === '' ? 0 : permission.length + (model === '' ? 1 : 3)) +
285 (model === '' ? 0 : model.length + 1) +
286 (provider === '' ? 0 : provider.length + 1) +
287 (effort === '' ? 0 : effort.length + 3)
288
289// The richest row that fits: the provider goes first, then the brand, then the effort, then the model.
290const richest = (room: number, { mode, permission, model, effort }: Named): Segments => {
291 const level = effort ?? ''
292 const isKnown = model !== ''
293 const name = isKnown ? modelName(model) : ''
294 const shortName = isKnown ? modelName(model, true) : ''
295 const rows = [
296 { mode, permission, model: name, provider: isKnown ? providerName(model) : '', effort: level },
297 { mode, permission, model: name, provider: '', effort: level },
298 { mode, permission, model: shortName, provider: '', effort: level },
299 { mode, permission, model: shortName, provider: '', effort: '' },
300 ]
301
302 return rows.find(row => columnsOf(row) <= room) ?? { mode, permission, model: '', provider: '', effort: '' }
303}
304
305export const fitLeft = (facts: Facts): Segments =>
306 richest(facts.columns - PILL_COLUMNS - RIGHT_COLUMNS[sizeOf(facts.columns)], { ...facts, permission: '' })
307
308// opencode.vim's icon for the branch, Nerd Font's `nf-oct-git_branch`, and how long a branch's
309// name is let stand: cut to fit, but not below the shorter.
310export const GIT_ICON = '\uf418'
311const MAX_BRANCH = 24
312const MIN_BRANCH = 8
313
314// The cells the git part takes: the icon, a space, the branch, and each count after a space.
315export const gitCells = (part: GitPart | null) =>
316 part === null ? 0 : 2 + part.branch.length + (part.added === '' ? 0 : part.added.length + 1) + (part.deleted === '' ? 0 : part.deleted.length + 1)
317
318// The git part and the usage in `room` cells, two between them. What gives way first is what
319// opencode.vim lets go first: the usage, then the counts, then the branch's length.
320export const fitGit = (room: number, git: Git | null, usage: string): { git: GitPart | null; usage: string } => {
321 if (git === null) {
322 return { git: null, usage: usage.length <= room ? usage : '' }
323 }
324
325 const branch = truncate(git.branch, MAX_BRANCH)
326 const counted = { branch, added: git.additions > 0 ? `+${git.additions}` : '', deleted: git.deletions > 0 ? `-${git.deletions}` : '' }
327 const bare = { branch, added: '', deleted: '' }
328
329 if (gitCells(counted) + 2 + usage.length <= room) {
330 return { git: counted, usage }
331 }
332
333 const fitting = [counted, bare].find(part => gitCells(part) <= room)
334
335 if (fitting !== undefined) {
336 return { git: fitting, usage: '' }
337 }
338
339 return room - 2 >= MIN_BRANCH ? { git: { ...bare, branch: truncate(branch, room - 2) }, usage: '' } : { git: null, usage: '' }
340}
341
342// The menu shows this many completions at most, and stays this narrow.
343const MENU_ROWS = 8
344const MENU_NAME = 24
345const MENU_WIDTH = 72
346
347// A window of the completions the hooks keep that holds the picked one, in the rows there are: the
348// menu covers the rows of the prompt box and the bar, and nothing can be drawn above the box.
349export const menuOf = (menu: Menu | null, rows: number, room: number): MenuBlock | null => {
350 const shown = Math.min(rows, MENU_ROWS)
351
352 if (menu === null || menu.items.length === 0 || shown < 1) {
353 return null
354 }
355
356 const first = clamp(menu.picked - Math.floor(shown / 2), 0, Math.max(0, menu.items.length - shown))
357 const items = menu.items.slice(first, first + shown)
358 const nameWidth = Math.min(MENU_NAME, Math.max(...items.map(({ name }) => name.length)))
359 // Two cells lead the name, two keep it off the description, and one ends the row.
360 const width = Math.min(room, MENU_WIDTH, nameWidth + 5 + Math.max(...items.map(({ description }) => description.length)))
361 const told = Math.max(0, width - nameWidth - 5)
362
363 return {
364 rows: items.map(({ name, description }, index) => ({
365 name: truncate(name, nameWidth),
366 description: truncate(description, told),
367 isPicked: first + index === menu.picked,
368 })),
369 nameWidth,
370 width,
371 }
372}
373
374// The command line where the bar is one row after the engine's mark: what that row has room for.
375export const fitLine = (columns: number, mode: string, command: string | null, echo: Echo | null) =>
376 lineOf(command, echo, columns - PILL_COLUMNS - RIGHT_COLUMNS[sizeOf(columns)] - mode.length - 4)
377
378// Where the footer is the engine's, one line of text: the git part where the screen is wide.
379export const fitRight = (columns: number, modes: readonly string[], cursor: Cursor, usage: Usage, git: Git | null = null): Right => {
380 const part = sizeOf(columns) === 'full' ? fitGit(MAX_BRANCH + 16, git, '').git : null
381
382 return {
383 cursor: labelled(columns, modes, cursor),
384 usage: usageText(usage, sizeOf(columns)),
385 git: part === null ? '' : [`${GIT_ICON} ${part.branch}`, part.added, part.deleted].filter(text => text !== '').join(' '),
386 }
387}
388
389const roundHalfUp = (value: number) => Math.floor(value + 0.5)
390
391// The footer's first row holds the engine's mark and then the mod's tree, which asks for the whole
392// row and `tuning` cells more. The row takes what it lacks from each in proportion to what each
393// asked for, and rounds as a layout does. These are the cells the mark is left.
394const cellsKept = (cells: number, inner: number, tuning: number) => {
395 const asked = cells + SEPARATOR
396
397 return (asked * inner) / (asked + inner + tuning)
398}
399
400// A mark cut to fewer than half its cells would take a third row.
401const MIN_KEPT = 0.6
402// How far from a rounding edge each mark must stand for the cells it is left to be told for sure.
403const CLEAR = 0.005
404const MAX_TUNING = 64
405
406const tellsApart = (inner: number, tuning: number) => {
407 const kept = PERMISSIONS.map(({ cells }) => cellsKept(cells, inner, tuning))
408 const isClear = kept.every(cells => Math.abs(cells - Math.floor(cells) - 0.5) >= CLEAR)
409 const isWhole = kept.every((cells, index) => cells >= ((PERMISSIONS[index]?.cells ?? 0) + SEPARATOR) * MIN_KEPT)
410
411 return isClear && isWhole && new Set(kept.map(roundHalfUp)).size === kept.length
412}
413
414// Two marks a cell apart can be left the same cells. Asking for a little more room moves where each
415// one rounds, so the least is asked that leaves every mark its own; 0 where none does.
416export const tuningOf = (columns: number) => {
417 const inner = columns - 2 * EDGE
418 const tunings = Array.from({ length: MAX_TUNING + 1 }, (_, tuning) => tuning)
419
420 return tunings.find(tuning => tellsApart(inner, tuning)) ?? 0
421}
422
423// What the measuring strip posts, once it is known to be what it should.
424export const readingOf = (data: unknown): Reading | null => {
425 const posted: Record<string, unknown> = typeof data === 'object' && data !== null ? { ...data } : {}
426 const { columns, of } = posted
427
428 return typeof columns === 'number' && typeof of === 'number' ? { columns, of } : null
429}
430
431// The strip is as wide as the row less the mark, so its width names the mode: the one whose mark
432// would be left exactly those cells, when there is one and only one.
433export const permissionOf = (columns: number, reading: Reading | null): Permission | null => {
434 if (reading === null || reading.of !== columns) {
435 return null
436 }
437
438 const inner = columns - 2 * EDGE
439 const tuning = tuningOf(columns)
440 const found = PERMISSIONS.filter(({ cells }) => roundHalfUp(cellsKept(cells, inner, tuning)) === inner - reading.columns)
441
442 return found.length === 1 ? (found[0] ?? null) : null
443}
444
445// The engine's own line names a key to cycle with in every mode but the manual one, and offers
446// `? for shortcuts` in that one alone.
447export const contradicts = (hint: string, mode: string) =>
448 mode === 'default' ? /\(\S+ to cycle\)/.test(hint) : hint.includes('? for shortcuts')
449
450// The mode a session starts in where the command line asks for none: the one the settings name, or
451// the manual one.
452export const startModeOf = (settings: Readonly<Record<string, unknown>>) => {
453 const { permissions } = settings
454 const held: Record<string, unknown> = typeof permissions === 'object' && permissions !== null ? { ...permissions } : {}
455
456 return typeof held.defaultMode === 'string' ? held.defaultMode : 'default'
457}
458
459// Which versions of Claude Code the label read the mode right on, as a session started or a prompt
460// went out, and which it read another on, as kept between sessions.
461export const verdictsOf = (kept: unknown): Record<string, boolean> => {
462 const held: Record<string, unknown> = typeof kept === 'object' && kept !== null ? { ...kept } : {}
463
464 return Object.fromEntries(Object.entries(held).flatMap(([version, isTrue]) => (typeof isTrue === 'boolean' ? [[version, isTrue]] : [])))
465}
466
467// A version the label was checked on is believed until it reads wrong; any other, once it has read right.
468export const isBelieved = (version: string, verdicts: Record<string, boolean>) => verdicts[version] ?? VERIFIED.includes(version)
469
470// Two modes the engine marks alike cannot be told apart, which is no fault of the reading.
471export const sharesMark = (read: string, said: string) => read === 'auto' && said === 'dontAsk'
472
473type Drawn = Facts & {
474 height: number
475 hint: string
476 draft: Draft
477 box: Box
478 reading: Reading | null
479 usage: Usage
480 isNumbered: boolean
481 // The rows the box is to stand at the least; 1 leaves it as the engine sizes it.
482 minRows: number
483 isBelieved: boolean
484 command: string | null
485 echo: Echo | null
486 menu: Menu | null
487 git: Git | null
488}
489
490// The gutter is two cells: a number under 10 keeps one clear of the text, one under 100 fills both,
491// and past that only its last two digits fit.
492export const gutterLabel = (line: number) => (line < 10 ? `${line} ` : String(line % 100).padStart(2, '0'))
493
494// A box too short for its draft shows a window of its rows, and the engine keeps the cursor's row in
495// the middle of it.
496export const windowStart = (row: number, laid: number, cap: number) => clamp(row - Math.floor(cap / 2), 0, Math.max(0, laid - cap))
497
498// The number of each row the box shows. The rows are the ones laid out here: the engine's own count
499// goes by the room the band above the prompt is left, and whatever else stands under the prompt (its
500// list of agents, a notice) takes from that room too. So that count only tells when the draft cannot
501// stand in as many rows as were laid out, and then nothing is numbered.
502const shownRows = (laid: readonly Row[] | null, cap: number, { box }: Drawn) => {
503 const shown = laid === null ? null : Math.min(cap, laid.length)
504
505 return shown === null || (!box.isAligned && box.rows !== null && box.rows < shown) ? null : shown
506}
507
508const lineNumbers = (laid: readonly Row[] | null, cap: number, facts: Drawn) => {
509 const { draft, isNumbered } = facts
510
511 if (!isNumbered || laid === null || shownRows(laid, cap, facts) === null) {
512 return null
513 }
514
515 const from = windowStart(cursorIn(laid, draft.offset).row, laid.length, cap)
516
517 return laid
518 .map((row, index) => (laid[index - 1]?.line === row.line ? null : { label: gutterLabel(row.line), isCurrent: row.line === draft.line }))
519 .slice(from, from + cap)
520}
521
522// The `minLines` option as a count of rows: a whole number from 1, which asks for nothing.
523export const minRowsOf = (value: unknown) => (typeof value === 'number' && value >= 1 ? Math.floor(value) : 1)
524
525// The `expandedLines` option: the rows the box stands at while expanded, never fewer than it stands
526// at otherwise. The box cannot grow past what the engine lets it (half the screen, less the rows
527// around the box), so a screen too short for them gets as many as fit, and the default is more
528// than any screen fits: as tall as the box can stand.
529export const EXPANDED_LINES = 100
530
531export const expandedRowsOf = (value: unknown, minRows: number) =>
532 Math.max(minRows, typeof value === 'number' && value >= 1 ? Math.floor(value) : EXPANDED_LINES)
533
534// The rows to add under a box that shows fewer than it is to stand. They are the footer's own, so
535// the engine's mark would stand in the first of them: none where the bar does not name the mode in
536// the mark's place, and none where another plugin's row is pinned between the box and the footer.
537const padRows = (shown: number | null, cap: number, slot: number, { box, minRows }: Drawn) =>
538 shown === null || slot !== 0 || box.under > 0 ? 0 : Math.max(0, Math.min(minRows, cap) - shown)
539
540export const fitBlock = (facts: Drawn): Block => {
541 const { columns, height, draft, box } = facts
542 const found = permissionOf(columns, facts.reading)
543 // The bar names the mode in the mark's place only where the reading is believed, and nothing the
544 // engine itself says of the mode stands against it.
545 const named = found !== null && facts.isBelieved && !contradicts(facts.hint, found.mode) ? found : null
546 const slot = named === null ? MARK_SLOT : 0
547 const start = slot === 0 ? 0 : GUTTER + slot
548 const cursor = cursorText(draft, sizeOf(columns))
549 // A cell leads the bar, two keep its halves apart and two end it. The git state and the usage take
550 // at most half of what is left, with two cells before the cursor; the left half has the rest.
551 const across = columns - start - cursor.length - 5
552 const right = fitGit(Math.floor(across / 2) - 2, facts.git, usageText(facts.usage, sizeOf(columns)))
553 const spent = gitCells(right.git) + (right.git !== null && right.usage !== '' ? 2 : 0) + right.usage.length
554 const tail = spent === 0 ? 0 : spent + 2
555 const bar = { ...richest(across - tail, { ...facts, permission: named?.label ?? '' }), cursor }
556 const room = columns - GUTTER - EDGE
557 const laid = draft.text === null ? null : layOut(draft.text, room)
558 const cap = rowCap(height, box.under)
559 const shown = shownRows(laid, cap, facts)
560 const pad = padRows(shown, cap, slot, facts)
561
562 return {
563 columns,
564 tuning: tuningOf(columns),
565 bar,
566 slot,
567 // After the badge and a cell, up to two cells short of the bar's right half.
568 line: lineOf(facts.command, facts.echo, across - facts.mode.length - tail - 3),
569 // From the row under the box's top rule down to the one above the bar: the rows the box shows,
570 // its bottom rule, the rows pinned under it and the rows added under those.
571 menu: menuOf(facts.menu, (shown ?? 1) + 1 + box.under + pad, columns - 2 * EDGE),
572 git: right.git,
573 usage: right.usage,
574 gap: Math.max(2, columns - start - columnsOf(bar) - tail - cursor.length - 2),
575 numbers: lineNumbers(laid, cap, facts),
576 pad,
577 under: box.under,
578 read: found?.mode ?? null,
579 }
580}
581hooks/git.ts 78 lines1// The working copy's git state for the footer: the branch, and the lines added and deleted in the
2// tracked files since the last commit, staged or not. Ported from opencode.vim's workspace footer
3// (`workspace-git.ts`): the same commands, run the same way, read the same way.
4
5import type { Git } from '../types'
6
7// What git is run with: nothing asked of a terminal, no lock taken that would get in the way of
8// the person's own git, and output that does not change with their locale or colors.
9export const GIT_ENV = {
10 GIT_TERMINAL_PROMPT: '0',
11 GIT_OPTIONAL_LOCKS: '0',
12 GIT_PAGER: 'cat',
13 PAGER: 'cat',
14 LC_ALL: 'C',
15 LANG: 'C',
16 LANGUAGE: 'C',
17 NO_COLOR: '1',
18}
19
20const git = (...args: string[]) => ['git', '--no-pager', '-c', 'color.ui=false', ...args]
21
22export const BRANCH = git('symbolic-ref', '--quiet', '--short', 'HEAD')
23// A HEAD on no branch is named by its commit.
24export const COMMIT = git('rev-parse', '--verify', '--short=12', 'HEAD')
25export const DIFFSTAT = git('diff', '--numstat', '--no-ext-diff', '--no-textconv', '--find-renames', '--ignore-submodules=all', 'HEAD', '--')
26
27// One line of output, as a name is printed: nothing else in it, and nothing around it.
28export const singleLine = (stdout: string) => {
29 const normalized = stdout.replace(/\r\n/g, '\n')
30 const line = normalized.endsWith('\n') ? normalized.slice(0, -1) : normalized
31
32 return line === '' || line.includes('\n') || line !== line.trim() || /[\u0000-\u001f\u007f]/.test(line) ? null : line
33}
34
35// The branch HEAD is on, or `detached@` and the commit when it is on none; null when neither reads
36// as one: no repository, or no commit to name.
37export const branchOf = (onBranch: string | null, onCommit: string | null) => {
38 if (onBranch !== null) {
39 return singleLine(onBranch)
40 }
41
42 const sha = onCommit === null ? null : singleLine(onCommit)
43
44 return sha !== null && /^[0-9a-f]{4,40}$/i.test(sha) ? `detached@${sha.toLowerCase()}` : null
45}
46
47// The lines added and deleted, summed over `git diff --numstat`; a binary file counts none. Output
48// that does not read as numstat counts nothing, rather than something wrong.
49export const diffstatOf = (stdout: string): Pick<Git, 'additions' | 'deletions'> => {
50 const body = stdout.replace(/\r\n/g, '\n').replace(/\n$/, '')
51 const counted = { additions: 0, deletions: 0 }
52
53 if (body === '') {
54 return counted
55 }
56
57 for (const line of body.split('\n')) {
58 const [added = '', deleted = '', ...path] = line.split('\t')
59
60 if (path.join('\t') === '') {
61 return { additions: 0, deletions: 0 }
62 }
63
64 if (added === '-' && deleted === '-') {
65 continue
66 }
67
68 if (!/^(0|[1-9]\d*)$/.test(added) || !/^(0|[1-9]\d*)$/.test(deleted)) {
69 return { additions: 0, deletions: 0 }
70 }
71
72 counted.additions += Number(added)
73 counted.deletions += Number(deleted)
74 }
75
76 return counted
77}
78hooks/view.tsx 168 lines1import type { Elements } from 'claude-code'
2
3import type { Menu } from '../types'
4import { EDGE, GIT_ICON, GUTTER, columnsOf, gitCells } from './format'
5import type { Block, GitPart, Line, MenuBlock, Segments } from './format'
6import { badgeColor, permissionColor, theme } from './theme'
7
8type Table = Pick<Elements['terminal'], 'Box' | 'Text'>
9
10type Terminal = Pick<Elements['terminal'], 'Box' | 'Text' | 'Client'>
11
12// A box over the screen's cells from `column`, `width` of them, `top` rows under the footer's first.
13type Place = (top: number, column: number, width: number) => { position: 'absolute'; top: number; right: number; width: number }
14
15const cells = (count: number) => ' '.repeat(Math.max(0, count))
16
17// The mode, the permission mode, the model and the effort.
18const segments = ({ Text }: Table, { mode, permission, model, provider, effort }: Segments) => [
19 <Text backgroundColor={badgeColor(mode)} color={theme.badge.text} bold>
20 {` ${mode} `}
21 </Text>,
22 permission !== '' && (
23 <Text color={permissionColor(permission)} bold>
24 {` ${permission}`}
25 </Text>
26 ),
27 permission !== '' && model !== '' && (
28 <Text dimColor>
29 {' ·'}
30 </Text>
31 ),
32 model !== '' && <Text>{` ${model}`}</Text>,
33 provider !== '' && (
34 <Text dimColor>
35 {` ${provider}`}
36 </Text>
37 ),
38 effort !== '' && (
39 <Text dimColor>
40 {' · '}
41 </Text>
42 ),
43 effort !== '' && (
44 <Text color={theme.effort} bold>
45 {effort}
46 </Text>
47 ),
48]
49
50// The command line or its last answer, and after an open line the cell its cursor stands in.
51const said = ({ Text }: Table, { text, hasCursor, isWarning }: Line) => [
52 <Text color={isWarning ? theme.warning : undefined}>{` ${text}`}</Text>,
53 hasCursor && <Text inverse> </Text>,
54]
55
56// The bar alone, in one row after the engine's own permission mark. While the command line has
57// something to say, it says it after the mode's badge, and the completion picked after that.
58export const StatusRow = (table: Table, row: Segments, line: Line | null, menu: Menu | null) => {
59 const { Box, Text } = table
60 const picked = menu?.items[menu.picked]
61
62 if (line === null) {
63 return <Box>{segments(table, row)}</Box>
64 }
65
66 return (
67 <Box>
68 {segments(table, { mode: row.mode, permission: '', model: '', provider: '', effort: '' })}
69 {said(table, line)}
70 {picked !== undefined && <Text dimColor>{` ${picked.name}`}</Text>}
71 </Box>
72 )
73}
74
75// The branch and the counts, before the usage in the bar's right half.
76const gitTexts = ({ Text }: Table, { branch, added, deleted }: GitPart, isLast: boolean) => [
77 <Text color={theme.git.branch}>{`${GIT_ICON} ${branch}`}</Text>,
78 added !== '' && <Text color={theme.git.added}>{` ${added}`}</Text>,
79 deleted !== '' && <Text color={theme.git.deleted}>{` ${deleted}`}</Text>,
80 !isLast && <Text>{' '}</Text>,
81]
82
83// The completions, in rows that end on the bar's, from the screen's edge, so that nothing of the
84// rows under them shows beside them, with the names under the name typed after the colon.
85const menuRows = ({ Box, Text }: Table, menu: MenuBlock, place: (top: number, column: number, width: number) => object, bottom: number) =>
86 menu.rows.map(({ name, description, isPicked }, index) => {
87 const background = isPicked ? theme.menu.picked : theme.menu.background
88
89 return (
90 <Box {...place(bottom - menu.rows.length + 1 + index, 0, menu.width)}>
91 <Text backgroundColor={background} color={isPicked ? theme.menu.pickedText : theme.menu.text} bold={isPicked}>
92 {` ${name.padEnd(menu.nameWidth)} `}
93 </Text>
94 <Text backgroundColor={background} color={isPicked ? theme.menu.pickedText : theme.menu.description}>
95 {description.padEnd(menu.width - menu.nameWidth - 4)}
96 </Text>
97 </Box>
98 )
99 })
100
101export const StatusNote = ({ Text }: Table, text: string) => <Text dimColor>{text}</Text>
102
103// Drawn from the left-hand site, in the footer's first row. The engine keeps its permission mark at
104// the head of that row and lays this tree out after it, so nothing here is placed from the tree's
105// left edge: the tree asks for more than the row, which pins its right edge two cells short of the
106// screen's, and every piece is placed from there. The bar goes over the engine's mark and names the
107// mode in it, with the git state and the usage before the cursor; where the mode is not known for
108// sure the engine's own mark keeps a slot at the head of the bar. A blank row under the bar keeps it
109// off the screen's last row (and covers what of the engine's mark wraps onto it). The line
110// numbers go over the gutter of the prompt box, whose last row stands two rows above the footer's
111// first: the box's rule is between them, and under the rule the rows other plugins pinned.
112//
113// A box that is to stand taller than its draft gets `pad` rows more: its own rule is blanked, the
114// tree takes that many rows ahead of the bar, all but the last blanked too (the engine's mark is
115// in the first, and wraps onto the second), and the last one is drawn as the rule.
116//
117// While the command line is open or has something to say, it stands in the bar after the badge.
118export const StatusBlock = (table: Terminal, { columns, tuning, bar, slot, gap, line, menu, git, usage, numbers, pad, under }: Block) => {
119 const { Box, Text, Client } = table
120 const at: Place = (top, column, width) => ({ position: 'absolute', top, right: columns - EDGE - column - width, width })
121 const start = slot === 0 ? 0 : GUTTER + slot
122 const taken = line === null ? 0 : line.text.length + (line.hasCursor ? 2 : 1)
123 const right = [git !== null && gitTexts(table, git, usage === ''), usage !== '' && <Text dimColor>{usage}</Text>, (git !== null || usage !== '') && <Text>{' '}</Text>]
124
125 return (
126 <Box flexDirection="column" height={pad + 2}>
127 <Box height={0}>
128 <Client key="measure" module="./measure.tsx" props={{ of: columns }} flexGrow={1} height={0} />
129 </Box>
130 <Box width={columns - 2 * EDGE + tuning} height={1} flexShrink={0} />
131 {pad > 0 && (
132 <Box {...at(-1, 0, columns)}>
133 <Text>{cells(columns)}</Text>
134 </Box>
135 )}
136 {Array.from({ length: Math.max(0, pad - 1) }, (_, row) => (
137 <Box {...at(row, 0, columns)}>
138 <Text>{cells(columns)}</Text>
139 </Box>
140 ))}
141 {pad > 0 && (
142 <Box {...at(pad - 1, 0, columns)}>
143 <Text color="promptBorder">{'─'.repeat(columns)}</Text>
144 </Box>
145 )}
146 <Box {...at(pad, start, columns - start)}>
147 {line === null ? segments(table, bar) : segments(table, { mode: bar.mode, permission: '', model: '', provider: '', effort: '' })}
148 {line !== null && said(table, line)}
149 <Text>{cells(line === null ? gap : gap + columnsOf(bar) - bar.mode.length - 2 - taken)}</Text>
150 {right}
151 <Text dimColor>{`${bar.cursor} `}</Text>
152 </Box>
153 <Box {...at(pad + 1, 0, columns)}>
154 <Text>{cells(columns)}</Text>
155 </Box>
156 {numbers?.map(
157 (number, index) =>
158 number !== null && (
159 <Box {...at(index - numbers.length - 1 - under, 0, GUTTER)}>
160 <Text dimColor={!number.isCurrent}>{number.label}</Text>
161 </Box>
162 ),
163 )}
164 {menu !== null && menuRows(table, menu, at, pad - 1)}
165 </Box>
166 )
167}
168hooks/wrap.ts 117 lines1// How the prompt box lays a draft out, so that what is drawn over the box lands beside its text and
2// never on it. The box wraps each line with Bun.wrapAnsi(line, width, { hard: true, trim: false })
3// and shows a row that continues a line without the spaces it would start with.
4
5// One row of the box: its text as shown, where the row starts in the draft, how many spaces ahead
6// of the text are not shown, and the line it shows, counted from 1.
7export type Row = { text: string; start: number; hidden: number; line: number }
8
9// Characters that are one cell each and break nowhere but at a space: printable ASCII, the Latin,
10// Greek and Cyrillic letters, and the common dashes, quotes, arrows, operators and box drawing.
11// Anything else (a tab, an emoji, a CJK character, a combining mark, an odd space) is laid out by
12// rules this file does not carry.
13const KNOWN =
14 /^[\x20-\x7e\u00a1-\u00ac\u00ae-\u02ff\u0370-\u0373\u0375-\u037d\u037f-\u0386\u0388-\u03ff\u0400-\u04ff\u1e00-\u1eff\u2010-\u2027\u2030-\u205e\u20a0-\u20bf\u2190-\u22ff\u2500-\u259f]*$/
15
16export const MAX_LENGTH = 8000
17
18// Whether every character of `text` is one this file has rules for: a cell each, one line.
19export const isPlain = (text: string) => KNOWN.test(text)
20
21const breakWord = (rows: string[], word: string, width: number) => {
22 let visible = rows[rows.length - 1]?.length ?? 0
23
24 for (let index = 0; index < word.length; index += 1) {
25 if (visible + 1 <= width) {
26 rows[rows.length - 1] += word.charAt(index)
27 } else {
28 rows.push(word.charAt(index))
29 visible = 0
30 }
31
32 visible += 1
33
34 if (visible === width && index < word.length - 1) {
35 rows.push('')
36 visible = 0
37 }
38 }
39
40 const last = rows[rows.length - 1] ?? ''
41
42 if (visible === 0 && last.length > 0 && rows.length > 1) {
43 rows.pop()
44 rows[rows.length - 1] += last
45 }
46}
47
48// wrap-ansi's own steps, less what only escape codes need: a space goes on the row it follows unless
49// that row is full, a word that does not fit starts a row, and one longer than a row is cut.
50const wrapLine = (line: string, width: number) => {
51 const rows = ['']
52
53 line.split(' ').forEach((word, index) => {
54 let length = rows[rows.length - 1]?.length ?? 0
55
56 if (index !== 0) {
57 if (length >= width) {
58 rows.push('')
59 length = 0
60 }
61
62 rows[rows.length - 1] += ' '
63 length += 1
64 }
65
66 if (word.length > width) {
67 const startingHere = 1 + Math.floor((word.length - (width - length) - 1) / width)
68
69 if (Math.floor((word.length - 1) / width) < startingHere) {
70 rows.push('')
71 }
72
73 breakWord(rows, word, width)
74
75 return
76 }
77
78 if (length + word.length > width && length > 0 && word.length > 0) {
79 rows.push('')
80 }
81
82 rows[rows.length - 1] += word
83 })
84
85 return rows
86}
87
88// The rows of a draft in a box `width` cells wide, or null for one this file cannot lay out.
89export const layOut = (text: string, width: number): Row[] | null => {
90 if (width < 1 || text.length > MAX_LENGTH || !KNOWN.test(text.replaceAll('\n', ''))) {
91 return null
92 }
93
94 const rows: Row[] = []
95 let start = 0
96
97 for (const [index, line] of text.split('\n').entries()) {
98 wrapLine(line, width).forEach((raw, part) => {
99 const shown = part === 0 ? raw : raw.trimStart()
100 rows.push({ text: shown, start, hidden: raw.length - shown.length, line: index + 1 })
101 start += raw.length
102 })
103 start += 1
104 }
105
106 return rows
107}
108
109// Where the cursor is drawn: the row it is on and the cell in it, counted from the row's text.
110export const cursorIn = (rows: readonly Row[], offset: number) => {
111 const found = rows.findLastIndex(row => row.start <= offset)
112 const index = Math.max(0, found)
113 const row = rows[index]
114
115 return { row: index, column: row === undefined ? 0 : Math.max(0, offset - row.start - row.hidden) }
116}
117hooks/theme.ts 36 lines1// Every color is a key of Claude Code's theme, so the bar follows whatever theme is picked in
2// `/theme`, light or dark. The keys are chosen for their color in the built-in themes as much as for
3// their meaning.
4const BADGES: Record<string, string> = {
5 NORMAL: 'suggestion',
6 INSERT: 'success',
7 VISUAL: 'autoAccept',
8 SHELL: 'bashBorder',
9 COMMAND: 'warning',
10}
11
12// The permission modes, in the colors Claude Code's own mark takes for them.
13const PERMISSIONS: Record<string, string> = {
14 Plan: 'planMode',
15 Auto: 'warning',
16 'Accept edits': 'autoAccept',
17 Bypass: 'error',
18}
19
20export const theme = {
21 badge: { text: 'inverseText', other: 'inactive' },
22 permission: 'text',
23 effort: 'claude',
24 // What the command line says when it will not do as asked.
25 warning: 'error',
26 // The branch and the lines added and deleted.
27 git: { branch: 'inactive', added: 'success', deleted: 'error' },
28 // The completions over the command line, and the picked one.
29 menu: { background: 'userMessageBackground', text: 'text', description: 'inactive', picked: 'selectionBg', pickedText: 'text' },
30}
31
32// `VISUAL LINE` and `VISUAL BLOCK` take the color of `VISUAL`.
33export const badgeColor = (mode: string) => BADGES[mode.split(' ')[0] ?? ''] ?? theme.badge.other
34
35export const permissionColor = (label: string) => PERMISSIONS[label] ?? theme.permission
36hooks/measure.tsx 20 lines1import type { ClientModule } from 'claude-code'
2
3type Props = { of: number }
4
5// Nothing to see: a strip that tells the hooks how wide the engine laid it out, with the terminal's
6// width it was drawn for. Each pair is posted once.
7const Measure: ClientModule<Props, string> = ({ of }, surface) => {
8 const { Text } = surface.elements
9 const seen = `${surface.columns}/${of}`
10
11 if (surface.columns > 0 && surface.state !== seen) {
12 surface.setState(seen)
13 surface.post({ columns: surface.columns, of })
14 }
15
16 return <Text> </Text>
17}
18
19export default Measure
20types/index.d.ts 62 lines1export type Cursor = { line: number; column: number; percent: number }
2
3// The prompt box: where the cursor is, and the draft itself (`offset` is the cursor's place in it)
4// unless it is too long to lay out.
5export type Draft = Cursor & { text: string | null; offset: number }
6
7// How the prompt box stands, as last checked: the rows the engine draws the draft in, the rows
8// other plugins have pinned between it and the footer, and whether the draft as laid out here takes
9// those same rows.
10export type Box = { rows: number | null; under: number; isAligned: boolean }
11
12// How wide the engine laid out the strip that measures the footer's first row, and the width of the
13// terminal it was drawn for.
14export type Reading = { columns: number; of: number }
15
16export type Usage = { tokens: number | null; percent: number | null; usd: number | null }
17
18// The working copy's git state: the branch HEAD is on (`detached@<commit>` on none), and the lines
19// added and deleted in the tracked files since the last commit.
20export type Git = { branch: string; additions: number; deletions: number }
21
22// The vim mod's command line, as that mod declares it: what it said last, a command's answer or why
23// it would not run, and the completions on show.
24export type Echo = { text: string; isWarning: boolean }
25
26// A command the line can complete to, and what the menu says of it.
27export type MenuItem = { name: string; description: string }
28
29// The completions on show: a window of them, the one picked in it, and how many there are in all.
30export type Menu = { items: MenuItem[]; picked: number; total: number }
31
32// These outlive a reload of the mod, so a value whose shape changes takes a new key: `input` was
33// `draft` while it held the cursor alone.
34declare module 'claude-code' {
35 interface PluginState {
36 statusline: {
37 input: Draft
38 // The editor's mode as the badge names it, which the syntax mod reads.
39 mode: string
40 box: Box
41 isBoxPlain: boolean
42 pins: string[]
43 reading: Reading | null
44 isLabelBelieved: boolean
45 model: string
46 effort: string | null
47 isVim: boolean
48 transcript: string | null
49 usage: Usage
50 git: Git | null
51 // Whether `:expand` stands the box at its expanded height.
52 isExpanded: boolean
53 }
54 // Read where the vim mod is installed; only that mod writes them.
55 vim: {
56 command: string | null
57 echo: Echo | null
58 menu: Menu | null
59 }
60 }
61}
62