SLOPSHOPPER

statusline

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…

newbandprocesstimer
★ 1v0.2.7MITupdated 2026-10-13iuliandita/mods/mods/statusline/claude
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 ⟨Claude Code's own drawing⟩ ──────────────────────────────────────────────────────────────────────────────────────────────────── ◆ Opus 5.5 │ 🖿 app ctx ━━━━━━━─────── 49% 98k/200k │ 5h ━━━─────── 31% │ $0.42 │ 30m ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ──────────────────────────────────────────────────────────────────────────────────────────────────── ◆ Opus 5.5 │ 🖿 app ctx ━━━━━━━─────── 49% 98k/200k │ 5h ━━━─────── 31% │ $0.42 │ 30m
README

mods

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.

Install

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.

Catalog

  • 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.

Private mods

Put your own mods in local/mods/<name>/ (gitignored) and install them with scripts/mods install <name> --local.

License

MIT

Project and support

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.

Source 2 files
hooks/register.tsx 644 lines
1import 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}
644
hooks/segments.ts 921 lines
1// 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