A two-row status line in the band above the prompt, in the terminal and in Claude Desktop: model and effort, worktree, branch and counts, lines changed, kube…

Claude Code mods you can install one at a time. guard-rules, secret-box, prompt-gate and skill-pick also run on Codex and OpenCode, and statusline and delegation-board on OpenCode; what works on which host, and why.
Mods are plugins built on the hooks-module API. They run with your permissions and are not sandboxed: read a mod before installing it. Each mod lists what leaves your machine (egress) in its README and in mods.json.
claude plugin marketplace add iuliandita/mods claude plugin install <mod>@iuliandita-mods
Run it in a terminal. It works for Claude Code and loads in Claude Desktop sessions too.
These calls install from the GitHub marketplace. From a clone, scripts/mods install <mod> registers that clone as the marketplace instead (or --source <owner/repo>).
For Codex and OpenCode, see the mod's README and INSTALL.md. OpenCode hook plugins use opencode.json; its terminal UI plugins use the global cli.json instead. Prompt advisers make model calls even in their default dry mode; check the host's costs and privacy limits before installing them.
guard-rules: blocks AI attribution in commits and PRs, direct commits to protected branches, commits under the wrong git identity, and mutating kubectl, helm and terraform calls on protected or unclassified environments.secret-box: keeps secret values out of the chat; a secret placeholder in a Bash command is filled in at execution time from the OS keyring.prompt-gate: before the main model spends a turn on a vague prompt, a small model says so and asks one short question. With a TypeSafe API key in the environment the prompt goes to a third party (Jev); jevPaths restricts where.skill-pick: names the 1 to 5 installed skills that fit each prompt. With a TypeSafe API key the prompt and skill descriptions go to a third party (Jev); jevPaths restricts where.delegation-board: a live pane of the session's subagents and teammates: type, model, effort, state, elapsed time and tokens; in Claude Desktop, a compact band above the prompt.statusline: a two-row status line above the prompt in the terminal and Claude Desktop: model and effort, git state, context against your auto-compact window, 5h and 7d limits with pace, cache, cost and time. In a git repo with a GitHub or GitLab remote it runs gh or glab for PR and issue counts; forge=off turns that off.mods.json has the full catalog: egress, modes, requirements, and the install command per mod. All six mods have recorded Claude terminal checks; the compatibility page lists the tested behavior and versions. Other Claude surfaces remain unverified.
Put your own mods in local/mods/<name>/ (gitignored) and install them with scripts/mods install <name> --local.
MIT
Made by iuliandita. Other projects.
See installation and recovery, contributing, documentation, and private security reporting.
agents provides global prompts and role definitions; skills provides individually installed task guidance. Installing one collection does not install the others.
hooks/register.tsx 644 lines1import type { EngineInterface as Engine, Register, SessionUsage, Timer } from 'claude-code'
2
3import type { Config, Forge, GitInfo, Palette, Pr, Span, Style, ThemeHints, Transcript, View } from './segments'
4import {
5 absorbChunk, buildRows, iconSet, PACE_TICK, PENDING_DEADLINE_MS, pendingFor, cacheFrom, effectiveWindow, sessionTotals, emptyTranscript, GH_QUERY, ICONS, kubeFromTexts, modelName, noctaliaPalette, PALETTES,
6 parseConfig, storedOptions, parseGhCounts, parseGhPr, parseGlabMr, parseKubectl, parseNumstat, parsePorcelain, parseRemote, parseWorktree,
7 pickTheme, providerFor, rule, systemDark, transcriptPath,
8} from './segments'
9
10// Everything below runs off the render path: a refresh every `refreshSeconds` and after each main-loop turn
11// fills the snapshot, and the band draws only what the snapshot already holds.
12const GIT_TTL_MS = 5_000
13const GIT_TIMEOUT_MS = 1_500
14const SLOW_REPO_MS = 600_000
15const REMOTE_TTL_MS = 300_000
16const SSH_TTL_MS = 86_400_000
17const KUBE_TIMEOUT_MS = 2_000
18const KUBE_RETRY_MS = 30_000
19const FORGE_TTL_MS = 300_000
20const PR_TTL_MS = 120_000
21const FORGE_TIMEOUT_MS = 10_000
22const FORGE_STALE_MS = 1_800_000
23const FORGE_ERROR_MS = 600_000
24const UNSUPPORTED_MS = 3_600_000
25const THEME_TTL_MS = 300_000
26const TRANSCRIPT_TIMEOUT_MS = 3_000
27const TRANSCRIPT_BUDGET_MS = 5_000
28const TRANSCRIPT_SEARCH_MS = 60_000
29const FIRST_PASS_WAIT_MS = 1_500
30
31type Target = { provider: 'github' | 'gitlab'; host: string; path: string }
32
33type Snap = {
34 cfg: Config
35 options: Record<string, unknown> // as register received them
36 model: string | null
37 effort: string | null
38 cwd: string | null
39 root: string | null
40 sessionId: string | null
41 transcriptPath: string | null
42 agent: string | null
43 sessionTitle: string | null
44 outputStyle: string | null
45 settingsEffort: string | null
46 windowSettings: Record<string, unknown>
47 windowEnv: string | null
48 usage: SessionUsage | null
49 git: GitInfo | null
50 worktree: string | null
51 diff: { added: number; removed: number } | null
52 gitAt: number
53 slowUntil: number
54 kube: { ctx: string; ns: string } | null
55 kubeSig: string
56 kubeError: { at: number } | null
57 remote: { url: string; at: number } | null
58 sshHosts: Map<string, { host: string; at: number }>
59 target: Target | null
60 pr: Pr | null
61 prAt: number
62 prKey: string
63 forge: Forge | null
64 forgeAt: number
65 forgeDataAt: number
66 forgeError: { at: number } | null
67 unsupported: Map<string, number>
68 // untimed: requests after the last snapshot that ran before this mod loaded, fixed when the first read ends.
69 tx: { path: string; offset: number; s: Transcript; caughtUp: boolean; apiAtSnapshot: number; untimed: number | null } | null
70 txChecked: boolean
71 txSearch: { id: string; at: number; path: string | null } | null
72 loadAt: number
73 firstPass: boolean
74 steps: number
75 hints: ThemeHints
76 noctalia: Palette | null
77 noColor: boolean
78 themeAt: number
79 apiMs: number
80 shown: Set<string> // failures the script shows too: kube, forge
81 sources: Set<string> // data sources whose last refresh failed
82 busy: Set<string>
83 timer: Timer | null
84}
85
86async function nowMs($: Engine): Promise<number> {
87 try {
88 return await $.clock.now()
89 } catch {
90 return Date.now()
91 }
92}
93
94function redraw($: Engine): void {
95 try {
96 $.ui.invalidate('ui.render')
97 } catch {
98 // nothing drawn yet
99 }
100}
101
102// One job of each name at a time; a failure is recorded under that name and loses only its segment.
103async function job(s: Snap, name: string, fn: () => Promise<void>): Promise<void> {
104 if (s.busy.has(name)) return
105 s.busy.add(name)
106 try {
107 await fn()
108 s.sources.delete(name)
109 } catch {
110 s.sources.add(name)
111 } finally {
112 s.busy.delete(name)
113 }
114}
115
116const gitArgv = (cwd: string, ...args: string[]) => ['git', '-c', 'core.fsmonitor=false', '-C', cwd, '--no-optional-locks', ...args]
117const missing = (err: unknown) => /ENOENT|not found|no such file/i.test(String(err))
118
119async function refreshGit($: Engine, s: Snap): Promise<void> {
120 const cwd = s.cwd
121 if (!cwd) return
122 const now = await nowMs($)
123 s.gitAt = now
124 const run = (args: string[]) => $.process.run(gitArgv(cwd, ...args), { cwd, timeoutMs: GIT_TIMEOUT_MS })
125 const skip = now < s.slowUntil
126 let res
127 try {
128 res = await run(['status', '--porcelain=v2', '--branch', skip ? '-uno' : '-uall'])
129 } catch {
130 // A timeout rejects: scan tracked files only, and remember the repo as slow for 10 minutes.
131 s.slowUntil = now + SLOW_REPO_MS
132 res = await run(['status', '--porcelain=v2', '--branch', '-uno'])
133 }
134 if (res.exitCode !== 0) {
135 s.git = null
136 s.worktree = null
137 s.diff = null
138 s.target = null
139 return
140 }
141 s.git = parsePorcelain(res.stdout, now < s.slowUntil)
142 const wt = await run(['rev-parse', '--show-toplevel', '--absolute-git-dir', '--git-common-dir'])
143 s.worktree = wt.exitCode === 0 ? parseWorktree(wt.stdout) : null
144 const diff = await run(['diff', '--numstat', 'HEAD'])
145 s.diff = diff.exitCode === 0 ? parseNumstat(diff.stdout) : null
146 if (!s.remote || now - s.remote.at > REMOTE_TTL_MS) {
147 const remote = await run(['config', '--get', 'remote.origin.url'])
148 s.remote = { url: remote.exitCode === 0 ? remote.stdout.trim() : '', at: now }
149 }
150}
151
152// The real host behind an ssh config alias (`ssh -G` only reads the config; it never connects).
153async function sshHost($: Engine, s: Snap, host: string, now: number): Promise<string> {
154 const hit = s.sshHosts.get(host)
155 if (hit && now - hit.at < SSH_TTL_MS) return hit.host
156 let real = host
157 try {
158 const out = await $.process.run(['ssh', '-G', '--', host], { timeoutMs: 1_000 })
159 const line = out.stdout.split('\n').find((l) => l.startsWith('hostname '))
160 if (out.exitCode === 0 && line) real = line.slice(9).trim().toLowerCase()
161 } catch {
162 // unknown: the name stands for itself
163 }
164 s.sshHosts.set(host, { host: real, at: now })
165 return real
166}
167
168async function forgeTarget($: Engine, s: Snap, now: number): Promise<Target | null> {
169 const parsed = s.remote ? parseRemote(s.remote.url) : null
170 if (!parsed) return null
171 let { host } = parsed
172 let provider = providerFor(host, s.cfg.forgeHosts)
173 if (!provider) {
174 const real = await sshHost($, s, host, now)
175 if (real !== host) {
176 host = real
177 provider = providerFor(real, s.cfg.forgeHosts)
178 }
179 }
180 if (!provider || (provider === 'github' && parsed.path.split('/').length !== 2)) return null
181 return { provider, host, path: parsed.path }
182}
183
184async function cli($: Engine, s: Snap, argv: string[], cwd: string, now: number): Promise<string> {
185 try {
186 const res = await $.process.run(argv, { cwd, timeoutMs: FORGE_TIMEOUT_MS })
187 if (res.exitCode !== 0) throw new Error(`${argv[0]} exited ${res.exitCode}`)
188 return res.stdout
189 } catch (err) {
190 if (missing(err)) s.unsupported.set(argv[0] ?? '', now)
191 throw err
192 }
193}
194
195// Runs gh (GitHub) or glab (GitLab) for this repository's remote only: never on the render path, the counts at
196// most every 5 minutes and the branch's PR every 2 minutes or on a branch change.
197async function refreshForge($: Engine, s: Snap): Promise<void> {
198 const cwd = s.cwd
199 const now = await nowMs($)
200 const target = s.cfg.forge && cwd && s.git ? await forgeTarget($, s, now) : null
201 const key = target ? `${target.host}/${target.path}` : ''
202 if (!target || !cwd || key !== (s.target ? `${s.target.host}/${s.target.path}` : '')) {
203 s.forge = null
204 s.pr = null
205 s.forgeAt = 0
206 s.prAt = 0
207 s.forgeError = null
208 }
209 s.target = target
210 if (!target || !cwd) return
211 const tool = target.provider === 'github' ? 'gh' : 'glab'
212 const since = s.unsupported.get(tool)
213 if (since !== undefined && now - since < UNSUPPORTED_MS) return
214 const enc = encodeURIComponent(target.path)
215 if (now - s.forgeAt > FORGE_TTL_MS) {
216 s.forgeAt = now
217 try {
218 if (target.provider === 'github') {
219 const [owner = '', name = ''] = target.path.split('/')
220 const counts = parseGhCounts(await cli($, s, ['gh', 'api', '--hostname', target.host, 'graphql', '-f', `query=${GH_QUERY}`, '-f', `owner=${owner}`, '-f', `name=${name}`], cwd, now))
221 s.forge = { provider: 'github', ...counts, capped: false, stale: false }
222 } else {
223 const mrs: unknown = JSON.parse(await cli($, s, ['glab', 'api', '--hostname', target.host, `projects/${enc}/merge_requests?state=opened&per_page=100`], cwd, now))
224 const project = JSON.parse(await cli($, s, ['glab', 'api', '--hostname', target.host, `projects/${enc}`], cwd, now)) as { open_issues_count?: unknown }
225 const n = Array.isArray(mrs) ? mrs.length : 0
226 s.forge = { provider: 'gitlab', prs: n, issues: typeof project.open_issues_count === 'number' ? project.open_issues_count : 0, capped: n >= 100, stale: false }
227 }
228 s.forgeDataAt = now
229 s.forgeError = null
230 } catch {
231 s.forgeError = { at: now }
232 }
233 }
234 if (s.forge && s.forgeError && now - s.forgeDataAt > FORGE_STALE_MS) s.forge = { ...s.forge, stale: true }
235 if (!s.forge && s.forgeError && now - s.forgeError.at < FORGE_ERROR_MS && !s.unsupported.has(tool)) s.shown.add('forge')
236 else s.shown.delete('forge')
237
238 const branch = s.git?.branch ?? ''
239 const prKey = `${key}@${branch}`
240 if (!branch || branch === '(detached)') {
241 s.pr = null
242 } else if (prKey !== s.prKey || now - s.prAt > PR_TTL_MS) {
243 s.prKey = prKey
244 s.prAt = now
245 try {
246 s.pr =
247 target.provider === 'github'
248 ? parseGhPr(await cli($, s, ['gh', 'pr', 'list', '--repo', `${target.host}/${target.path}`, `--head=${branch}`, '--state', 'open', '--json', 'number,isDraft,reviewDecision', '--limit', '1'], cwd, now))
249 : parseGlabMr(await cli($, s, ['glab', 'api', '--hostname', target.host, `projects/${enc}/merge_requests?state=opened&source_branch=${encodeURIComponent(branch)}&per_page=1`], cwd, now))
250 } catch {
251 // keep the last answer
252 }
253 }
254}
255
256// The current kube context and namespace, through kubectl, else read from the kubeconfig files. Re-read when a
257// file changes; a failure shows as ✗kube and is retried after 30 seconds.
258async function refreshKube($: Engine, s: Snap): Promise<void> {
259 const now = await nowMs($)
260 const home = (await $.env.get('HOME')) ?? ''
261 const listed = ((await $.env.get('KUBECONFIG')) ?? '').split(':').filter(Boolean)
262 const paths: string[] = []
263 const sig: string[] = []
264 for (const p of listed.length ? listed : [`${home}/.kube/config`]) {
265 try {
266 const st = await $.fs.stat(p)
267 if (st.kind === 'file') {
268 paths.push(p)
269 sig.push(`${p}@${st.mtimeMs}`)
270 }
271 } catch {
272 // not there
273 }
274 }
275 if (!paths.length) {
276 s.kube = null
277 s.shown.delete('kube')
278 return
279 }
280 const key = sig.join(':')
281 if (key === s.kubeSig && (!s.kubeError || now - s.kubeError.at < KUBE_RETRY_MS)) return
282 s.kubeSig = key
283 try {
284 let res
285 try {
286 res = await $.process.run(['kubectl', 'config', 'view', '--minify', '--output', 'json'], { timeoutMs: KUBE_TIMEOUT_MS })
287 } catch (err) {
288 if (!missing(err)) throw err
289 res = null
290 }
291 if (res === null) s.kube = kubeFromTexts(await Promise.all(paths.map((p) => $.fs.read(p))))
292 else if (res.exitCode === 0) s.kube = parseKubectl(res.stdout)
293 else if (res.stderr.includes('current-context')) s.kube = { ctx: '', ns: '' }
294 else throw new Error('kubectl failed')
295 s.kubeError = null
296 s.shown.delete('kube')
297 } catch {
298 s.kubeError = { at: now }
299 s.kube = null
300 s.shown.add('kube')
301 }
302}
303
304// The session's transcript: the path the classic hooks name, else under the config dir in the folder named after
305// the project root, else (a session resumed from or moved to another folder keeps its transcript where it
306// started) the folder that holds a file named after the session id, looked for at most once a minute.
307async function findTranscript($: Engine, s: Snap): Promise<string | null> {
308 if (s.transcriptPath) return s.transcriptPath
309 if (!s.sessionId) return null
310 const dir = (await $.env.get('CLAUDE_CONFIG_DIR')) || `${(await $.env.get('HOME')) ?? ''}/.claude`
311 if (s.root) {
312 const guess = transcriptPath(dir, s.root, s.sessionId)
313 if (await $.fs.exists(guess)) return guess
314 }
315 const now = await nowMs($)
316 const prev = s.txSearch
317 if (prev && prev.id === s.sessionId && (prev.path || now - prev.at < TRANSCRIPT_SEARCH_MS)) return prev.path
318 const search: NonNullable<Snap['txSearch']> = { id: s.sessionId, at: now, path: null }
319 s.txSearch = search
320 // No projects folder yet is no transcript yet.
321 const entries = await $.fs.list(`${dir}/projects`).catch(() => [])
322 for (const entry of entries) {
323 const p = `${dir}/projects/${entry.name}/${s.sessionId}.jsonl`
324 if (entry.kind === 'dir' && (await $.fs.exists(p))) {
325 search.path = p
326 break
327 }
328 }
329 return search.path
330}
331
332// Folds what was appended to the session transcript since the last read: effort, fast mode, the session's
333// title, cache totals and lines changed. Read with `tail -c +N`, at most 4 MiB a time, read after read for up
334// to 5 seconds, so a long transcript of a resumed session is caught up on the first pass.
335async function refreshTranscript($: Engine, s: Snap): Promise<void> {
336 try {
337 const path = await findTranscript($, s)
338 // A new session names its transcript before the first message writes it: nothing to read yet, not a failure.
339 if (!path || !(await $.fs.exists(path))) return
340 const st = await $.fs.stat(path)
341 if (!s.tx || s.tx.path !== path || st.size < s.tx.offset) s.tx = { path, offset: 0, s: emptyTranscript(), caughtUp: false, apiAtSnapshot: 0, untimed: null }
342 const tx = s.tx
343 const start = await nowMs($)
344 while (tx.offset < st.size) {
345 const res = await $.process.run(['tail', '-c', `+${tx.offset + 1}`, path], { timeoutMs: TRANSCRIPT_TIMEOUT_MS })
346 if (res.exitCode !== 0) throw new Error('tail failed')
347 const before = tx.s.snapshots
348 const used = absorbChunk(tx.s, res.stdout)
349 // A snapshot read in the first catch-up predates every request this mod timed; one written while it runs
350 // already holds what was timed until then.
351 if (tx.s.snapshots !== before) tx.apiAtSnapshot = tx.untimed !== null ? s.apiMs : 0
352 // A single line longer than one read can never complete: skip what was read. A half-written last line
353 // waits for the next refresh.
354 if (used === 0 && !res.isStdoutTruncated) break
355 tx.offset += used === 0 ? st.size - tx.offset : used
356 if ((await nowMs($)) - start > TRANSCRIPT_BUDGET_MS) break
357 }
358 // Once the first read reaches the end, the figures stand; later appends are folded refresh by refresh.
359 if (tx.offset >= st.size && tx.untimed === null) tx.untimed = Math.max(0, tx.s.sinceSnapshot - s.steps)
360 tx.caughtUp = tx.untimed !== null
361 } finally {
362 s.txChecked = true
363 }
364}
365
366async function refreshTheme($: Engine, s: Snap): Promise<void> {
367 const now = await nowMs($)
368 if (now - s.themeAt < THEME_TTL_MS) return
369 s.themeAt = now
370 const opt = (p: Promise<string | undefined>) => p.then((v) => v ?? null, () => null)
371 const home = (await opt($.env.get('HOME'))) ?? ''
372 const xdg = (await opt($.env.get('XDG_CONFIG_HOME'))) || `${home}/.config`
373 s.noColor = !!(await opt($.env.get('NO_COLOR')))
374 s.hints = {
375 env: await opt($.env.get('STATUSLINE_THEME')),
376 terminalEnv: await opt($.env.get('STATUSLINE_TERMINAL_THEME')),
377 colorFgBg: await opt($.env.get('COLORFGBG')),
378 desktopMode: null,
379 systemDark: null,
380 }
381 try {
382 s.noctalia = noctaliaPalette(await $.fs.read(`${xdg}/ghostty/themes/noctalia`))
383 } catch {
384 s.noctalia = null
385 }
386 try {
387 const mode = (JSON.parse(await $.fs.read(`${xdg}/Claude/config.json`)) as { userThemeMode?: unknown }).userThemeMode
388 s.hints.desktopMode = typeof mode === 'string' ? mode : null
389 } catch {
390 s.hints.desktopMode = null
391 }
392 if (s.hints.desktopMode !== 'system') return
393 const probes = [
394 ['gsettings', 'get', 'org.gnome.desktop.interface', 'color-scheme'],
395 ['gdbus', 'call', '--session', '--dest', 'org.freedesktop.portal.Desktop', '--object-path', '/org/freedesktop/portal/desktop', '--method', 'org.freedesktop.portal.Settings.Read', 'org.freedesktop.appearance', 'color-scheme'],
396 ]
397 for (const argv of probes) {
398 try {
399 const res = await $.process.run(argv, { timeoutMs: 400 })
400 const dark = res.exitCode === 0 ? systemDark(res.stdout) : null
401 if (dark !== null) {
402 s.hints.systemDark = dark
403 return
404 }
405 } catch {
406 // next probe
407 }
408 }
409}
410
411// The engine's own answers and the local probes (git and its forge, kube, the transcript, the theme) start on
412// the same pass, each as soon as what it needs is known, so the first full band is ready about a second after
413// load.
414async function refresh($: Engine, s: Snap): Promise<void> {
415 const now = await nowMs($)
416 if (!s.loadAt) s.loadAt = now
417 const session = Promise.all([
418 job(s, 'model', async () => void (s.model = await $.session.model())),
419 job(s, 'usage', async () => void (s.usage = await $.session.usage())),
420 job(s, 'settings', async () => {
421 const set = await $.settings.read()
422 s.outputStyle = typeof set.outputStyle === 'string' ? set.outputStyle : null
423 s.settingsEffort = typeof set.effortLevel === 'string' ? set.effortLevel : null
424 // The merged settings carry both window keys; only these two are kept.
425 s.windowSettings = { autoCompactWindow: set.autoCompactWindow, modelSettings: set.modelSettings }
426 // The options as the user's settings.json stores them, over the ones register was given: Claude Desktop has
427 // been seen drawing the band without the user's options (icons: nerd drawn as unicode). Only the user source:
428 // the merge also carries a repository's own .claude/settings.json, which must not change a user's options.
429 const stored = storedOptions((await $.settings.read({ source: 'user' })).pluginConfigs, $.plugin.name)
430 if (stored) s.cfg = parseConfig({ ...s.options, ...stored })
431 if (!s.agent && typeof set.agent === 'string') s.agent = set.agent
432 }).then(() =>
433 // The style in force is the /config row: a style chosen in the session lives there, not in a settings file.
434 job(s, 'config', async () => {
435 const row = (await $.config.list()).find((r) => r.key === 'outputStyle')
436 if (row && typeof row.value === 'string') s.outputStyle = row.value
437 }),
438 ),
439 job(s, 'env', async () => void (s.windowEnv = (await $.env.get('CLAUDE_CODE_AUTO_COMPACT_WINDOW')) ?? null)),
440 ])
441 const local = Promise.all([
442 job(s, 'theme', () => refreshTheme($, s)),
443 job(s, 'kube', () => refreshKube($, s)),
444 Promise.all([
445 job(s, 'root', async () => void (s.root = await $.session.root())),
446 job(s, 'id', async () => void (s.sessionId = await $.session.id())),
447 ]).then(() => job(s, 'transcript', () => refreshTranscript($, s))),
448 job(s, 'cwd', async () => void (s.cwd = await $.session.cwd())).then(() =>
449 now - s.gitAt > GIT_TTL_MS ? job(s, 'git', () => refreshGit($, s)).then(() => job(s, 'forge', () => refreshForge($, s))) : undefined,
450 ),
451 ])
452 await session
453 if (s.firstPass) redraw($)
454 // The first band waits for this pass, at most 1.5 seconds; a slower probe fills in when it answers.
455 if (!s.firstPass) await Promise.race([local, $.clock.sleep(FIRST_PASS_WAIT_MS).catch(() => {})])
456 s.firstPass = true
457 redraw($)
458 await local
459 redraw($)
460}
461
462function kick($: Engine, s: Snap): void {
463 if (!s.timer) s.timer = $.clock.every(s.cfg.refreshMs, () => void refresh($, s).catch(() => {}))
464 void refresh($, s).catch(() => {})
465}
466
467function view(s: Snap, now: number): View {
468 const u = s.usage
469 const t = s.tx?.caughtUp ? s.tx.s : null
470 const partial = s.tx?.s ?? null
471 const totals = t && s.tx ? sessionTotals(t, s.apiMs, s.tx.apiAtSnapshot, s.tx.untimed ?? 0) : null
472 // Until the transcript has been read through, the API total is unknown: the mod's own timing alone would
473 // understate a resumed session.
474 const transcript = !s.txChecked || (s.tx && !s.tx.caughtUp) ? 'reading' : s.tx ? 'done' : 'none'
475 const sessionLines = totals && totals.added + totals.removed > 0 ? { added: totals.added, removed: totals.removed, session: true } : null
476 // The window lookup needs the model id and the settings; without either the ctx denominator may be wrong.
477 const ctxwin = s.cfg.ctxWindow === 'auto' && (s.sources.has('settings') || s.sources.has('env') || !s.model) ? ['ctxwin'] : []
478 const v: View = {
479 now,
480 model: s.model ? modelName(s.model) : null,
481 effort: s.effort ?? partial?.effort ?? s.settingsEffort,
482 fast: partial?.fast ?? false,
483 cwd: s.cwd,
484 worktree: s.worktree,
485 git: s.git,
486 kube: s.kube,
487 pr: s.pr,
488 agent: s.agent,
489 sessionName: partial?.title ?? s.sessionTitle,
490 outputStyle: s.outputStyle,
491 vim: null,
492 forge: s.forge,
493 context: u?.context
494 ? { ...u.context, limit: effectiveWindow({ mode: s.cfg.ctxWindow, engineWindow: u.context.window, model: s.model, settings: s.windowSettings, env: s.windowEnv }) }
495 : null,
496 limits: u?.rateLimits ?? [],
497 costUsd: u?.cost?.usd ?? null,
498 durationMs: u ? now - u.startedAt : null,
499 apiMs: totals ? totals.apiMs : transcript === 'reading' ? null : s.apiMs,
500 cache: t ? cacheFrom(t, now) : null,
501 lines: sessionLines ?? (s.diff ? { ...s.diff, session: false } : null),
502 failed: [...s.shown, ...s.sources, ...ctxwin],
503 }
504 const responded = v.context?.tokens !== undefined || !!v.costUsd
505 const forgeExpected = s.cfg.forge && !!s.target && !s.forge && !s.forgeError
506 v.pending = pendingFor({ enabled: s.cfg.placeholders, waiting: now - s.loadAt < PENDING_DEADLINE_MS, responded, transcript, forgeExpected }, v)
507 return v
508}
509
510function styleFor(s: Snap, surface: string): Style {
511 const available = (t: string) => t === 'dark' || t === 'light' || t === 'neutral' || (t === 'noctalia' && s.noctalia !== null)
512 const theme = s.cfg.theme !== 'auto' && available(s.cfg.theme) ? s.cfg.theme : pickTheme(surface, s.hints, available)
513 const P = theme === 'noctalia' ? (s.noctalia ?? PALETTES.neutral) : PALETTES[theme]
514 return { P, glyph: ICONS[iconSet(s.cfg.icons)], tick: PACE_TICK, noColor: s.noColor, desktop: surface === 'desktop' }
515}
516
517// Classic hook inputs carry what the engine's own calls do not: the transcript's path, the main thread's agent
518// in an --agent session, the session's title and the turn's effort.
519function noteClassic(s: Snap, e: { transcript_path?: string; agent_id?: string; agent_type?: string; session_title?: string; effort?: { level?: string } }): void {
520 if (e.agent_id) return
521 if (typeof e.transcript_path === 'string' && e.transcript_path) s.transcriptPath = e.transcript_path
522 if (typeof e.agent_type === 'string' && e.agent_type) s.agent = e.agent_type
523 if (typeof e.session_title === 'string' && e.session_title) s.sessionTitle = e.session_title
524 if (typeof e.effort?.level === 'string' && e.effort.level) s.effort = e.effort.level
525}
526
527export const register: Register = (on, options) => {
528 const cfg = parseConfig(options)
529 // off: no hook, no timer, no process.
530 if (cfg.mode === 'off') return
531
532 const s: Snap = {
533 cfg, options, model: null, effort: null, cwd: null, root: null, sessionId: null, transcriptPath: null, agent: null, sessionTitle: null,
534 outputStyle: null, settingsEffort: null, windowSettings: {}, windowEnv: null, usage: null, git: null, worktree: null, diff: null, gitAt: 0, slowUntil: 0,
535 kube: null, kubeSig: '', kubeError: null, remote: null, sshHosts: new Map(), target: null, pr: null, prAt: 0, prKey: '',
536 forge: null, forgeAt: 0, forgeDataAt: 0, forgeError: null, unsupported: new Map(), tx: null, txChecked: false, txSearch: null,
537 loadAt: 0, firstPass: false, steps: 0,
538 hints: { env: null, terminalEnv: null, colorFgBg: null, desktopMode: null, systemDark: null }, noctalia: null, noColor: false,
539 themeAt: 0, apiMs: 0, shown: new Set(), sources: new Set(), busy: new Set(), timer: null,
540 }
541
542 on('session.start', async ($, e, next) => {
543 const result = await next(e)
544 try {
545 kick($, s)
546 } catch {
547 // fail soft: the band draws once a later event refreshes it
548 }
549 return result
550 })
551
552 on('classic.SessionStart', async ($, e, next) => {
553 try {
554 // /clear and /resume switch to another transcript, and the API time this mod timed belonged to the one
555 // before. The start of the session names the transcript already being read: keep what was read.
556 const path = typeof e.transcript_path === 'string' ? e.transcript_path : null
557 if (!e.agent_id && path !== (s.tx?.path ?? s.transcriptPath)) {
558 s.tx = null
559 s.txChecked = false
560 s.transcriptPath = null
561 s.sessionTitle = null
562 s.apiMs = 0
563 s.steps = 0
564 }
565 noteClassic(s, e)
566 } catch {
567 // fail soft
568 }
569 return next(e)
570 }).catch(($, e, next) => next(e))
571
572 on('classic.UserPromptSubmit', async ($, e, next) => {
573 try {
574 noteClassic(s, e)
575 } catch {
576 // fail soft
577 }
578 return next(e)
579 }).catch(($, e, next) => next(e))
580
581 on('turn.complete', async ($, e, next) => {
582 const result = await next(e)
583 if (!e.agentId) void refresh($, s).catch(() => {})
584 return result
585 })
586
587 // Main-loop requests only: the effort each ran at and how long the API took. The stream beneath is passed
588 // through untouched and read once; the bookkeeping around it never throws.
589 on('turn.step', async function* ($, e, next) {
590 if (e.agentId) return yield* next(e)
591 let t0 = 0
592 try {
593 if (typeof e.effort === 'string') s.effort = e.effort
594 t0 = await nowMs($)
595 } catch {
596 t0 = 0
597 }
598 const result = yield* next(e)
599 try {
600 if (t0) {
601 s.apiMs += Math.max(0, (await nowMs($)) - t0)
602 s.steps++
603 }
604 } catch {
605 // fail soft
606 }
607 return result
608 })
609
610 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
611 if (e.props.hasSurvey) return next(e)
612 if (!s.usage && !s.model) {
613 // A refresh already under way (from session.start) fills the snapshot; only a missed start kicks here.
614 if (!s.timer) kick($, s)
615 return next(e)
616 }
617 const now = await nowMs($)
618 // The band appears once, whole: it waits for the first pass of every source (at most 1.5 seconds), so row
619 // one does not grow segment by segment.
620 if (s.cfg.placeholders && !s.firstPass) return next(e)
621 // The script lays a row out in the terminal's columns less 4; the band's own width is already net of the
622 // engine's marks, so it is the whole budget here.
623 const cols = (e.props.bodyColumns > 0 ? e.props.bodyColumns : 100) + 4
624 const st = styleFor(s, e.surface)
625 const rows = buildRows(st, view(s, now), cols, s.cfg.rows)
626 if (!rows.length) return next(e)
627 // Other mods' bands stack above this one; the status line sits next to the prompt, under its rule on the
628 // terminal. Desktop draws its own edge above the band, so no rule there.
629 const below = await next(e)
630 const { Box, Text } = $.ui.resolve(e)
631 const text = (sp: Span, i: number) => (
632 <Text key={`s${i}`} color={sp.fg} backgroundColor={sp.bg} dimColor={sp.faint} bold={sp.bold} italic={sp.italic}>{sp.t}</Text>
633 )
634 return (
635 <Box flexDirection="column">
636 {below ?? null}
637 {[...(e.surface === 'terminal' ? [rule(st, cols - 4)] : []), ...rows].map((row, r) => (
638 <Box key={`row-${r}`} flexDirection="row">{row.map(text)}</Box>
639 ))}
640 </Box>
641 )
642 }).catch(($, e, next) => next(e))
643}
644hooks/segments.ts 921 lines1// Pure formatting for the status line, ported from the reference statusline script: no `$`, so every piece here
2// is unit-tested in segments.test.ts. A segment is a list of forms, richest first; `place` keeps the most
3// important that fit the width, then widens them.
4
5export type Rgb = readonly [number, number, number]
6export type Paint = Rgb | 'faint' | null
7export type Span = { t: string; fg?: string; bg?: string; bold?: boolean; italic?: boolean; faint?: boolean }
8export type Part = Span[]
9export type Seg = { name: string; prio: number; forms: Part[]; keep?: boolean; optional?: boolean }
10
11export type ThemeName = 'dark' | 'light' | 'neutral' | 'noctalia'
12export type Palette = { MINT: Rgb; PINK: Rgb; YELLOW: Rgb; LAV: Rgb; DIM: Paint; TRACK: Paint; INK: Rgb; BAR: readonly [Rgb, Rgb, Rgb] }
13
14export const PALETTES: Record<'dark' | 'light' | 'neutral', Palette> = {
15 dark: { MINT: [155, 254, 206], PINK: [253, 70, 99], YELLOW: [255, 245, 155], LAV: [169, 174, 254], DIM: [138, 140, 188], TRACK: [100, 102, 172], INK: [7, 7, 34], BAR: [[155, 254, 206], [255, 245, 155], [253, 70, 99]] },
16 light: { MINT: [0, 122, 82], PINK: [196, 24, 66], YELLOW: [138, 92, 0], LAV: [77, 66, 214], DIM: [98, 102, 140], TRACK: [140, 147, 194], INK: [255, 255, 255], BAR: [[0, 160, 110], [184, 110, 0], [214, 40, 80]] },
17 neutral: { MINT: [17, 151, 71], PINK: [245, 66, 88], YELLOW: [179, 120, 5], LAV: [105, 119, 247], DIM: 'faint', TRACK: 'faint', INK: [7, 7, 34], BAR: [[13, 160, 72], [189, 126, 5], [246, 76, 96]] },
18}
19
20// Symbols, not words: the worktree, branch, PR and issue counts and the cache each get a glyph in both sets.
21export type Icons = { branch: string; folder: string; tree: string; kube: string; pr: string; issue: string; cache: string }
22export type IconSet = 'nerd' | 'unicode' | 'ascii'
23export const ICONS: Record<IconSet, Icons> = {
24 nerd: { branch: ' ', folder: ' ', tree: ' ', kube: '\u{f10fe} ', pr: ' ', issue: ' ', cache: ' ' },
25 // Standard Unicode symbols only, no private-use code points: they render without a patched font, in a terminal
26 // and in Claude Desktop's web view alike.
27 // Monochrome text symbols, none a colour emoji: U+1F5BF BLACK FOLDER (flat, like the Nerd folder) has no emoji
28 // property; ☸ has one with text presentation by default, and U+FE0E asks for text explicitly.
29 unicode: { branch: '⎇ ', folder: '\u{1f5bf} ', tree: '⌥ ', kube: '☸︎ ', pr: '⑂ ', issue: '⊙ ', cache: '◔ ' },
30 ascii: { branch: '', folder: '', tree: 'wt:', kube: 'k8s ', pr: 'pr ', issue: 'iss ', cache: 'cache ' },
31}
32
33// The `icons` option, the same on every surface: auto is unicode, safe where no Nerd Font is installed; nerd is
34// Nerd Font glyphs in the terminal and in Desktop alike.
35export const iconSet = (option: Config['icons']): IconSet => (option === 'auto' ? 'unicode' : option)
36
37// The pace tick in the limit bars, on both surfaces: a dot, which a taller Desktop line cannot turn into a cursor.
38export const PACE_TICK = '●'
39
40// How a row is painted: the palette, the icon set, the pace tick and NO_COLOR, which keeps bold, italic and faint
41// only. `desktop`: the row is laid out in a web view, where the forge counts are not held back to 120 columns.
42export type Style = { P: Palette; glyph: Icons; tick: string; noColor: boolean; desktop?: boolean }
43
44export type Config = {
45 mode: 'off' | 'on'
46 rows: 1 | 2
47 forge: boolean
48 forgeHosts: Record<string, 'github' | 'gitlab'>
49 icons: 'auto' | 'nerd' | 'unicode' | 'ascii'
50 theme: 'auto' | ThemeName
51 refreshMs: number
52 ctxWindow: 'auto' | 'full'
53 placeholders: boolean
54}
55
56export function parseHosts(text: unknown): Record<string, 'github' | 'gitlab'> {
57 const out: Record<string, 'github' | 'gitlab'> = {}
58 for (const pair of String(text ?? '').split(',')) {
59 const [k, v] = pair.split('=', 2).map((s) => s.trim().toLowerCase())
60 if (k && (v === 'github' || v === 'gitlab')) out[k] = v
61 }
62 return out
63}
64
65export function parseConfig(o: Record<string, unknown>): Config {
66 const secs = typeof o.refreshSeconds === 'number' ? o.refreshSeconds : Number(o.refreshSeconds)
67 const theme = String(o.theme ?? 'auto')
68 return {
69 mode: o.mode === 'off' ? 'off' : 'on',
70 rows: String(o.rows) === '1' ? 1 : 2,
71 forge: o.forge !== 'off',
72 forgeHosts: parseHosts(o.forgeHosts),
73 icons: o.icons === 'nerd' || o.icons === 'unicode' || o.icons === 'ascii' ? o.icons : 'auto',
74 theme: theme === 'dark' || theme === 'light' || theme === 'neutral' || theme === 'noctalia' ? theme : 'auto',
75 refreshMs: Number.isFinite(secs) && secs > 0 ? Math.min(3600, Math.max(5, Math.round(secs))) * 1000 : 30_000,
76 ctxWindow: o.ctxWindow === 'full' ? 'full' : 'auto',
77 placeholders: o.placeholders !== 'off',
78 }
79}
80
81// This plugin's options as settings.json stores them (`pluginConfigs["<name>@<marketplace>"].options`), when
82// exactly one entry is this plugin's; with none or several there is nothing certain to fall back on.
83export function storedOptions(pluginConfigs: unknown, name: string): Record<string, unknown> | null {
84 if (!pluginConfigs || typeof pluginConfigs !== 'object') return null
85 const mine = Object.entries(pluginConfigs as Record<string, unknown>).filter(([k]) => k === name || k.startsWith(`${name}@`))
86 const opts = mine.length === 1 ? (mine[0]?.[1] as { options?: unknown } | null)?.options : null
87 return opts && typeof opts === 'object' && !Array.isArray(opts) ? (opts as Record<string, unknown>) : null
88}
89
90// ---- numbers and text as Python prints them ----
91
92// Python's round() and format(".0f"): halves go to the even neighbour.
93export function roundEven(x: number): number {
94 const r = Math.round(x)
95 return Math.abs(x % 1) === 0.5 && r % 2 !== 0 ? r - 1 : r
96}
97const fixed0 = (x: number): string => String(roundEven(x))
98const signed0 = (x: number): string => `${x < 0 || Object.is(x, -0) ? '-' : '+'}${roundEven(Math.abs(x))}`
99
100const CONTROL = /[\u0000-\u001f\u007f-\u009f---]/g
101export const clean = (v: unknown): string => String(v ?? '').replace(CONTROL, '')
102export const clip = (s: string, n: number): string => ([...s].length <= n ? s : [...s].slice(0, n - 1).join('') + '…')
103
104export function humanize(seconds: number): string {
105 const s = Math.floor(Math.max(0, seconds))
106 const d = Math.floor(s / 86400)
107 const h = Math.floor((s % 86400) / 3600)
108 const m = Math.floor((s % 3600) / 60)
109 if (d) return `${d}d${h}h`
110 return h ? `${h}h${String(m).padStart(2, '0')}m` : `${m}m`
111}
112
113export function fmtTokens(n: number): string {
114 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
115 if (n >= 1000) return `${roundEven(n / 1000)}k`
116 return String(Math.trunc(n))
117}
118
119function cellWidth(cp: number): number {
120 if ((cp >= 0x300 && cp <= 0x36f) || (cp >= 0xfe00 && cp <= 0xfe0f)) return 0
121 const wide =
122 (cp >= 0x1100 && cp <= 0x115f) || (cp >= 0x2e80 && cp <= 0xa4cf) || (cp >= 0xac00 && cp <= 0xd7a3) ||
123 (cp >= 0xf900 && cp <= 0xfaff) || (cp >= 0xfe30 && cp <= 0xfe4f) || (cp >= 0xff00 && cp <= 0xff60) ||
124 (cp >= 0xffe0 && cp <= 0xffe6) || (cp >= 0x1f300 && cp <= 0x1f64f) || (cp >= 0x1f900 && cp <= 0x1f9ff) ||
125 (cp >= 0x20000 && cp <= 0x3fffd) || cp === 0x26a1
126 return wide ? 2 : 1
127}
128export const textWidth = (s: string): number => [...s].reduce((w, ch) => w + cellWidth(ch.codePointAt(0) ?? 0), 0)
129export const partWidth = (p: Part): number => p.reduce((w, s) => w + textWidth(s.t), 0)
130export const plain = (p: Part): string => p.map((s) => s.t).join('')
131
132// ---- colour ----
133
134const hex = (c: Rgb): string => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`
135const lerp = (a: Rgb, b: Rgb, t: number): Rgb => [0, 1, 2].map((i) => roundEven((a[i] ?? 0) + ((b[i] ?? 0) - (a[i] ?? 0)) * t)) as unknown as Rgb
136
137// Green to yellow to red as t goes 0 to 1, through the three anchor colours.
138export function ramp(t: number, anchors: readonly [Rgb, Rgb, Rgb]): Rgb {
139 const x = Math.max(0, Math.min(1, t))
140 return x < 0.5 ? lerp(anchors[0], anchors[1], x * 2) : lerp(anchors[1], anchors[2], (x - 0.5) * 2)
141}
142
143// Green up to 50%, then through yellow (75%) to red (100%): a healthy value must not look like a warning.
144export const levelColor = (P: Palette, pct: number): Rgb => ramp(Math.max(0, (pct - 50) / 50), [P.MINT, P.YELLOW, P.PINK])
145
146export function c(st: Style, paint: Paint, text: string, bold = false, italic = false): Part {
147 const s: Span = { t: text }
148 if (bold) s.bold = true
149 if (italic) s.italic = true
150 if (paint === 'faint') s.faint = true
151 else if (paint && !st.noColor) s.fg = hex(paint)
152 return [s]
153}
154
155export function badge(st: Style, bg: Rgb, text: string): Part {
156 if (st.noColor) return c(st, null, `[${text}]`, true)
157 return [{ t: ` ${text} `, bg: hex(bg), fg: hex(st.P.INK), bold: true }]
158}
159
160const cat = (...parts: Part[]): Part => parts.flat()
161// " ".join(p for p in parts if p)
162export function join(...parts: (Part | null | undefined)[]): Part {
163 const out: Span[] = []
164 for (const p of parts) {
165 if (!p || p.length === 0) continue
166 if (out.length) out.push({ t: ' ' })
167 out.push(...p)
168 }
169 return out
170}
171
172// ---- layout ----
173
174const sep = (st: Style): Part => c(st, st.P.TRACK, ' │ ')
175
176function seg(name: string, prio: number, forms: Part[], extra: Partial<Seg> = {}): Seg {
177 const keys = forms.map((f) => JSON.stringify(f))
178 return { name, prio, forms: forms.filter((f, i) => f.length && keys.indexOf(keys[i] ?? '') === i), ...extra }
179}
180const none = (name: string): Seg => ({ name, prio: 0, forms: [] })
181
182// Every core segment starts at its leanest form and the least important are dropped until that fits. The width
183// left over goes to richer forms one step at a time, most important first, and last to optional garnish.
184export function place(segs: Seg[], cols: number, sepWidth = 3): Part[] {
185 const live = segs.filter((s) => s.forms.length)
186 const at = new Map(live.map((s) => [s, s.forms.length - 1]))
187 const on = new Set(live.filter((s) => !s.optional))
188 const form = (s: Seg): Part => s.forms[at.get(s) ?? 0] ?? []
189 const width = (): number => {
190 const shown = live.filter((s) => on.has(s))
191 return shown.reduce((w, s) => w + partWidth(form(s)), 0) + Math.max(0, shown.length - 1) * sepWidth
192 }
193 while (width() > cols) {
194 const droppable = live.filter((s) => on.has(s) && !s.keep)
195 if (!droppable.length) break
196 on.delete(droppable.reduce((a, b) => (b.prio < a.prio ? b : a)))
197 }
198 const core = live.filter((s) => on.has(s)).sort((a, b) => b.prio - a.prio)
199 for (let advanced = true; advanced; ) {
200 advanced = false
201 for (const s of core) {
202 const i = at.get(s) ?? 0
203 if (i === 0) continue
204 at.set(s, i - 1)
205 if (width() <= cols) advanced = true
206 else at.set(s, i)
207 }
208 }
209 for (const s of live.filter((x) => x.optional).sort((a, b) => b.prio - a.prio)) {
210 on.add(s)
211 let fits = false
212 for (let i = 0; i < s.forms.length && !fits; i++) {
213 at.set(s, i)
214 fits = width() <= cols
215 }
216 if (!fits) on.delete(s)
217 }
218 return live.filter((s) => on.has(s)).map(form)
219}
220
221// Hard guarantee: the visible text is cut to cols, the last cell an ellipsis.
222export function truncate(p: Part, cols: number): Part {
223 if (partWidth(p) <= cols) return p
224 const out: Span[] = []
225 let used = 0
226 for (const s of p) {
227 let t = ''
228 for (const ch of s.t) {
229 const w = cellWidth(ch.codePointAt(0) ?? 0)
230 if (used + w > cols - 1) {
231 out.push({ ...s, t: t + '…' })
232 return out
233 }
234 t += ch
235 used += w
236 }
237 out.push(s)
238 }
239 return out
240}
241
242export function fit(st: Style, segs: Seg[], cols: number): Part {
243 const row: Span[] = []
244 place(segs, cols).forEach((f, i) => {
245 if (i) row.push(...sep(st))
246 row.push(...f)
247 })
248 return truncate(row, cols)
249}
250
251// ---- the data a row is drawn from ----
252
253export type GitInfo = { branch: string; oid: string; ahead: number; behind: number; staged: number; modified: number; untracked: number | null; conflicts: number }
254export type Pr = { number: number; kind: 'pr' | 'mr'; state: 'approved' | 'changes_requested' | 'draft' | 'pending' | '' }
255export type Forge = { provider: 'github' | 'gitlab'; prs: number; issues: number; capped: boolean; stale: boolean }
256export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
257export type PromptCache = { observed: boolean; hit: number | null; requests: number; misses: number; warm: boolean; expiresAt: number | null }
258
259export type View = {
260 now: number // ms
261 model: string | null // display name
262 effort: string | null
263 fast: boolean
264 cwd: string | null
265 worktree: string | null
266 git: GitInfo | null
267 kube: { ctx: string; ns: string } | null
268 pr: Pr | null
269 agent: string | null
270 sessionName: string | null
271 outputStyle: string | null
272 vim: string | null
273 forge: Forge | null
274 // `limit`: the auto-compact window when it is smaller than the model's `window` (see effectiveWindow).
275 context: { tokens?: number; window: number; percent?: number; limit?: number } | null
276 limits: Limit[]
277 costUsd: number | null
278 durationMs: number | null
279 // null: not known (a resumed session whose transcript holds no saved total yet); never drawn as 0.
280 apiMs: number | null
281 cache: PromptCache | null
282 lines: { added: number; removed: number; session: boolean } | null
283 failed: string[]
284 // Segments whose data has not arrived yet; each draws a dim placeholder of its usual width (see pendingFor).
285 pending?: string[]
286}
287
288// ---- segments, as the script builds them ----
289
290const EFFORT: Record<string, number> = { low: 1, medium: 2, high: 3, xhigh: 4, max: 5 }
291const PROTECTED = /^(main|master|develop|release[/-].*)$/
292const PROD = /(?<![a-z])prod(?:uction)?(?![a-z])/i
293const REVIEW: Record<string, string> = { approved: '✓', changes_requested: '✗', draft: '◌', pending: '●' }
294const WINDOWS: [string, string, number, number][] = [['5h', 'five_hour', 5 * 3600, 8], ['7d', 'seven_day', 7 * 86400, 6]]
295export const FORGE_MIN_COLS = 120
296
297export function bar(st: Style, pct: number, width: number, mark: number | null = null, markRgb: Rgb | null = null): Part {
298 const filled = Math.max(pct >= 1 ? 1 : 0, Math.min(width, roundEven((pct / 100) * width)))
299 const cells: Part = []
300 for (let i = 0; i < width; i++) {
301 if (i === mark) cells.push(...c(st, markRgb, st.tick, markRgb === null))
302 else if (i < filled) cells.push(...c(st, ramp(i / Math.max(1, width - 1), st.P.BAR), '━'))
303 else cells.push(...c(st, st.P.TRACK, '─'))
304 }
305 return cells
306}
307
308export function modelSeg(st: Style, model: string | null, effort: string | null): Seg {
309 const name = clean(model || 'Claude')
310 const head = (n: number) => c(st, st.P.LAV, `◆ ${clip(name, n)}`, true)
311 const level = clean(effort ?? '')
312 const steps = EFFORT[level]
313 if (!steps) return seg('model', 10, [level ? join(head(40), c(st, st.P.DIM, clip(level, 10))) : head(40), head(12)], { keep: true })
314 const pips = cat(c(st, steps >= 4 ? st.P.YELLOW : st.P.LAV, '▰'.repeat(steps)), c(st, st.P.DIM, '▱'.repeat(5 - steps)))
315 return seg('model', 10, [cat(head(40), [{ t: ' ' }], pips), cat(head(12), [{ t: ' ' }], pips)], { keep: true })
316}
317
318export function gitSeg(st: Style, g: GitInfo | null, wt: string | null): Seg {
319 if (!g || !g.branch) return none('git')
320 const P = st.P
321 const branch = clean(g.branch)
322 const worktree = wt ? clean(wt) : null
323 const lead = (n: number): Part => {
324 let text: Part = worktree ? badge(st, P.LAV, `${st.glyph.tree}${clip(worktree, n)}`) : []
325 if (branch === '(detached)') return join(text, badge(st, P.YELLOW, `detached @${clean(g.oid)}`))
326 if (branch !== worktree) text = join(text, badge(st, PROTECTED.test(branch) ? P.PINK : P.MINT, `${st.glyph.branch}${clip(branch, n)}`))
327 return text
328 }
329 const sync = cat(g.ahead ? c(st, P.LAV, ` ↑${g.ahead}`) : [], g.behind ? c(st, P.PINK, ` ↓${g.behind}`) : [])
330 const conflicts = g.conflicts ? c(st, P.PINK, ` =${g.conflicts}`, true) : []
331 const counts = cat(
332 g.staged ? c(st, P.MINT, ` +${g.staged}`) : [],
333 g.modified ? c(st, P.YELLOW, ` ~${g.modified}`) : [],
334 g.untracked ? c(st, P.DIM, ` ?${g.untracked}`) : g.untracked === null ? c(st, P.DIM, ' ?…') : [],
335 )
336 return seg('git', conflicts.length ? 11 : 9, [
337 cat(lead(40), counts, conflicts, sync),
338 cat(lead(40), counts, conflicts),
339 cat(lead(24), counts, conflicts),
340 cat(lead(24), conflicts),
341 cat(lead(14), conflicts),
342 ])
343}
344
345export function kubeSeg(st: Style, kube: View['kube']): Seg {
346 if (!kube || !kube.ctx) return none('kube')
347 const name = clean(kube.ctx)
348 const ns = clean(kube.ns)
349 const label = (n: number) => `${st.glyph.kube}${clip(name, n)}${ns ? `/${clip(ns, n)}` : ''}`
350 if (PROD.test(name)) return seg('kube', 10, [badge(st, st.P.PINK, label(30)), badge(st, st.P.PINK, label(12))])
351 return seg('kube', 6, [c(st, st.P.LAV, label(30)), c(st, st.P.LAV, label(12))])
352}
353
354export function prSeg(st: Style, pr: Pr | null): Seg {
355 if (!pr || !pr.number) return none('pr')
356 const ref = `${pr.kind === 'mr' ? '!' : '#'}${pr.number}`
357 const tint = { approved: st.P.MINT, changes_requested: st.P.PINK, pending: st.P.YELLOW }[pr.state as string] ?? st.P.DIM
358 const mark = REVIEW[pr.state] ? c(st, tint, REVIEW[pr.state] ?? '') : null
359 return seg('pr', 5, [join(c(st, st.P.LAV, ref), mark), c(st, st.P.LAV, ref)])
360}
361
362export function forgeSeg(st: Style, f: Forge | null): Seg {
363 if (!f) return none('forge')
364 const mark = f.stale ? '≈' : ''
365 const prs = `${mark}${f.prs}${f.capped ? '+' : ''}`
366 const issues = `${mark}${f.issues}`
367 return seg('forge', 8, [join(c(st, st.P.DIM, st.glyph.pr.trim()), c(st, null, prs), c(st, st.P.DIM, st.glyph.issue.trim()), c(st, null, issues))])
368}
369
370export function limitSeg(st: Style, label: string, lim: Limit | undefined, window: number, basePrio: number, nowS: number): Seg {
371 if (!lim || typeof lim.percentUsed !== 'number') return none(label)
372 const P = st.P
373 const pct = lim.percentUsed
374 const resets = lim.resetsAt ? Date.parse(lim.resetsAt) / 1000 : NaN
375 const left = Number.isFinite(resets) ? resets - nowS : null
376 const frac = left !== null && left > 0 && left <= window ? 1 - left / window : null
377 const delta = frac !== null ? pct - frac * 100 : 0
378 const tick = delta <= -5 ? P.MINT : delta <= 5 ? null : delta <= 15 ? P.YELLOW : P.PINK
379 const reached = pct >= 100 && (left === null || left > 0)
380 let warn: Part = []
381 if (reached) warn = c(st, P.PINK, '■', true)
382 else if (frac !== null && frac >= 0.1 && pct / frac >= 110) warn = c(st, P.PINK, `▲${humanize((100 - pct) / (pct / (frac * window)))}`)
383 const flag = warn.length ? c(st, P.PINK, reached ? '■' : '▲', true) : null
384 const head = c(st, P.DIM, label)
385 const value = c(st, levelColor(P, pct), `${fixed0(pct)}%`, pct >= 90)
386 const reset = left !== null && left > 0 ? c(st, P.DIM, `↻${humanize(left)}`) : null
387 const gap = frac !== null ? c(st, tick ?? P.DIM, `(${signed0(delta)})`) : null
388 const meter = (w: number) => bar(st, pct, w, frac !== null ? Math.min(w - 1, Math.floor(frac * w)) : null, frac !== null ? tick : null)
389 const prio = warn.length ? 11 : pct >= 90 ? 10 : basePrio
390 return seg(label, prio, [
391 join(head, meter(10), value, gap, reset, warn),
392 join(head, meter(8), value, gap, warn),
393 join(head, meter(6), value, gap, warn),
394 join(head, meter(4), value, flag),
395 ])
396}
397
398// The engine reports a spend limit's percentage and reset only, not its dollar amounts.
399export function spendSeg(st: Style, lim: Limit | undefined, nowS: number): Seg {
400 if (!lim || typeof lim.percentUsed !== 'number') return none('spend')
401 const pct = lim.percentUsed
402 const head = c(st, st.P.DIM, 'spend')
403 const value = c(st, levelColor(st.P, pct), `${fixed0(pct)}%`)
404 const left = lim.resetsAt ? Date.parse(lim.resetsAt) / 1000 - nowS : 0
405 const reset = left > 0 ? c(st, st.P.DIM, `↻${humanize(left)}`) : null
406 const flag = pct >= 100 ? c(st, st.P.PINK, '■', true) : null
407 return seg('spend', flag ? 11 : 7, [join(head, bar(st, pct, 8), value, reset, flag), join(head, bar(st, pct, 6), value, flag), join(head, value, flag)])
408}
409
410export function ctxSeg(st: Style, ctx: View['context']): Seg {
411 if (!ctx) return none('ctx')
412 const P = st.P
413 const head = c(st, P.DIM, 'ctx')
414 const over = (ctx.tokens ?? 0) > 200_000 ? c(st, P.YELLOW, '>200k') : null
415 // Against a smaller auto-compact window the share is recomputed from the tokens, and may pass 100%.
416 const size = ctx.limit && ctx.limit < ctx.window ? ctx.limit : ctx.window
417 const reduced = size !== ctx.window
418 const pct = !reduced ? ctx.percent : ctx.tokens !== undefined ? (ctx.tokens / size) * 100 : ctx.percent !== undefined ? (ctx.percent * ctx.window) / size : undefined
419 if (pct === undefined || pct === null) return seg('ctx', 9, [join(head, bar(st, 0, 14), c(st, P.DIM, '--'), over), join(head, c(st, P.DIM, '--'), over)])
420 const value = c(st, levelColor(P, pct), `${fixed0(pct)}%`, pct >= 90)
421 const used = (pct / 100) * size
422 const amount = used && size ? c(st, P.DIM, `${fmtTokens(used)}/${fmtTokens(size)}`) : null
423 return seg('ctx', pct >= 90 ? 10 : 9, [
424 join(head, bar(st, pct, 14), value, amount, over),
425 join(head, bar(st, pct, 10), value, amount, over),
426 join(head, bar(st, pct, 6), value, over),
427 join(head, value, over),
428 ])
429}
430
431// A window value as Claude Code takes it: a token count from 100K to 1M. "auto" (the model's tuned window)
432// or anything else is no value.
433const windowValue = (v: unknown): number | null => {
434 const n = typeof v === 'number' ? v : typeof v === 'string' && /^\d+$/.test(v.trim()) ? Number(v) : NaN
435 return Number.isFinite(n) && n >= 100_000 && n <= 1_000_000 ? n : null
436}
437
438// The auto-compact window (how full the context may get before Claude Code compacts it), resolved as Claude
439// Code does: CLAUDE_CODE_AUTO_COMPACT_WINDOW, then modelSettings[<model>].autoCompactWindow, then the
440// top-level autoCompactWindow; capped at the model's own window, which is also the answer when none is set.
441// The model's key is matched by its exact id, then without a bracket suffix (`[1m]`), then by the longest
442// key the id starts with (a dated id such as claude-x-4-5-20250929).
443export function effectiveWindow(o: { mode: 'auto' | 'full'; engineWindow: number; model: string | null; settings: Record<string, unknown>; env: string | null }): number {
444 if (o.mode === 'full' || !(o.engineWindow > 0)) return o.engineWindow
445 const fromEnv = windowValue(o.env)
446 let chosen = fromEnv
447 if (chosen === null && o.model) {
448 const all = o.settings.modelSettings && typeof o.settings.modelSettings === 'object' ? (o.settings.modelSettings as Record<string, unknown>) : {}
449 const bare = o.model.replace(/\[[^\]]*\]$/, '')
450 const key =
451 [o.model, bare].find((k) => k in all) ??
452 Object.keys(all).filter((k) => bare.startsWith(`${k}-`)).sort((a, b) => b.length - a.length)[0]
453 const entry = key !== undefined ? all[key] : undefined
454 const value = entry && typeof entry === 'object' ? (entry as Record<string, unknown>).autoCompactWindow : undefined
455 // "auto" for the model returns it to its own tuned window, whatever the top-level key says.
456 if (value === 'auto') return o.engineWindow
457 chosen = windowValue(value)
458 }
459 if (chosen === null) chosen = windowValue(o.settings.autoCompactWindow)
460 return chosen === null ? o.engineWindow : Math.min(chosen, o.engineWindow)
461}
462
463export function cacheSeg(st: Style, pc: PromptCache | null, nowS: number): Seg {
464 if (!pc || !pc.observed) return none('cache')
465 const P = st.P
466 const ratio = pc.hit
467 const left = pc.expiresAt !== null ? pc.expiresAt / 1000 - nowS : 0
468 const tint = pc.requests < 3 ? P.DIM : ratio !== null && ratio >= 0.8 ? P.MINT : ratio !== null && ratio >= 0.5 ? P.YELLOW : P.PINK
469 const hit = ratio !== null ? c(st, tint, `${fixed0(ratio * 100)}%`) : null
470 const isCold = !pc.warm || (pc.expiresAt !== null && left <= 0)
471 // w (green) while the cache is warm, c (blue) once it has gone cold; the script wrote `cold`.
472 const state = c(st, isCold ? P.LAV : P.MINT, isCold ? 'c' : 'w')
473 const timeLeft = !isCold && pc.expiresAt !== null && left > 0 ? c(st, P.DIM, left >= 60 ? humanize(left) : '<1m') : null
474 const miss = pc.misses ? c(st, P.PINK, `⊘${pc.misses}`) : null
475 const head = c(st, P.DIM, st.glyph.cache.trim())
476 return seg('cache', pc.misses ? 6 : 4, [join(head, hit, state, timeLeft, miss), join(head, hit, state, miss)])
477}
478
479export const costSeg = (st: Style, usd: number | null): Seg => (usd ? seg('cost', 5, [c(st, null, `$${usd.toFixed(2)}`)]) : none('cost'))
480
481// The API part: the known total (under a minute as <1m, never 0m), `--` while pending, else left out.
482export function timeSeg(st: Style, durationMs: number | null, apiMs: number | null, apiPending = false): Seg {
483 if (!durationMs || durationMs < 60_000) return none('time')
484 const wall = humanize(durationMs / 1000)
485 const api = apiMs ? `/${apiMs < 60_000 ? '<1m' : humanize(apiMs / 1000)}` : apiPending ? '/--' : ''
486 return seg('time', 4, [c(st, st.P.DIM, wall + api), c(st, st.P.DIM, wall)])
487}
488
489// ---- loading ----
490
491// What the band knows about its sources while they load (see pendingFor).
492export type Arrival = {
493 enabled: boolean // the placeholders option
494 waiting: boolean // within the deadline after the mod loaded
495 responded: boolean // the engine has reported the session's first API response (context tokens, cost or limits)
496 transcript: 'none' | 'reading' | 'done' // none: no transcript file yet (a new session)
497 forgeExpected: boolean // a forge remote whose counts have not been answered yet
498}
499export const PENDING_DEADLINE_MS = 10_000
500
501// The segments that are still on their way, each drawn as a dim placeholder until its data comes. A segment
502// whose source has answered and has nothing to show (no repository, no limits on this plan) is not pending.
503// - ctx, 5h, 7d, cost: the engine has them only after the session's first API response; they wait for it with
504// no deadline, and the limits that are still missing after it (an API-key plan has none) are dropped.
505// - time: under a minute of session time is drawn as `--`.
506// - api, cache: read from the transcript; pending while it is being read (until the deadline), and in a new
507// session until the first response writes it. A resumed session shows the transcript's figures as soon as
508// they are read: the hit ratio over its past requests and, from the last response's time, whether the cache
509// is still warm.
510// - forge: until gh/glab answer or the deadline passes; a failure shows as ✗forge.
511export function pendingFor(a: Arrival, v: View): string[] {
512 if (!a.enabled) return []
513 const out: string[] = []
514 if (!a.responded) {
515 if (!v.context || v.context.tokens === undefined) out.push('ctx')
516 for (const [label, kind] of WINDOWS) if (!v.limits.some((l) => l.kind === kind)) out.push(label)
517 if (!v.costUsd) out.push('cost')
518 }
519 if (!v.durationMs || v.durationMs < 60_000) out.push('time')
520 const fromTranscript = (a.transcript === 'reading' && a.waiting) || (!a.responded && a.transcript === 'none')
521 if (fromTranscript && !v.apiMs) out.push('api')
522 if (fromTranscript && !v.cache) out.push('cache')
523 if (a.forgeExpected && a.waiting && !v.forge) out.push('forge')
524 return out
525}
526
527// A pending segment's stand-in: dim, the shape of the real one, at the same priority.
528export function placeholderSeg(st: Style, name: string): Seg {
529 const P = st.P
530 const dim = (t: string) => c(st, P.DIM, t)
531 const track = (w: number) => c(st, P.TRACK, '─'.repeat(w))
532 const window = WINDOWS.find(([label]) => label === name)
533 if (window) return seg(name, window[3], [10, 8, 6, 4].map((w) => join(dim(name), track(w), dim('--'))))
534 if (name === 'ctx') return ctxSeg(st, { window: 0 })
535 if (name === 'cost') return seg('cost', 5, [dim('$--')])
536 if (name === 'time') return seg('time', 4, [dim('--')])
537 if (name === 'cache') return seg('cache', 4, [join(dim(st.glyph.cache.trim()), dim('--'))])
538 if (name === 'forge') return seg('forge', 8, [join(dim(st.glyph.pr.trim()), dim('--'), dim(st.glyph.issue.trim()), dim('--'))])
539 return none(name)
540}
541
542// The session's own line counts when known; otherwise uncommitted lines in the working tree, marked Δ.
543export function linesSeg(st: Style, lines: View['lines']): Seg {
544 if (!lines || (!lines.added && !lines.removed)) return none('lines')
545 const body = cat(c(st, st.P.MINT, `+${lines.added}`), [{ t: ' ' }], c(st, st.P.PINK, `-${lines.removed}`))
546 return seg('lines', 2, [lines.session ? body : cat(c(st, st.P.DIM, 'Δ'), body)])
547}
548
549// Builds every segment; one that throws is left out and named, as the script does, in a ✗name notice.
550// linesRow 1 (the owner's layout, the default) puts lines added/removed beside the git segment, dropped after
551// the forge counts and before git; 2 is the script's layout, kept so the formatter can be checked against it.
552export function buildRows(st: Style, v: View, cols: number, rows: 1 | 2 = 2, linesRow: 1 | 2 = 1): Part[] {
553 const failed = [...v.failed]
554 const safe = (name: string, build: () => Seg): Seg => {
555 try {
556 return build()
557 } catch {
558 failed.push(name)
559 return none(name)
560 }
561 }
562 // A source that failed shows its ✗name, never a placeholder.
563 const pend = new Set((v.pending ?? []).filter((n) => !failed.includes(n)))
564 const slot = (name: string, build: () => Seg): Seg => {
565 const s = safe(name, build)
566 return s.forms.length || !pend.has(name) ? s : placeholderSeg(st, name)
567 }
568 const P = st.P
569 const budget = cols - 4
570 const nowS = v.now / 1000
571 const folder = clean(v.cwd ? v.cwd.split('/').filter(Boolean).pop() || '/' : '')
572 const limit = (kind: string) => v.limits.find((l) => l.kind === kind)
573
574 const ident: Seg[] = [safe('model', () => modelSeg(st, v.model, v.effort))]
575 if (v.fast) ident.push(seg('fast', 7, [c(st, P.YELLOW, '⚡')]))
576 // The folder is always drawn, also beside a worktree badge of the same name (the script hid it then).
577 if (v.cwd) ident.push(seg('folder', 8, [c(st, null, `${st.glyph.folder}${clip(folder, 32)}`), c(st, null, clip(folder, 12))]))
578 const lines = safe('lines', () => linesSeg(st, v.lines))
579 const git = safe('git', () => gitSeg(st, v.git, v.worktree))
580 const kube = safe('kube', () => kubeSeg(st, v.kube))
581 const pr = safe('pr', () => prSeg(st, v.pr))
582 const agent = v.agent ? [seg('agent', 4, [c(st, P.YELLOW, `@${clip(clean(v.agent), 24)}`)])] : []
583 // The repository's PR and issue counts, from 120 columns. In the owner's layout they sit right after the
584 // branch and its PR, as garnish: on a short row they go before kube, the session name and the style.
585 const forge = cols < FORGE_MIN_COLS && !st.desktop ? [] : [slot('forge', () => forgeSeg(st, v.forge))]
586 if (linesRow === 1) {
587 lines.prio = 8.5
588 for (const f of forge) Object.assign(f, { optional: true, prio: 3.5 })
589 ident.push(git, pr, ...forge, lines, kube, ...agent)
590 } else {
591 ident.push(git, kube, pr, ...agent)
592 }
593
594 const extras: Seg[] = []
595 if (v.sessionName) {
596 const name = clean(v.sessionName)
597 extras.push(seg('session', 4, [c(st, P.DIM, clip(name, 28), false, true), c(st, P.DIM, clip(name, 12), false, true)], { optional: true }))
598 }
599 const style = clean(v.outputStyle ?? '')
600 if (style && style.toLowerCase() !== 'default') extras.push(seg('style', 5, [c(st, P.DIM, `✎ ${clip(style, 20)}`)], { optional: true }))
601 if (v.vim) extras.push(seg('vim', 3, [c(st, P.DIM, `[${clean(v.vim).slice(0, 1)}]`)], { optional: true }))
602 // The script draws the counts at the far right, after the garnish.
603 const tail = linesRow === 1 ? [] : forge
604
605 const budgets = [
606 slot('ctx', () => ctxSeg(st, v.context)),
607 ...WINDOWS.map(([label, kind, window, prio]) => slot(label, () => limitSeg(st, label, limit(kind), window, prio, nowS))),
608 safe('spend', () => spendSeg(st, limit('spend_limit'), nowS)),
609 ]
610 const stats = [
611 slot('cache', () => cacheSeg(st, v.cache, nowS)),
612 slot('cost', () => costSeg(st, v.costUsd)),
613 slot('time', () => timeSeg(st, v.durationMs, v.apiMs, pend.has('api'))),
614 ...(linesRow === 2 ? [lines] : []),
615 ]
616 // Every source or segment that failed, as a dim ✗name at the end of row 1, so a screenshot says why a
617 // segment is missing.
618 const names = [...new Set(failed)]
619 const notice = names.length ? [seg('failed', 9, [c(st, 'faint', names.map((n) => `✗${n}`).join(' '))])] : []
620 const out = rows === 1 ? [fit(st, [...ident, ...extras, ...tail, ...notice, ...budgets, ...stats], budget)] : [fit(st, [...ident, ...extras, ...tail, ...notice], budget), fit(st, [...budgets, ...stats], budget)]
621 return out.filter((r) => r.length)
622}
623
624// The thin dim rule drawn above the rows, the band's width, so the band stands apart from the transcript.
625export const rule = (st: Style, width: number): Part => c(st, st.P.TRACK, '─'.repeat(Math.max(1, width)))
626
627// ---- parsers for what git, gh, glab, kubectl and the transcript print ----
628
629export function parsePorcelain(out: string, skipUntracked: boolean): GitInfo {
630 const g: GitInfo = { branch: '', oid: '', ahead: 0, behind: 0, staged: 0, modified: 0, untracked: skipUntracked ? null : 0, conflicts: 0 }
631 for (const line of out.split('\n')) {
632 if (line.startsWith('# branch.head ')) g.branch = line.slice(14)
633 else if (line.startsWith('# branch.oid ')) g.oid = line.slice(13, 20)
634 else if (line.startsWith('# branch.ab ')) {
635 const [a = '+0', b = '-0'] = line.slice(12).split(' ')
636 g.ahead = Number(a.slice(1)) || 0
637 g.behind = Number(b.slice(1)) || 0
638 } else if (line.startsWith('1 ') || line.startsWith('2 ')) {
639 if (line[2] !== '.') g.staged++
640 if (line[3] !== '.') g.modified++
641 } else if (line.startsWith('u ')) g.conflicts++
642 else if (line.startsWith('? ') && g.untracked !== null) g.untracked++
643 }
644 return g
645}
646
647export function parseNumstat(out: string): { added: number; removed: number } {
648 let added = 0
649 let removed = 0
650 for (const row of out.split('\n')) {
651 const [a = '', r = ''] = row.split('\t')
652 if (/^\d+$/.test(a)) added += Number(a)
653 if (/^\d+$/.test(r)) removed += Number(r)
654 }
655 return { added, removed }
656}
657
658// `git rev-parse --show-toplevel --absolute-git-dir --git-common-dir`: the worktree's name when this is a
659// linked worktree (its own git dir differs from the shared one), else null.
660export function parseWorktree(out: string): string | null {
661 const [top = '', gitDir = '', common = ''] = out.split('\n').map((s) => s.trim())
662 if (!top || !gitDir || !common) return null
663 const norm = (p: string) => (p.startsWith('/') ? p : `${top}/${p}`).replace(/\/+$/, '').replace(/\/\.git$/, '/.git')
664 if (norm(gitDir) === norm(common) || !gitDir.includes('/worktrees/')) return null
665 return top.split('/').filter(Boolean).pop() ?? null
666}
667
668const REMOTE = /^(?:[a-z+]+:\/\/)?(?:[^@/]+@)?([^/:]+)(?::\d+(?=\/))?[:/](.+?)(?:\.git)?\/?$/i
669const REMOTE_HOST = /^[A-Za-z0-9][A-Za-z0-9.-]*$/
670const REMOTE_PATH = /^[A-Za-z0-9_.-]+(\/[A-Za-z0-9_.-]+)+$/
671export const TRUSTED_FORGES: Record<string, 'github' | 'gitlab'> = { 'github.com': 'github', 'gitlab.com': 'gitlab' }
672
673// (host, owner/repo) from an ssh, scp-style or https remote URL; null for local paths and odd shapes.
674export function parseRemote(url: string): { host: string; path: string } | null {
675 const u = url.trim()
676 const m = u.startsWith('/') || u.startsWith('.') ? null : REMOTE.exec(u)
677 if (!m || !REMOTE_HOST.test(m[1] ?? '') || !REMOTE_PATH.test(m[2] ?? '')) return null
678 if ((m[2] ?? '').split('/').some((p) => p === '.' || p === '..')) return null
679 return { host: (m[1] ?? '').toLowerCase(), path: m[2] ?? '' }
680}
681
682// Only github.com, gitlab.com and hosts the person declared: a repository's own remote is untrusted input and
683// must not point the logged-in gh or glab at an arbitrary server.
684export const providerFor = (host: string, declared: Record<string, 'github' | 'gitlab'>): 'github' | 'gitlab' | null => declared[host] ?? TRUSTED_FORGES[host] ?? null
685
686export const GH_QUERY = 'query($owner:String!,$name:String!){repository(owner:$owner,name:$name){pullRequests(states:OPEN){totalCount} issues(states:OPEN){totalCount}}}'
687
688export function parseGhCounts(json: string): { prs: number; issues: number } {
689 const repo = (JSON.parse(json) as { data?: { repository?: { pullRequests?: { totalCount?: unknown }; issues?: { totalCount?: unknown } } } }).data?.repository
690 const prs = repo?.pullRequests?.totalCount
691 const issues = repo?.issues?.totalCount
692 if (typeof prs !== 'number' || typeof issues !== 'number') throw new Error('no counts')
693 return { prs, issues }
694}
695
696// `gh pr list --json number,isDraft,reviewDecision --limit 1`
697export function parseGhPr(json: string): Pr | null {
698 const rows: unknown = JSON.parse(json)
699 const r = Array.isArray(rows) ? (rows[0] as Record<string, unknown> | undefined) : undefined
700 if (!r || typeof r.number !== 'number') return null
701 const state = r.isDraft ? 'draft' : r.reviewDecision === 'APPROVED' ? 'approved' : r.reviewDecision === 'CHANGES_REQUESTED' ? 'changes_requested' : 'pending'
702 return { number: r.number, kind: 'pr', state }
703}
704
705// `glab api projects/<id>/merge_requests?state=opened&source_branch=<b>&per_page=1`
706export function parseGlabMr(json: string): Pr | null {
707 const rows: unknown = JSON.parse(json)
708 const r = Array.isArray(rows) ? (rows[0] as Record<string, unknown> | undefined) : undefined
709 if (!r || typeof r.iid !== 'number') return null
710 return { number: r.iid, kind: 'mr', state: r.draft ? 'draft' : 'pending' }
711}
712
713// `kubectl config view --minify --output json`
714export function parseKubectl(json: string): { ctx: string; ns: string } {
715 const entry = ((JSON.parse(json) as { contexts?: { name?: string; context?: { namespace?: string } }[] }).contexts ?? [{}])[0] ?? {}
716 return { ctx: entry.name ?? '', ns: entry.context?.namespace ?? '' }
717}
718
719// Without kubectl: the current context and its namespace read from the kubeconfig files' text, best effort.
720export function kubeFromTexts(texts: string[]): { ctx: string; ns: string } {
721 const found = texts.map((t) => /^current-context:\s*(.+?)\s*$/m.exec(t)?.[1]).filter((x): x is string => !!x)
722 const ctx = (found[0] ?? '').replace(/^['"]|['"]$/g, '')
723 if (!ctx) return { ctx: '', ns: '' }
724 for (const text of texts) {
725 const lines = text.split('\n')
726 const start = lines.findIndex((l) => /^contexts:\s*$/.test(l))
727 if (start < 0) continue
728 const entries: string[][] = []
729 let base: number | null = null
730 for (const line of lines.slice(start + 1)) {
731 if (line.trim() && !line.startsWith(' ') && !line.startsWith('-')) break
732 const item = /^(\s*)- /.exec(line)
733 if (item && (base === null || (item[1] ?? '').length <= base)) {
734 base = (item[1] ?? '').length
735 entries.push([])
736 }
737 entries[entries.length - 1]?.push(line)
738 }
739 for (const e of entries.map((x) => x.join('\n'))) {
740 const name = /^\s*(?:-\s+)?name:\s*(.+?)\s*$/m.exec(e)
741 if (name && (name[1] ?? '').replace(/^['"]|['"]$/g, '') === ctx) {
742 const ns = /namespace:\s*([^\s,}]+)/.exec(e)
743 return { ctx, ns: ns ? (ns[1] ?? '').replace(/^['"]|['"]$/g, '') : '' }
744 }
745 }
746 }
747 return { ctx, ns: '' }
748}
749
750// Running totals over the session transcript (append-only JSONL), folded one line at a time.
751export type Transcript = {
752 title: string | null
753 effort: string | null
754 fast: boolean
755 ts: string | null
756 ttl: number
757 recent: string[]
758 requests: number
759 read: number
760 create: number
761 input: number
762 misses: number
763 // Lines from edit and write results since the last cost snapshot (or since the start without one).
764 added: number
765 removed: number
766 // The engine's own running totals, as its latest `cost-state` entry saved them; `snapshots` counts them.
767 snapshot: { apiMs: number; linesAdded: number; linesRemoved: number } | null
768 snapshots: number
769 // API requests since the last snapshot (or the start): ones from before this mod loaded were never timed.
770 sinceSnapshot: number
771}
772export const emptyTranscript = (): Transcript => ({ title: null, effort: null, fast: false, ts: null, ttl: 300, recent: [], requests: 0, read: 0, create: 0, input: 0, misses: 0, added: 0, removed: 0, snapshot: null, snapshots: 0, sinceSnapshot: 0 })
773
774// Session totals for the status line: the engine's last snapshot plus what came after it (lines from the
775// transcript, API time from the requests this mod timed since the snapshot was read); without a snapshot,
776// the transcript's lines and the mod's own API time. `untimed` counts requests after the last snapshot that
777// ran before this mod loaded (a resumed session with no saved total, or one that ended without saving): their
778// API time is unknown, so the total is null rather than a figure lower than the truth.
779export function sessionTotals(t: Transcript, liveApiMs: number, apiAtSnapshot: number, untimed = 0): { apiMs: number | null; added: number; removed: number } {
780 const known = untimed <= 0
781 if (!t.snapshot) return { apiMs: known ? liveApiMs : null, added: t.added, removed: t.removed }
782 return { apiMs: known ? t.snapshot.apiMs + Math.max(0, liveApiMs - apiAtSnapshot) : null, added: t.snapshot.linesAdded + t.added, removed: t.snapshot.linesRemoved + t.removed }
783}
784
785type Obj = Record<string, unknown>
786const obj = (v: unknown): Obj => (v && typeof v === 'object' && !Array.isArray(v) ? (v as Obj) : {})
787const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
788
789export function absorb(s: Transcript, line: string): void {
790 const entry = obj(JSON.parse(line))
791 const kind = entry.type
792 if (kind === 'custom-title' || kind === 'ai-title') {
793 s.title = (entry.customTitle as string) || (entry.aiTitle as string) || s.title
794 } else if (kind === 'assistant') {
795 const message = obj(entry.message)
796 const usage = message.usage && typeof message.usage === 'object' ? obj(message.usage) : null
797 if (typeof entry.effort === 'string' && entry.effort) s.effort = entry.effort
798 if (!usage) return
799 s.fast = usage.speed === 'fast'
800 s.ts = typeof entry.timestamp === 'string' ? entry.timestamp : s.ts
801 const created = obj(usage.cache_creation)
802 if (num(created.ephemeral_1h_input_tokens)) s.ttl = 3600
803 else if (num(created.ephemeral_5m_input_tokens)) s.ttl = 300
804 const id = String(message.id ?? '')
805 if (s.recent.includes(id)) return // one API response is written as several entries
806 s.recent = [...s.recent, id].slice(-64)
807 const read = num(usage.cache_read_input_tokens)
808 const create = num(usage.cache_creation_input_tokens)
809 if (s.requests > 0 && read === 0 && create > 0) s.misses++
810 s.requests++
811 s.sinceSnapshot++
812 s.read += read
813 s.create += create
814 s.input += num(usage.input_tokens)
815 } else if (kind === 'cost-state') {
816 // Cumulative, as the terminal's status line input has them (total_api_duration_ms, total_lines_*).
817 s.snapshot = { apiMs: num(entry.totalAPIDuration), linesAdded: num(entry.totalLinesAdded), linesRemoved: num(entry.totalLinesRemoved) }
818 s.snapshots++
819 s.sinceSnapshot = 0
820 s.added = 0
821 s.removed = 0
822 } else if (kind === 'user' && entry.toolUseResult && typeof entry.toolUseResult === 'object') {
823 const result = obj(entry.toolUseResult)
824 for (const hunk of Array.isArray(result.structuredPatch) ? result.structuredPatch : []) {
825 for (const l of Array.isArray(obj(hunk).lines) ? (obj(hunk).lines as unknown[]) : []) {
826 if (typeof l !== 'string') continue
827 if (l.startsWith('+')) s.added++
828 if (l.startsWith('-')) s.removed++
829 }
830 }
831 if (result.type === 'create' && typeof result.content === 'string') s.added += result.content.split(/\r?\n/).length - (result.content.endsWith('\n') ? 1 : 0)
832 }
833}
834
835// Bytes in UTF-8, so the read offset advances by what was consumed whatever the characters.
836export function utf8Length(s: string): number {
837 let n = 0
838 for (const ch of s) {
839 const cp = ch.codePointAt(0) ?? 0
840 n += cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4
841 }
842 return n
843}
844
845// Folds the complete lines of a chunk read from `offset`; a half-written last line waits for the next read.
846// Returns the bytes consumed.
847export function absorbChunk(s: Transcript, chunk: string): number {
848 const end = chunk.lastIndexOf('\n')
849 if (end < 0) return 0
850 const body = chunk.slice(0, end + 1)
851 for (const line of body.split('\n').slice(0, -1)) {
852 try {
853 absorb(s, line)
854 } catch {
855 // a line this version does not understand
856 }
857 }
858 return utf8Length(body)
859}
860
861// The cache figures the status line shows, from the transcript totals; the script's Desktop derivation.
862export function cacheFrom(s: Transcript, nowMs: number): PromptCache | null {
863 if (!s.requests || !s.ts) return null
864 const at = Date.parse(s.ts)
865 if (!Number.isFinite(at)) return null
866 const expiresAt = at + s.ttl * 1000
867 const total = s.read + s.create + s.input
868 return { observed: s.read + s.create > 0, warm: expiresAt > nowMs, expiresAt, requests: s.requests, misses: s.misses, hit: total ? s.read / total : null }
869}
870
871// The transcript Claude Code keeps for a session: under the config dir, in a folder named after the project
872// root with every non-alphanumeric character replaced by a dash.
873export const transcriptPath = (configDir: string, root: string, sessionId: string): string => `${configDir}/projects/${root.replace(/[^A-Za-z0-9]/g, '-')}/${sessionId}.jsonl`
874
875// claude-<family>-<major>-<minor>[-yyyymmdd][[1m]] reads as "Family major.minor"; anything else as given.
876export function modelName(id: string): string {
877 const m = /^claude-([a-z]+)-(\d+)-(\d+)(?:-\d{8})?(?:\[[^\]]*\])?$/.exec(id)
878 if (!m) return clean(id)
879 const fam = m[1] ?? ''
880 return `${fam.charAt(0).toUpperCase()}${fam.slice(1)} ${m[2]}.${m[3]}`
881}
882
883// ---- theme ----
884
885// The "dark" colours from a Ghostty theme file (Noctalia generates one that follows the wallpaper).
886export function noctaliaPalette(text: string): Palette | null {
887 const slots = new Map<number, string>()
888 for (const m of text.matchAll(/^\s*palette\s*=\s*(\d+)\s*=\s*#([0-9a-fA-F]{6})\s*$/gm)) slots.set(Number(m[1]), m[2] ?? '')
889 const bg = /^\s*background\s*=\s*#([0-9a-fA-F]{6})\s*$/m.exec(text)
890 if (!bg || ![1, 2, 3, 4].every((i) => slots.has(i))) return null
891 const rgb = (h: string): Rgb => [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)) as unknown as Rgb
892 const ink = rgb(bg[1] ?? '')
893 const [pink, mint, yellow, lav] = [1, 2, 3, 4].map((i) => rgb(slots.get(i) ?? '000000')) as [Rgb, Rgb, Rgb, Rgb]
894 const toward = (share: number): Rgb => [0, 1, 2].map((i) => roundEven((ink[i] ?? 0) + ((lav[i] ?? 0) - (ink[i] ?? 0)) * share)) as unknown as Rgb
895 return { MINT: mint, PINK: pink, YELLOW: yellow, LAV: lav, DIM: toward(0.75), TRACK: toward(0.55), INK: ink, BAR: [mint, yellow, pink] }
896}
897
898export type ThemeHints = { env: string | null; terminalEnv: string | null; colorFgBg: string | null; desktopMode: string | null; systemDark: boolean | null }
899
900// The script's choice: an explicit theme, then COLORFGBG's background index, then (in Desktop) the app's own
901// theme or the OS scheme it follows, else the mid-tone neutral palette that reads on dark and light alike.
902export function pickTheme(surface: string, h: ThemeHints, available: (t: string) => boolean): ThemeName {
903 const desktop = surface !== 'terminal'
904 const wanted = (h.env || (desktop ? '' : h.terminalEnv || '')).toLowerCase()
905 if (available(wanted)) return wanted as ThemeName
906 const bgIndex = (h.colorFgBg ?? '').split(';').pop() ?? ''
907 if (/^\d+$/.test(bgIndex)) return ['7', '15'].includes(bgIndex) ? 'light' : 'dark'
908 if (desktop) {
909 if (h.desktopMode === 'dark' || h.desktopMode === 'light') return h.desktopMode
910 if (h.desktopMode === 'system' && h.systemDark !== null) return h.systemDark ? 'dark' : 'light'
911 }
912 return 'neutral'
913}
914
915// `gsettings get org.gnome.desktop.interface color-scheme` or the portal's color-scheme reply.
916export function systemDark(out: string): boolean | null {
917 if (out.includes('prefer-dark') || out.includes('uint32 1')) return true
918 if (out.includes('prefer-light') || out.includes('uint32 2')) return false
919 return null
920}
921