SLOPSHOPPER

statusline

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…

newbandspinnercommandpromptprocess
v0.1.0MITupdated 2026-10-06thefuga/claude-x/mods/statusline
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · statusline
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /expand ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ▣ client module ./measure.tsx INSERT Claude Opus 5.5 Anthropic 97.4K (49%) · $0.42 Ln 1, Col 1 · 100% 1 auto-accept edits

Draws

Prompt hint
▣ client module ./measure.tsx INSERT Claude Opus 5.5 Anthropic 97.4K (49% 1
README

statusline

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.

Install

claude plugin marketplace add thefuga/claude-x
claude plugin install statusline@claude-x

Works with

  • vim: its command line opens in the bar after the badge, and its completions stand just above it.
  • syntax: reads the editor's mode from here to color a shell command as one.

Neither is needed: without vim the bar has no command line, and nothing here reads syntax.

Getting the look

  • Fullscreen renderer (/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.
  • True color in tmux. Inside tmux Claude Code rounds every color to the 256-color palette. Start it with CLAUDE_CODE_TMUX_TRUECOLOR=1 (and tmux's RGB terminal feature on) to get the exact ones.
  • Theme. Every color is one of Claude Code's own theme colors, so the bar follows whatever /theme picks, light or dark.
  • Vim editor. /config → Editor mode → vim gives the badge its NORMAL and VISUAL.

Options

In /config, or under pluginConfigs."statusline@claude-x".options in the settings (statusline@inline when loaded from a folder):

OptionDefaultWhat it does
lineNumbersonNumbers the draft's lines over the prompt box's two-cell gutter.
minLines1Keeps the prompt box at least this many rows tall.
expandedLines100How tall /expand makes the box; the default is as tall as Claude Code lets it grow.
gitonThe branch and the lines changed, before the usage.

What each segment reads

SegmentSource
Editor modeThe -- 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 modeHow 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.
CursorThe 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.
Usagesession.measure: context tokens, how full the window is, and the session's cost. Claude Code's own labels (focus, memory paused) stand before it.
GitBefore 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.

A taller prompt

/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" } }

How the footer holds together

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:

  • Where. The engine keeps its permission mark at the head of that row and lays the mod's tree out after it, so the tree does not know where its left edge is. It asks for more room than the row has instead, which pins its right edge two cells short of the screen's, and every piece is placed from there: the bar in the row itself, the blank row under it, and the line numbers up over the prompt box's gutter.
  • How many rows. The band above the prompt is told how many rows it may take, which is what the prompt leaves of half the screen. The mod draws nothing there, but reads the number, and the rows of the box follow from it.
  • Which line each row shows. The mod lays the draft out the way the box does (wrap.ts, a port of the wrapping Claude Code uses), so that each number lands on the row its line starts on.
  • A taller box. Claude Code has no setting for the box's height, so minLines and /expand add rows of the footer's own under the draft: the box's rule is blanked and drawn again under them.
  • Rows other mods pin. A status line another mod pins ($.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.

The permission mode

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.

Not there yet

  • Line numbers: the gutter is two cells wide, so past 99 only the last two digits show.
  • Claude Code's own hints in the footer's first row (esc to interrupt, ← for agents) are covered by the bar.
  • A session's first frames show Claude Code's mark at the head of the bar, until it has been measured.
  • With a pane docked beside the transcript the footer is narrower than the terminal, which the footer does not allow for.
  • Session tabs: Claude Code has no call that lists sessions.
Source 8 files
hooks/register.tsx 800 lines
1import { 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}
800
hooks/format.ts 581 lines
1import 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}
581
hooks/git.ts 78 lines
1// 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}
78
hooks/view.tsx 168 lines
1import 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}
168
hooks/wrap.ts 117 lines
1// 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}
117
hooks/theme.ts 36 lines
1// 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
36
hooks/measure.tsx 20 lines
1import 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
20
types/index.d.ts 62 lines
1export 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