SLOPSHOPPER

session-status

A native Claude Code status line and side pane: model, effort, context, quotas with burn-rate projection, cost and repo.

newpanespinnerguardcommandprocess
v0.3.0MITupdated 2026-10-14AdrienGras/claude-session-status-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-status
│ ┃ Session ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─ Opus 5.5 │ ┃ ────────────────────────────────── ○ idle ─╮ ⏺ Read(src/auth.ts) │ ┃ │ ⎿ Read 6 lines │ ┃ 0 req │ ⏺ Update(src/auth.ts) │ ┃ │ ⎿ Added 2 lines, removed 1 line │ ┃ window 200k │ ⏺ Bash(bun test) │ ┃ ╰─────────────────────────────────────────── ⎿ 3 pass, 1 fail │ ┃ ───────────╯ │ ┃ ╭─ Context ───────────────────────────── ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ 97k/200k 49% ─╮ │ ┃ │ ✻ Worked for 42s · done 4:20 PM │ ┃ ━━━━━━━━━━━━━━━━━━━━━━━━━─────────────────── │ ┃ ──────── │ › /sst │ ┃ ╰─────────────────────────────────────────── ⎿ session-status: Session pane opened. │ ┃ ───────────╯ │ ┃ ╭─ Quotas │ ┃ ──────────────────────────────────────────── │ ┃ ─╮ │ ┃ │ 5h ━━━─────── │ ┃ 31% │ │ ┃ ╰─────────────────────────────────────────── │ ┃ ───────────╯ │ ┃ ╭─ Session │ ┃ ──────────────────────────────────── $0.42 │ ┃ ─╮ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts │ Opus 5.5 │ ctx ━━━━╸───── 49% │ 5h ━━━─────── 31% ◨

Draws

Pane · Session
╭─ Opus 5.5 ────────────────────────────────── ○ idle ─╮ │ 0 req │ │ window 200k │ ╰──────────────────────────────────────────────────────╯ ╭─ Context ───────────────────────────── 97k/200k 49% ─╮ │ ━━━━━━━━━━━━━━━━━━━━━━━━━─────────────────────────── │ ╰──────────────────────────────────────────────────────╯ ╭─ Quotas ─────────────────────────────────────────────╮ │ 5h ━━━─────── 31% │ ╰──────────────────────────────────────────────────────╯ ╭─ Session ──────────────────────────────────── $0.42 ─╮ │ $0.84/h 30m · 0 prompts │ │ since open 0 compactions · 0 subagents │ │ Bash 4 · Write 2 · Read 1 · Grep 1 · Edit 1 │ │ last turn 0s │ │ 0 agents · 3 edits · 1 error │ ╰──────────────────────────────────────────────────────╯ [ compact ] [ recount ] [ settings ]
Pane · sst-config
Status line [x] Model & effort ↑ ↓ [x] Context ↑ ↓ [x] 5h window ↑ ↓ [ ] 7d window [ ] Session cost [ ] Branch & diff Bar width 10 [ − ] [ + ] Layout beside Claude Code's hint [ switch ] [ done ]
Prompt hint
? for shortcuts │ Opus 5.5 │ ctx ━━━━╸───── 49% │ 5h ━━━─────── 31% ◨
README

📟 session-status

CI Quality Gate Version License: MIT Claude Code 2.1.296

Your Claude Code status line, but native. Model, effort, context by category, how fast you're burning through your quotas, session cost, your repo: all of it lives right under the prompt, with a side pane one keystroke away for the full picture. ✨

No script polling every ten seconds, no binary on the side: the mod reads the session itself and follows your Claude Code theme.

⚡ TL;DR

In a Claude Code terminal session:

/plugin install session-status --marketplace AdrienGras/claude-session-status-mod

Answer y, pick the user scope, done: the status line shows up under the prompt. 🎉 Then type /sst to open the side pane.

[!TIP] Replacing an existing status line? Once you're happy, remove the statusLine entry from ~/.claude/settings.json. The mod never touches your settings itself.

👀 What you get

The status line

One line under the prompt, right where Claude Code draws its hint (which stays on the left):

The status line under the prompt: model and effort, context, the 5h window with its projection

  • 🧠 Model and effort, the effort as five notches ▮▮▮▯▯
  • 📦 Context: a bar coloured by category (the same colours as /context) and how full the window is. Before the first reply, you get Claude Code's own estimate, marked ≈.
  • 🔥 5h window: what you've used, a ┊ mark where you should be at an even pace, and where you'll land at the reset. ✓ you've got time to spare, ⚠ you'll hit the wall before the reset at this pace.

Want more? The 7d window, session cost and branch & diff are one /sst config away. Pick your items, reorder them, resize the bars. Whatever doesn't fit your terminal is dropped from the right, and ◨ opens the side pane.

One row or two? By default the status line shares its row with Claude Code's hint, which it redraws as plain dim text. Prefer to keep Claude Code's line exactly as it draws it (the coloured permission mode, the clickable task pills, other mods' additions)? Switch Layout to own row in /sst config: the hint keeps its row and the status line takes the one under it.

The side pane

/sst toggles it. In fullscreen at 110+ columns it docks beside the transcript; otherwise it opens above the prompt.

<img src="docs/media/pane.png" alt="The side pane: model, context by category, quotas with burn-rate projection, session cost, repo" width="420">

BoxWhat's inside
🧠 Modelmodel, effort, permission mode, working/idle, requests, time to first token and tokens per second of the last request, context window
📦 Contextthe bar and its four biggest categories (the rest as other), room left before auto-compaction, unfoldable lists of memory files, MCP servers and agents
🔥 Quotasevery window your plan reports: 5h and 7d with used share, projection, verdict, time to reset and when it resets; a spend limit as a plain gauge
💸 Sessioncost, per hour and per prompt, duration, prompts, compactions, tool calls by tool, subagents, and a receipt of the last turn
🤖 Agentsa card per subagent, live from spawn to end: type, task, state (running, done, failed, stopped), time, tokens and its last tool calls; the last three finished ones stay
🌿 Repofolder, owner/repo, branch, +added −removed, ahead/behind upstream, files you changed during this session

At the bottom: compact runs /compact (after asking 🙏), recount counts the context exactly like /context, settings opens the settings.

⌨️ Commands

CommandDoes
/sstopen or close the side pane
/sst configchoose what the status line shows, in which order, how wide the bars are (6–24), and whether it shares the hint's row or has its own
/sst debugprint what the mod sees, for a bug report

🐛 /sst debug prints figures and categories only: no path, MCP server, agent, skill, repo or branch name. Paste it in an issue without a second thought.

🔍 Measured, estimated, counted

We'd rather show nothing than a made-up number. Here's how each figure comes about:

  • 🎨 Colours come from your Claude Code theme, so light, dark and colour-blind themes all read well. Gauges go from success to warning to error at 50% and 80% for context, and at 80% and 130% of projected use for quotas.
  • ≈ The context breakdown is an estimate: Claude Code's local one, which costs nothing. recount swaps it for the exact count (one token-count request per tool and memory file, like /context), until the next turn brings the estimate back. The total and percentage are always the real ones.
  • 📈 Burn-rate projections assume your pace so far holds until the reset. Early in a window (under 10% elapsed) they're marked ~ and drawn dim, because it's too soon to tell.
  • ⏱️ Time to first token and tokens per second are timed by the mod around each request, so they include the network and other mods. They aren't the API's own figures.
  • 🔢 Compactions, tool calls and subagents are counted by the mod since it loaded (since open). Cost, duration and prompts come from Claude Code and cover the whole session, a resumed one included.
  • 🚫 A figure Claude Code doesn't have is left out, never shown as zero.

🔒 Privacy

The mod reads Claude Code's session data and runs git in your project for the repo box. On its own it makes no network request and calls no model. Only two buttons do, and only when you press them: recount (token-count requests, like /context) and compact (runs /compact). The only thing it keeps between sessions is your settings.

🛠️ Development

git clone https://github.com/AdrienGras/claude-session-status-mod
cd claude-session-status-mod
claude --plugin-dir .
claude plugin validate --strict . && claude plugin test .

The pure logic (formatting, burn rate, bars, context, git parsing, status line and pane rows) lives in hooks/*.ts and is unit-tested; the *.tsx files only draw. Every displayed string is in hooks/strings.ts. The demo above is recorded with docs/media/record.sh.

Mods are an early-access Claude Code feature: the API may change between releases. Found something off? Open an issue with your /sst debug output, in English or French. 🙌 Want to send a fix? See CONTRIBUTING.md. Changes are listed in CHANGELOG.md.

📄 License

MIT

Source 21 files
hooks/register.tsx 56 lines
1import type { Register } from 'claude-code'
2import { atom, read } from 'claude-code'
3
4import { EMPTY_LIVE, emptyCounters, INITIAL_SETTINGS } from './state'
5import type { AgentCard, GitSnapshot, Receipt } from '../types'
6import { registerMinibar } from './minibar'
7import { PANE_ID, registerPane } from './pane'
8import { CONFIG_PANE_ID, registerSettingsView } from './settings-view'
9import { debugReport } from './debug'
10import { normalizeSettings } from './settings'
11import { S } from './strings'
12import { registerTracking } from './track'
13
14const liveAtom = atom({ plugin: 'session-status', key: 'live' } as const, EMPTY_LIVE)
15const countersAtom = atom({ plugin: 'session-status', key: 'counters' } as const, emptyCounters(0))
16const receiptAtom = atom({ plugin: 'session-status', key: 'receipt' } as const, null as Receipt | null)
17const gitAtom = atom({ plugin: 'session-status', key: 'git' } as const, null as GitSnapshot | null)
18const agentsAtom = atom({ plugin: 'session-status', key: 'agents' } as const, [] as AgentCard[])
19const settingsAtom = atom({ plugin: 'session-status', key: 'settings' } as const, INITIAL_SETTINGS)
20
21export const register: Register = on => {
22  registerTracking(on)
23  registerMinibar(on)
24  registerPane(on)
25  registerSettingsView(on)
26
27  on('command.run', { command: 'sst' }, async ($, e) => {
28    const args = e.args.trim()
29    if (args === 'debug') {
30      const usage = await $.session.usage({ breakdown: 'summary' })
31      const dump = debugReport(usage, {
32        live: await read($, liveAtom),
33        counters: await read($, countersAtom),
34        receipt: await read($, receiptAtom),
35        git: await read($, gitAtom),
36        settings: normalizeSettings(await read($, settingsAtom)),
37        agents: await read($, agentsAtom),
38      })
39      return { text: JSON.stringify(dump, null, 2) }
40    }
41    if (args === 'config') {
42      await $.ui.open({ id: CONFIG_PANE_ID, title: S.settingsTitle, focus: true, closeOnEscape: true })
43      return { text: S.settingsOpened }
44    }
45    // Bare /sst: the pane, closed if in view; else opened, or brought forward from behind another
46    // tab (an open id only retitles without focus).
47    const pane = (await $.ui.panes()).find(p => p.id === PANE_ID)
48    if (pane?.isShown && pane.isPlaced) {
49      await $.ui.close({ id: PANE_ID })
50      return { text: S.paneClosed }
51    }
52    await $.ui.open({ id: PANE_ID, title: S.paneTitle, ...(pane ? { focus: true } : {}) })
53    return { text: S.paneOpened }
54  })
55}
56
hooks/state.ts 12 lines
1// Initial values of the state atoms. Each file that reads or writes an atom declares it itself,
2// with a literal reference (the validator reads them where they are used):
3//   const live = atom({ plugin: 'session-status', key: 'live' } as const, EMPTY_LIVE)
4import { DEFAULT_SETTINGS } from './settings'
5import type { Counters, Live, Settings } from '../types'
6
7export const EMPTY_LIVE: Live = { model: null, effort: null, permissionMode: null, isWorking: false, requests: 0, speed: null }
8
9export const emptyCounters = (since: number): Counters => ({ since, compactions: 0, tools: {}, subagents: 0 })
10
11export const INITIAL_SETTINGS: Settings = DEFAULT_SETTINGS
12
hooks/minibar.tsx 72 lines
1// The status line under the prompt, where Claude Code draws its hint. Beside it (one row), the hint is kept
2// at the left as dim text; on a row of its own, the engine's line is kept as it draws it (mode, live pills).
3import type { On } from 'claude-code'
4import { atom, read } from 'claude-code'
5
6import { cellWidth } from './cells'
7import { minibarLine, minibarRow } from './minibar-rows'
8import { PANE_ID } from './pane'
9import { normalizeSettings } from './settings'
10import { EMPTY_LIVE, INITIAL_SETTINGS } from './state'
11import { S } from './strings'
12import type { Part } from './parts'
13import type { GitSnapshot, UsageView } from '../types'
14
15const usageAtom = atom({ plugin: 'session-status', key: 'usage' } as const, null as UsageView | null)
16const liveAtom = atom({ plugin: 'session-status', key: 'live' } as const, EMPTY_LIVE)
17const gitAtom = atom({ plugin: 'session-status', key: 'git' } as const, null as GitSnapshot | null)
18const settingsAtom = atom({ plugin: 'session-status', key: 'settings' } as const, INITIAL_SETTINGS)
19
20export const registerMinibar = (on: On): void => {
21  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
22    // The terminal only: the desktop app shows model and context on its own.
23    if (e.surface !== 'terminal') return next(e)
24    const columns = e.viewport?.columns ?? 120
25    const input = {
26      usage: await read($, usageAtom),
27      live: await read($, liveAtom),
28      git: await read($, gitAtom),
29      settings: normalizeSettings(await read($, settingsAtom)),
30      now: await $.clock.now(),
31    }
32    const { Box, Button, Text } = $.ui.resolve(e)
33    const status = (parts: Part[]) => (
34      <Box flexDirection="row">
35        <Text>
36          {parts.map(p => (
37            <Text color={p.color} bold={p.bold} dimColor={p.dim}>
38              {p.text}
39            </Text>
40          ))}
41        </Text>
42        <Text> </Text>
43        <Button key="open" label={S.openPane} plain onPress={() => $.ui.open({ id: PANE_ID, title: S.paneTitle })} />
44      </Box>
45    )
46
47    if (input.settings.hintLayout === 'below') {
48      // The engine draws its own line first, whatever its place in the tree, padded as its own: the status
49      // is the row under it, aligned with the hint.
50      const engine = await next(e)
51      const parts = minibarRow(input, columns)
52      if (parts.length === 0) return engine
53      return (
54        <Box flexDirection="column">
55          {engine}
56          {status(parts)}
57        </Box>
58      )
59    }
60
61    const hint = e.props.hint
62    const parts = minibarLine(input, cellWidth(hint), columns)
63    if (parts.length === 0) return next(e)
64    return (
65      <Box flexDirection="row" justifyContent="space-between" paddingX={2}>
66        <Text dimColor>{hint}</Text>
67        {status(parts)}
68      </Box>
69    )
70  })
71}
72
hooks/pane.tsx 96 lines
1// The side pane: docked beside the transcript in fullscreen, inline above the prompt otherwise.
2import type { On } from 'claude-code'
3import { atom, read, update } from 'claude-code'
4
5import type { Part } from './parts'
6import { toBreakdownView } from './context'
7import { paneRows } from './pane-rows'
8import { normalizeSettings } from './settings'
9import { CONFIG_PANE_ID } from './settings-view'
10import { EMPTY_LIVE, emptyCounters, INITIAL_SETTINGS } from './state'
11import { S } from './strings'
12import type { AgentCard, GitSnapshot, Receipt, UsageView } from '../types'
13
14export const PANE_ID = 'sst'
15
16const usageAtom = atom({ plugin: 'session-status', key: 'usage' } as const, null as UsageView | null)
17const liveAtom = atom({ plugin: 'session-status', key: 'live' } as const, EMPTY_LIVE)
18const countersAtom = atom({ plugin: 'session-status', key: 'counters' } as const, emptyCounters(0))
19const receiptAtom = atom({ plugin: 'session-status', key: 'receipt' } as const, null as Receipt | null)
20const gitAtom = atom({ plugin: 'session-status', key: 'git' } as const, null as GitSnapshot | null)
21const agentsAtom = atom({ plugin: 'session-status', key: 'agents' } as const, [] as AgentCard[])
22const settingsAtom = atom({ plugin: 'session-status', key: 'settings' } as const, INITIAL_SETTINGS)
23const expandedAtom = atom({ plugin: 'session-status', key: 'expanded' } as const, [] as string[])
24
25export const registerPane = (on: On): void => {
26  on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
27    const { Box, Button, Text } = $.ui.resolve(e)
28    const columns = e.props.bodyColumns
29    const rows = paneRows({
30      usage: await read($, usageAtom),
31      live: await read($, liveAtom),
32      counters: await read($, countersAtom),
33      receipt: await read($, receiptAtom),
34      git: await read($, gitAtom),
35      settings: normalizeSettings(await read($, settingsAtom)),
36      expanded: await read($, expandedAtom),
37      agents: await read($, agentsAtom),
38      now: await $.clock.now(),
39      columns,
40    })
41    const draw = (parts: Part[]) =>
42      parts.map(p => (
43        <Text color={p.color} bold={p.bold} dimColor={p.dim}>
44          {p.text}
45        </Text>
46      ))
47
48    const toggle = (key: string) => update($, expandedAtom, list => (list.includes(key) ? list.filter(k => k !== key) : [...list, key]))
49
50    const compact = async () => {
51      // A dismissed question rejects: that is a no.
52      const answer = await $.ui.ask(S.compactConfirm, [S.compactYes, S.compactNo]).catch(() => S.compactNo)
53      if (answer === S.compactYes) await $.command.run({ command: 'compact' })
54    }
55
56    // The exact count, as /context makes it: one token-count request per tool and memory file.
57    const recount = async () => {
58      const u = await $.session.usage({ breakdown: 'full', columns })
59      const b = u.context.breakdown
60      if (b) await update($, usageAtom, v => v && { ...v, tokens: u.context.tokens, breakdown: toBreakdownView(b), breakdownDetail: 'full' as const })
61    }
62
63    return (
64      <Box flexDirection="column">
65        {rows.map(row =>
66          row.kind === 'text' ? (
67            <Text>{draw(row.parts)}</Text>
68          ) : (
69            <Box flexDirection="row">
70              <Text>{draw(row.lead)}</Text>
71              {row.groups.map(g => (
72                <Button key={`g-${g.key}`} label={g.parts.map(p => p.text).join('').trim()} plain onPress={() => toggle(g.key)}>
73                  {draw(g.parts)}
74                </Button>
75              ))}
76              <Text>{draw(row.tail)}</Text>
77            </Box>
78          ),
79        )}
80        <Box flexDirection="row">
81          <Button key="compact" label={S.actionCompact} hotkey="c" onPress={compact} />
82          <Text> </Text>
83          <Button key="recount" label={S.actionRecount} hotkey="r" onPress={recount} />
84          <Text> </Text>
85          <Button
86            key="settings"
87            label={S.actionSettings}
88            hotkey="s"
89            onPress={() => $.ui.open({ id: CONFIG_PANE_ID, title: S.settingsTitle, focus: true, closeOnEscape: true })}
90          />
91        </Box>
92      </Box>
93    )
94  })
95}
96
hooks/settings-view.tsx 69 lines
1// The settings pane: which lines the card shows, in what order, how wide its bars are, and where it sits.
2import type { On } from 'claude-code'
3import { atom, read, update } from 'claude-code'
4
5import { moveLine, normalizeSettings, resizeBars, SETTINGS_KEY, settingsRows, toggleLayout, toggleLine } from './settings'
6import { INITIAL_SETTINGS } from './state'
7import { S } from './strings'
8import type { CardLine, Settings } from '../types'
9
10export const CONFIG_PANE_ID = 'sst-config'
11
12const settingsAtom = atom({ plugin: 'session-status', key: 'settings' } as const, INITIAL_SETTINGS)
13
14const LABEL: Record<CardLine, string> = {
15  model: S.lineModel,
16  context: S.lineContext,
17  five_hour: S.lineFiveHour,
18  seven_day: S.lineSevenDay,
19  cost: S.lineCost,
20  repo: S.lineRepo,
21}
22
23export const registerSettingsView = (on: On): void => {
24  on('ui.render', { component: 'Pane', requestId: CONFIG_PANE_ID }, async ($, e) => {
25    const { Box, Button, Text } = $.ui.resolve(e)
26    const current = normalizeSettings(await read($, settingsAtom))
27
28    // Drawn at once (the status line reads the same atom) and kept. The change goes to `update`
29    // itself, so two presses before a redraw both land; the store gets the latest settings.
30    const change = async (fn: (s: Settings) => Settings) => {
31      await update($, settingsAtom, s => fn(normalizeSettings(s)))
32      await $.store.set(SETTINGS_KEY, normalizeSettings(await read($, settingsAtom)))
33    }
34
35    return (
36      <Box flexDirection="column">
37        <Text bold color="claude">
38          {S.cardLines}
39        </Text>
40        {settingsRows(current).map(({ line, isOn }) => (
41          <Box key={`row-${line}`} flexDirection="row">
42            <Button key={`line-${line}`} label={`${isOn ? '[x]' : '[ ]'} ${LABEL[line]}`} plain onPress={() => change(s => toggleLine(s, line))} />
43            {isOn && (
44              <Box marginLeft={1} flexDirection="row">
45                <Button key={`up-${line}`} label="↑" plain onPress={() => change(s => moveLine(s, line, -1))} />
46                <Text> </Text>
47                <Button key={`down-${line}`} label="↓" plain onPress={() => change(s => moveLine(s, line, 1))} />
48              </Box>
49            )}
50          </Box>
51        ))}
52        <Box marginTop={1} flexDirection="row">
53          <Text>{`${S.barWidth} ${current.barWidth}  `}</Text>
54          <Button key="narrower" label="−" hotkey="n" onPress={() => change(s => resizeBars(s, -1))} />
55          <Text> </Text>
56          <Button key="wider" label="+" hotkey="w" onPress={() => change(s => resizeBars(s, 1))} />
57        </Box>
58        <Box marginTop={1} flexDirection="row">
59          <Text>{`${S.layout} ${current.hintLayout === 'below' ? S.layoutBelow : S.layoutInline}  `}</Text>
60          <Button key="layout" label={S.layoutSwitch} hotkey="l" onPress={() => change(toggleLayout)} />
61        </Box>
62        <Box marginTop={1}>
63          <Button key="done" label={S.done} role="dismiss" onPress={() => $.ui.close({ id: CONFIG_PANE_ID })} />
64        </Box>
65      </Box>
66    )
67  })
68}
69
hooks/debug.ts 61 lines
1// What `/sst debug` prints for a bug report: figures and categories only. It ends up pasted in
2// public issues, so nothing local passes: no path, server, agent, skill, repo or branch name.
3import type { SessionUsage } from 'claude-code'
4
5import type { AgentCard, Counters, GitSnapshot, Live, Receipt, Settings } from '../types'
6
7type ModState = { live: Live; counters: Counters; receipt: Receipt | null; git: GitSnapshot | null; settings: Settings; agents: AgentCard[] }
8
9/**
10 * The Claude model name inside a model id, or `other`: a configured id can be a cloud ARN or a
11 * gateway's alias, with an account id or a host in it.
12 */
13const modelName = (id: string | null) => (id === null ? null : (/claude-[a-z0-9.-]*[a-z0-9]/i.exec(id)?.[0] ?? 'other'))
14
15/** `mcp__server__tool` names a server: every MCP tool counts as `mcp`. */
16const toolKind = (name: string) => (name.startsWith('mcp__') ? 'mcp' : name)
17
18export const debugReport = (usage: SessionUsage, mod: ModState) => {
19  const b = usage.context.breakdown
20  const tools: Record<string, number> = {}
21  for (const [name, n] of Object.entries(mod.counters.tools)) tools[toolKind(name)] = (tools[toolKind(name)] ?? 0) + n
22  const g = mod.git
23  return {
24    startedAt: usage.startedAt,
25    rateLimits: usage.rateLimits.map(r => ({ kind: r.kind, percentUsed: r.percentUsed, ...(r.resetsAt ? { resetsAt: r.resetsAt } : {}) })),
26    costUsd: usage.cost?.usd,
27    context: {
28      tokens: usage.context.tokens,
29      window: usage.context.window,
30      percent: usage.context.percent,
31      categories: b?.categories.map(c => ({ name: c.name, tokens: c.tokens, kind: c.kind })),
32      memoryFiles: b?.memoryFiles.length,
33      mcpServers: b && new Set(b.mcpTools.map(t => t.serverName)).size,
34      mcpTools: b?.mcpTools.length,
35      agents: b?.agents.length,
36      skills: b?.skills?.totalSkills,
37      autoCompactThreshold: b?.autoCompactThreshold,
38      isAutoCompactEnabled: b?.isAutoCompactEnabled,
39      autocompactSource: b?.autocompactSource,
40    },
41    mod: {
42      live: { ...mod.live, model: modelName(mod.live.model) },
43      counters: { ...mod.counters, tools },
44      receipt: mod.receipt,
45      git: g && {
46        inRepo: g.branch !== null || g.repo !== null,
47        hasRemote: g.repo !== null,
48        hasBranch: g.branch !== null,
49        files: g.files,
50        added: g.added,
51        removed: g.removed,
52        ahead: g.ahead,
53        behind: g.behind,
54        sessionFiles: g.sessionFiles.length,
55      },
56      settings: mod.settings,
57      agents: { running: mod.agents.filter(a => a.state === 'running').length, finished: mod.agents.filter(a => a.state !== 'running').length },
58    },
59  }
60}
61
hooks/settings.ts 50 lines
1// The person's choices for the card, kept across sessions in $.store and read leniently.
2import type { CardLine, HintLayout, Settings } from '../types'
3
4export const CARD_LINES: readonly CardLine[] = ['model', 'context', 'five_hour', 'seven_day', 'cost', 'repo']
5
6export const DEFAULT_SETTINGS: Settings = { cardLines: ['model', 'context', 'five_hour'], barWidth: 10, hintLayout: 'inline' }
7
8const HINT_LAYOUTS: ReadonlySet<string> = new Set<HintLayout>(['inline', 'below'])
9
10const MIN_WIDTH = 6
11const MAX_WIDTH = 24
12/** The $.store key the settings live under. */
13export const SETTINGS_KEY = 'settings'
14
15/** Anything stored, or nothing, as valid settings: unknown lines dropped, width clamped, layout defaulted. */
16export const normalizeSettings = (raw: unknown): Settings => {
17  const r = (raw ?? {}) as { cardLines?: unknown; barWidth?: unknown; hintLayout?: unknown }
18  const cardLines = Array.isArray(r.cardLines)
19    ? r.cardLines.filter((l, i, all): l is CardLine => CARD_LINES.includes(l as CardLine) && all.indexOf(l) === i)
20    : [...DEFAULT_SETTINGS.cardLines]
21  const width = typeof r.barWidth === 'number' && Number.isFinite(r.barWidth) ? Math.round(r.barWidth) : DEFAULT_SETTINGS.barWidth
22  const hintLayout = typeof r.hintLayout === 'string' && HINT_LAYOUTS.has(r.hintLayout) ? (r.hintLayout as HintLayout) : DEFAULT_SETTINGS.hintLayout
23  return { cardLines, barWidth: Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, width)), hintLayout }
24}
25
26/** Every line once: those on, in the card's order, then those off. */
27export const settingsRows = (s: Settings): { line: CardLine; isOn: boolean }[] => [
28  ...s.cardLines.map(line => ({ line, isOn: true })),
29  ...CARD_LINES.filter(l => !s.cardLines.includes(l)).map(line => ({ line, isOn: false })),
30]
31
32/** Off if on; on, at the end, if off. */
33export const toggleLine = (s: Settings, line: CardLine): Settings =>
34  normalizeSettings({ ...s, cardLines: s.cardLines.includes(line) ? s.cardLines.filter(l => l !== line) : [...s.cardLines, line] })
35
36/** Swaps a line on with its neighbour above (-1) or below (1). */
37export const moveLine = (s: Settings, line: CardLine, dir: -1 | 1): Settings => {
38  const i = s.cardLines.indexOf(line)
39  const j = i + dir
40  if (i < 0 || j < 0 || j >= s.cardLines.length) return s
41  const lines = [...s.cardLines]
42  ;[lines[i], lines[j]] = [lines[j]!, lines[i]!]
43  return normalizeSettings({ ...s, cardLines: lines })
44}
45
46export const resizeBars = (s: Settings, delta: number): Settings => normalizeSettings({ ...s, barWidth: s.barWidth + delta })
47
48/** Beside the hint, or a row of its own under it. */
49export const toggleLayout = (s: Settings): Settings => normalizeSettings({ ...s, hintLayout: s.hintLayout === 'inline' ? 'below' : 'inline' })
50
hooks/strings.ts 80 lines
1// Every string the mod draws, in one place, for a later translation.
2export const S = {
3  commandDescription: 'Session status: toggle the side pane (config, debug)',
4  limit: 'limit',
5  wall: 'wall',
6  effort: 'effort',
7  ctx: 'ctx',
8  ttft: 'ttft',
9  tokPerS: 'tok/s',
10  mcp: 'MCP',
11  windowLabel: { five_hour: '5h', seven_day: '7d' } as Record<string, string>,
12  paneTitle: 'Session',
13  openPane: '◨',
14  model: 'Model',
15  context: 'Context',
16  quotas: 'Quotas',
17  session: 'Session',
18  repo: 'Repo',
19  mode: 'mode',
20  working: 'working',
21  idle: 'idle',
22  req: 'req',
23  window: 'window',
24  autoCompactOff: 'auto-compact off',
25  autoCompactIn: 'auto-compact in',
26  memoryFile: 'memory file',
27  mcpServer: 'MCP server',
28  tool: 'tool',
29  skill: 'skill',
30  agent: 'agent',
31  agentsTitle: 'Agents',
32  running: 'running',
33  out: 'out',
34  step: 'step',
35  noCalls: 'no tool calls yet',
36  resets: 'resets',
37  quotaReset: 'reset; figures with the next reply',
38  prompt: 'prompt',
39  sinceOpen: 'since open',
40  compaction: 'compaction',
41  subagent: 'subagent',
42  more: 'more',
43  turnRunning: 'turn running',
44  lastTurn: 'last turn',
45  edit: 'edit',
46  error: 'error',
47  file: 'file',
48  thisSession: 'changed this session',
49  compactConfirm: 'Compact the conversation now?',
50  compactYes: 'Compact',
51  compactNo: 'Cancel',
52  actionCompact: 'compact',
53  actionRecount: 'recount',
54  paneOpened: 'Session pane opened.',
55  paneClosed: 'Session pane closed.',
56  cardLines: 'Status line',
57  lineModel: 'Model & effort',
58  lineContext: 'Context',
59  lineFiveHour: '5h window',
60  lineSevenDay: '7d window',
61  lineCost: 'Session cost',
62  lineRepo: 'Branch & diff',
63  barWidth: 'Bar width',
64  layout: 'Layout',
65  layoutInline: "beside Claude Code's hint",
66  layoutBelow: "own row, under Claude Code's hint",
67  layoutSwitch: 'switch',
68  done: 'done',
69  settingsTitle: 'Status settings',
70  actionSettings: 'settings',
71  settingsOpened: 'Status settings opened.',
72  other: 'other',
73  memory: 'memory',
74  skills: 'skills',
75  agents: 'agents',
76  ctxPending: 'after 1st reply',
77  ctxEstimate: 'estimate until the first reply',
78  ctxFillsLater: 'fills after the first reply',
79} as const
80
hooks/track.ts 298 lines
1// Session lifecycle and events → state atoms. Every hook observes and hands back what is beneath unchanged.
2import type { EngineInterface, On } from 'claude-code'
3import { atom, read, update } from 'claude-code'
4
5import { keepCards, reconcile, spawnCard, stepCard, callCard, endCard, withCard } from './agents'
6import { toBreakdownView } from './context'
7import { makeGitReader } from './git'
8import { stepSpeed } from './session'
9import { normalizeSettings, SETTINGS_KEY } from './settings'
10import { EMPTY_LIVE, emptyCounters, INITIAL_SETTINGS } from './state'
11import { S as STR } from './strings'
12import type { AgentCard, GitSnapshot, Receipt, UsageView } from '../types'
13
14const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit', 'MultiEdit'])
15
16type Dollar = EngineInterface
17
18const usageAtom = atom({ plugin: 'session-status', key: 'usage' } as const, null as UsageView | null)
19const liveAtom = atom({ plugin: 'session-status', key: 'live' } as const, EMPTY_LIVE)
20const countersAtom = atom({ plugin: 'session-status', key: 'counters' } as const, emptyCounters(0))
21const receiptAtom = atom({ plugin: 'session-status', key: 'receipt' } as const, null as Receipt | null)
22const gitAtom = atom({ plugin: 'session-status', key: 'git' } as const, null as GitSnapshot | null)
23const agentsAtom = atom({ plugin: 'session-status', key: 'agents' } as const, [] as AgentCard[])
24const settingsAtom = atom({ plugin: 'session-status', key: 'settings' } as const, INITIAL_SETTINGS)
25
26const gitReader = makeGitReader()
27
28/** The git reader on the engine's own calls (the validator follows `$` only through this file). */
29async function readGit($: Dollar, startedAt: number): Promise<GitSnapshot> {
30  return gitReader(
31    {
32      clock: { now: () => $.clock.now() },
33      session: { cwd: () => $.session.cwd(), repo: () => $.session.repo() },
34      process: { run: (argv, init) => $.process.run(argv, init) },
35      fs: { stat: path => $.fs.stat(path) },
36    },
37    startedAt,
38  )
39}
40
41// Set by a /clear or a resume: the counts already started over, so the new session's first
42// figures must not wipe what its first turn counted.
43let isCountReset = false
44
45/** How often the drawings are redrawn while nothing else moves: countdowns, the `┊` mark. */
46const REDRAW_MS = 30_000
47let redraw: { cancel: () => void } | null = null
48/** While a turn runs, its timer in the pane counts seconds. */
49const TICK_MS = 1_000
50/** Every this many ticks, the agent list is read again: a killed agent raises no end of its own. */
51const RECONCILE_TICKS = 5
52let tick: { cancel: () => void } | null = null
53
54/** The one-second tick runs while a main turn or a subagent card runs, and rests otherwise. */
55const syncTick = async ($: Dollar) => {
56  const isBusy = (await read($, liveAtom)).isWorking || (await read($, agentsAtom)).some(c => c.state === 'running')
57  if (isBusy && !tick) {
58    let ticks = 0
59    tick = $.clock.every(TICK_MS, () => {
60      ticks += 1
61      if (ticks % RECONCILE_TICKS === 0) void reconcileAgents($).catch(() => undefined)
62      return $.ui.invalidate('ui.render')
63    })
64  }
65  else if (!isBusy && tick) {
66    tick.cancel()
67    tick = null
68  }
69}
70
71/** Ends the cards of agents the engine no longer reports running (a killed one raises no end). */
72const reconcileAgents = async ($: Dollar) => {
73  if (!(await read($, agentsAtom)).some(c => c.state === 'running')) return
74  const listed = await $.agent.list().catch(() => null)
75  if (!listed) return
76  const now = await $.clock.now()
77  await update($, agentsAtom, cs => keepCards(reconcile(cs, listed, now)))
78  await syncTick($)
79}
80
81/** Changes one card; an agent the mod holds no card for writes nothing. */
82const changeCard = async ($: Dollar, id: string, change: (c: AgentCard) => AgentCard) => {
83  if (!(await read($, agentsAtom)).some(c => c.id === id)) return
84  await update($, agentsAtom, cs => keepCards(withCard(cs, id, change)))
85}
86
87/** Re-read the session's figures; a new `startedAt` the mod did not see coming starts the counts over. */
88const refreshUsage = async ($: Dollar) => {
89  const u = await $.session.usage({ breakdown: 'summary' })
90  const prompts = await $.session.turns()
91  const before = await read($, usageAtom)
92  const b = u.context.breakdown
93  const view: UsageView = {
94    startedAt: u.startedAt,
95    tokens: u.context.tokens,
96    window: u.context.window,
97    rateLimits: u.rateLimits.map(r => ({ ...r })),
98    costUsd: u.cost?.usd,
99    prompts,
100    breakdown: b ? toBreakdownView(b) : (before?.breakdown ?? null),
101    // A full count stays until the next turn's figures replace it.
102    breakdownDetail: 'summary',
103  }
104  await update($, usageAtom, () => view)
105  // Counts older than the conversation: started over by /clear already (their `since` is the hook's
106  // clock, a little before the engine's stamp), rebased onto it; or a conversation the mod did not
107  // see start, counted afresh. Read from the counters themselves, so a second refresh is a no-op.
108  if ((await read($, countersAtom)).since < u.startedAt) {
109    if (isCountReset) await update($, countersAtom, c => ({ ...c, since: u.startedAt }))
110    else {
111      await update($, countersAtom, () => emptyCounters(u.startedAt))
112      await update($, receiptAtom, () => null)
113    }
114  }
115  if (before && before.startedAt !== u.startedAt) isCountReset = false
116  return view
117}
118
119/** Usage figures and git, read after the hook that asked has handed back. */
120const refreshLater = ($: Dollar, finish?: (view: UsageView) => Promise<void>) => {
121  $.clock.after(0, () => {
122    void (async () => {
123      const view = await refreshUsage($)
124      if (finish) await finish(view)
125      await reconcileAgents($).catch(() => undefined)
126      const snap = await readGit($, view.startedAt)
127      await update($, gitAtom, () => snap)
128    })()
129  })
130}
131
132/** Wires the session's events to the state atoms. */
133export const registerTracking = (on: On): void => {
134  // Cost when the running turn began, to tell what it added.
135  let costAtTurnStart: number | undefined
136  // Main-loop turns started, to tell a turn's late figures from the next turn's receipt.
137  let turnCount = 0
138
139  // The module's one session.start hook (an event takes one per module, and `$` never crosses an
140  // import): it registers /sst too.
141  on('session.start', async ($, e, next) => {
142    const r = await next(e)
143    // Immediate: the pane is most wanted while a turn runs; the answer only opens, closes or reads.
144    await $.command.register({ name: 'sst', description: STR.commandDescription, immediate: true })
145    const model = await $.session.model()
146    await update($, liveAtom, l => ({ ...l, model: l.model ?? model }))
147    const stored = await $.store.get(SETTINGS_KEY)
148    await update($, settingsAtom, () => normalizeSettings(stored))
149    redraw?.cancel()
150    redraw = $.clock.every(REDRAW_MS, () => $.ui.invalidate('ui.render'))
151    // A fresh process starts counting; a reloaded module keeps the counts of this session. A read
152    // refused this early is tried again just after.
153    await refreshUsage($).catch(() => undefined)
154    await syncTick($)
155    refreshLater($)
156    return r
157  })
158
159  // /clear (and a resume) starts the conversation over: so do the counts, at once.
160  on('session.end', async ($, e, next) => {
161    const r = await next(e)
162    if (e.reason !== 'clear' && e.reason !== 'resume') return r
163    const now = await $.clock.now()
164    isCountReset = true
165    await update($, countersAtom, () => emptyCounters(now))
166    await update($, receiptAtom, () => null)
167    await update($, agentsAtom, () => [])
168    await update($, liveAtom, l => ({ ...l, requests: 0, speed: null, isWorking: false }))
169    await syncTick($)
170    // The old conversation's fill and cost no longer hold; the next figures bring the new ones.
171    await update($, usageAtom, u => u && { ...u, tokens: undefined, costUsd: undefined, prompts: 0, breakdown: null })
172    // The old conversation's files are not this one's.
173    await update($, gitAtom, g => g && { ...g, sessionFiles: [] })
174    const stored = await $.store.get(SETTINGS_KEY)
175    await update($, settingsAtom, () => normalizeSettings(stored))
176    // No measure follows a /clear: read the new conversation's figures and git once the engine has it.
177    refreshLater($)
178    return r
179  })
180
181  on('session.measure', async ($, e, next) => {
182    const r = await next(e)
183    // After the measure is handed back, as every slow read.
184    $.clock.after(0, () => void refreshUsage($))
185    return r
186  })
187
188  on('turn.step', async function* ($, e, next) {
189    if (e.agentId) {
190      const agentId = e.agentId
191      const sub = yield* next(e)
192      await changeCard($, agentId, c => stepCard(c, sub.usage ?? null))
193      return sub
194    }
195    await update($, liveAtom, l => ({
196      ...l,
197      model: e.model,
198      // A model without effort sends none: the gauge goes.
199      effort: e.effort === undefined ? null : String(e.effort),
200      requests: l.requests + 1,
201    }))
202    const sentAt = await $.clock.now()
203    let firstAt: number | null = null
204    let stopAt: number | null = null
205    const s = next(e)
206    for await (const c of s) {
207      if (c.kind === 'stop') stopAt = await $.clock.now()
208      else if (firstAt === null && c.kind !== 'engine') firstAt = await $.clock.now()
209      yield c
210    }
211    const result = await s.result
212    const speed = stopAt === null ? null : stepSpeed(sentAt, firstAt, stopAt, result.usage?.output_tokens ?? null)
213    if (speed) await update($, liveAtom, l => ({ ...l, speed }))
214    return result
215  })
216
217  on('classic.UserPromptSubmit', async ($, e, next) => {
218    if (e.permission_mode) await update($, liveAtom, l => ({ ...l, permissionMode: e.permission_mode ?? null }))
219    return next(e)
220  })
221
222  on('turn.start', async ($, e, next) => {
223    const now = await $.clock.now()
224    turnCount += 1
225    costAtTurnStart = (await read($, usageAtom))?.costUsd
226    await update($, liveAtom, l => ({ ...l, isWorking: true }))
227    await update($, receiptAtom, () => ({ isRunning: true, startedAt: now, durationMs: null, agents: 0, edits: 0, errors: 0, costAddedUsd: null }))
228    await syncTick($)
229    return next(e)
230  })
231
232  on('turn.complete', async ($, e, next) => {
233    const r = await next(e)
234    const now = await $.clock.now()
235    if (e.agentId) {
236      const agentId = e.agentId
237      await changeCard($, agentId, c => endCard(c, e, now))
238      await syncTick($)
239      return r
240    }
241    await update($, liveAtom, l => ({ ...l, isWorking: false }))
242    await syncTick($)
243    await update($, receiptAtom, rc => rc && { ...rc, isRunning: false, durationMs: now - rc.startedAt })
244    // The figures and git come after the turn is handed back, never holding it.
245    const costBefore = costAtTurnStart
246    const turn = turnCount
247    refreshLater($, async view => {
248      // This turn's receipt only: the next prompt may have started its own meanwhile.
249      if (turnCount !== turn) return
250      const added = view.costUsd !== undefined && costBefore !== undefined ? view.costUsd - costBefore : null
251      await update($, receiptAtom, rc => rc && { ...rc, costAddedUsd: added })
252    })
253    return r
254  })
255
256  on('session.compact', async ($, e, next) => {
257    const r = await next(e)
258    // A precompute installs nothing, a subagent compacts its own transcript, a skip compacts nothing.
259    const isCompaction = e.trigger !== 'precompute' && !e.agentId && r.messages !== undefined
260    if (isCompaction) {
261      await update($, countersAtom, c => ({ ...c, compactions: c.compactions + 1 }))
262      // The fill and breakdown were the old transcript's; between turns no measure follows to replace them.
263      await update($, usageAtom, u => u && { ...u, tokens: undefined, breakdown: null })
264      refreshLater($)
265    }
266    return r
267  })
268
269  on('tool.call', async ($, e, next) => {
270    // A subagent's call is on its card before the tool runs: a long one shows while it works.
271    if (e.agentId) await changeCard($, e.agentId, c => callCard(c, e.tool, e))
272    const r = await next(e)
273    const isError = 'deny' in r ? true : r.isError === true
274    await update($, countersAtom, c => ({ ...c, tools: { ...c.tools, [e.tool]: (c.tools[e.tool] ?? 0) + 1 } }))
275    await update($, receiptAtom, rc =>
276      // A denied or failed edit changed no file: an error, not an edit.
277      rc?.isRunning ? { ...rc, edits: rc.edits + (EDIT_TOOLS.has(e.tool) && !isError ? 1 : 0), errors: rc.errors + (isError ? 1 : 0) } : rc,
278    )
279    return r
280  })
281
282  on('agent.spawn', async ($, e, next) => {
283    const r = await next(e)
284    // A refused spawn starts nothing.
285    if (r.deny === undefined) {
286      await update($, countersAtom, c => ({ ...c, subagents: c.subagents + 1 }))
287      await update($, receiptAtom, rc => (rc?.isRunning ? { ...rc, agents: rc.agents + 1 } : rc))
288      // A teammate lives in its own terminal pane and raises no end here: no card.
289      if (r.agentId && !e.isTeammate) {
290        const card = spawnCard(r.agentId, e.subagentType, e.description, await $.clock.now())
291        await update($, agentsAtom, cs => keepCards([...cs, card]))
292        await syncTick($)
293      }
294    }
295    return r
296  })
297}
298
hooks/cells.ts 65 lines
1// Text measured in terminal cells, as the terminal draws it: an emoji or a CJK character takes two,
2// a combining accent none. The pane's frame and the status line's budget count in these.
3
4const segmenter = typeof Intl !== 'undefined' && 'Segmenter' in Intl ? new Intl.Segmenter() : null
5
6/** User-perceived characters: an accent with its letter, a joined emoji as one. */
7const graphemes = (text: string): string[] => (segmenter ? Array.from(segmenter.segment(text), s => s.segment) : [...text])
8
9/** East Asian Wide and Fullwidth blocks (Hangul, CJK, kana, fullwidth forms). */
10const WIDE: readonly [number, number][] = [
11  [0x1100, 0x115f],
12  [0x2e80, 0x303e],
13  [0x3041, 0x33ff],
14  [0x3400, 0x4dbf],
15  [0x4e00, 0x9fff],
16  [0xa000, 0xa4cf],
17  [0xa960, 0xa97f],
18  [0xac00, 0xd7a3],
19  [0xf900, 0xfaff],
20  [0xfe10, 0xfe19],
21  [0xfe30, 0xfe6f],
22  [0xff00, 0xff60],
23  [0xffe0, 0xffe6],
24  [0x20000, 0x2fffd],
25  [0x30000, 0x3fffd],
26]
27
28const EMOJI = /\p{Emoji_Presentation}|\p{Extended_Pictographic}️/u
29const ZERO = /^[\p{Mn}\p{Me}\p{Cc}​-‏︀-️]/u
30
31const graphemeCells = (g: string): number => {
32  if (EMOJI.test(g)) return 2
33  if (ZERO.test(g)) return 0
34  const cp = g.codePointAt(0)!
35  return WIDE.some(([lo, hi]) => cp >= lo && cp <= hi) ? 2 : 1
36}
37
38export const cellWidth = (text: string): number => graphemes(text).reduce((a, g) => a + graphemeCells(g), 0)
39
40/** The longest head of `text` within `max` cells. */
41export const takeCells = (text: string, max: number): string => {
42  let used = 0
43  let out = ''
44  for (const g of graphemes(text)) {
45    const w = graphemeCells(g)
46    if (used + w > max) break
47    used += w
48    out += g
49  }
50  return out
51}
52
53/** The longest tail of `text` within `max` cells. */
54export const takeLastCells = (text: string, max: number): string => {
55  let used = 0
56  let out = ''
57  for (const g of graphemes(text).reverse()) {
58    const w = graphemeCells(g)
59    if (used + w > max) break
60    used += w
61    out = g + out
62  }
63  return out
64}
65
hooks/minibar-rows.ts 95 lines
1// The status line under the prompt: the person's items, one line, cut to the room it has.
2import { lineBar, segmentBar, burnBar } from './bars'
3import { cellWidth } from './cells'
4import { contextTone, projectionText, projectionTone, readQuota } from './burn'
5import { contextFigure, contextLabel, toSegments } from './context'
6import { fmtUsd } from './format'
7import { cellsToParts, prettyModel } from './parts'
8import { S } from './strings'
9import type { Part } from './parts'
10import type { CardLine, GitSnapshot, Live, RateLimit, Settings, UsageView } from '../types'
11
12const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max']
13const SEP: Part = { text: ' │ ', color: 'subtle' }
14
15export type MinibarInput = { usage: UsageView | null; live: Live; git: GitSnapshot | null; settings: Settings; now: number }
16
17const width = (parts: Part[]) => parts.reduce((a, p) => a + cellWidth(p.text), 0)
18
19const model = (live: Live): Part[] | null => {
20  if (!live.model) return null
21  const parts: Part[] = [{ text: prettyModel(live.model), color: 'claude', bold: true }]
22  if (!live.effort) return parts
23  const n = EFFORTS.indexOf(live.effort) + 1
24  // A level the notches don't know (a number) as its word, as the pane shows it.
25  parts.push({ text: n > 0 ? ` ${'▮'.repeat(n)}${'▯'.repeat(EFFORTS.length - n)}` : ` ${live.effort}`, color: 'claude' })
26  return parts
27}
28
29const context = (usage: UsageView, w: number): Part[] => {
30  const figure = contextFigure(usage)
31  const label = contextLabel(figure.tokens, usage.window, figure.isEstimate)
32  if (label.pct === null) return [{ text: `${S.ctx} ${S.ctxPending}`, dim: true }]
33  const tone = figure.isEstimate ? 'inactive' : contextTone(label.pct)
34  const bar = usage.breakdown
35    ? segmentBar(toSegments(usage.breakdown, figure.tokens, usage.window), usage.window, w)
36    : lineBar(label.pct / 100, w, tone)
37  return [{ text: `${S.ctx} `, dim: true }, ...cellsToParts(bar), { text: ` ${figure.isEstimate ? '≈' : ''}${label.pct}%`, color: tone }]
38}
39
40/** `5h ━━──┊──── 20% → 40% ✓`: the verdict as its glyph alone; a plain gauge without a reset time. */
41const quota = (r: RateLimit, w: number, now: number): Part[] => {
42  const label: Part = { text: `${S.windowLabel[r.kind] ?? r.kind} `, dim: true }
43  const used = `${Math.round(r.percentUsed)}%`
44  const burn = readQuota(r, now)
45  if (burn === 'stale') return [label, { text: '—', dim: true }]
46  if (!burn) {
47    const tone = contextTone(r.percentUsed)
48    return [label, ...cellsToParts(lineBar(r.percentUsed / 100, w, tone)), { text: ` ${used}`, color: tone }]
49  }
50  const glyph = burn.usedPct >= 100 ? '⛔' : burn.marginMs >= 0 ? '✓' : '⚠'
51  return [label, ...cellsToParts(burnBar(burn, w)), { text: ` ${used} ${projectionText(burn)} ${glyph}`, color: projectionTone(burn) }]
52}
53
54const item = (key: CardLine, { usage, live, git, settings, now }: MinibarInput): Part[] | null => {
55  const w = settings.barWidth
56  if (key === 'model') return model(live)
57  if (key === 'context') return usage && context(usage, w)
58  if (key === 'five_hour' || key === 'seven_day') {
59    const r = usage?.rateLimits.find(l => l.kind === key)
60    return r ? quota(r, w, now) : null
61  }
62  if (key === 'cost') return usage?.costUsd === undefined ? null : [{ text: fmtUsd(usage.costUsd), color: 'warning' }]
63  if (!git?.branch) return null
64  const branch: Part = { text: ` ${git.branch}`, color: 'ide' }
65  if (git.added === null || git.removed === null) return [branch]
66  return [branch, { text: ` +${git.added}`, color: 'diffAdded' }, { text: ` −${git.removed}`, color: 'diffRemoved' }]
67}
68
69/** The items in the person's order, separated by `│`; those past `maxCols` dropped from the right. */
70export const minibarParts = (input: MinibarInput, maxCols: number): Part[] => {
71  const out: Part[] = []
72  for (const key of input.settings.cardLines) {
73    const parts = item(key, input)
74    if (!parts) continue
75    const next = out.length ? [SEP, ...parts] : parts
76    if (width(out) + width(next) > maxCols) break
77    out.push(...next)
78  }
79  return out
80}
81
82/** Between the engine's hint and the status: never run into it. */
83const LEAD: Part = { text: '  │ ', color: 'subtle' }
84/** Columns kept for the line's padding and the open mark. */
85const RESERVED = 6
86
87/** The status as drawn after a hint `hintWidth` cells wide on a `columns`-wide row; empty when nothing fits. */
88export const minibarLine = (input: MinibarInput, hintWidth: number, columns: number): Part[] => {
89  const parts = minibarParts(input, columns - hintWidth - RESERVED - width([LEAD]))
90  return parts.length ? [LEAD, ...parts] : []
91}
92
93/** The status on a row of its own, under the engine's hint: the whole row, no lead. */
94export const minibarRow = (input: MinibarInput, columns: number): Part[] => minibarParts(input, columns - RESERVED)
95
hooks/parts.ts 33 lines
1// Coloured parts shared by the status line under the prompt and the side pane.
2import type { Cell } from './bars'
3import { S } from './strings'
4
5export type Part = { text: string; color?: string; bold?: boolean; dim?: boolean }
6
7const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max']
8
9/** `claude-opus-5-5` → `Opus 5.5`; a dated or `[1m]` id the same; a display name as it is. */
10export const prettyModel = (id: string): string => {
11  const m = id.replace(/\[.*\]$/, '').match(/^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?$/)
12  if (!m) return id
13  const family = m[1]!
14  return `${family[0]!.toUpperCase()}${family.slice(1)} ${m[2]}${m[3] ? `.${m[3]}` : ''}`
15}
16
17/** `effort ▮▮▮▯▯ high`; a level it does not know, as a word alone. */
18export const effortGauge = (effort: string): Part[] => {
19  const n = EFFORTS.indexOf(effort) + 1
20  const label: Part = { text: `${S.effort} `, dim: true }
21  if (n === 0) return [label, { text: effort, color: 'claude' }]
22  return [label, { text: '▮'.repeat(n) + '▯'.repeat(EFFORTS.length - n), color: 'claude' }, { text: ` ${effort}`, color: 'claude' }]
23}
24
25/** Cells as parts, consecutive cells of one colour merged. */
26export const cellsToParts = (cells: readonly Cell[]): Part[] =>
27  cells.reduce<Part[]>((parts, c) => {
28    const last = parts[parts.length - 1]
29    if (last && last.color === c.color) last.text += c.text
30    else parts.push({ text: c.text, color: c.color })
31    return parts
32  }, [])
33