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

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.
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
statusLineentry from~/.claude/settings.json. The mod never touches your settings itself.
One line under the prompt, right where Claude Code draws its hint (which stays on the left):

▮▮▮▯▯/context) and how full the window is. Before the first reply, you get Claude Code's own estimate, marked ≈.┊ 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.
/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">
| Box | What's inside |
|---|---|
| 🧠 Model | model, effort, permission mode, working/idle, requests, time to first token and tokens per second of the last request, context window |
| 📦 Context | the bar and its four biggest categories (the rest as other), room left before auto-compaction, unfoldable lists of memory files, MCP servers and agents |
| 🔥 Quotas | every 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 |
| 💸 Session | cost, per hour and per prompt, duration, prompts, compactions, tool calls by tool, subagents, and a receipt of the last turn |
| 🤖 Agents | a 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 |
| 🌿 Repo | folder, 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.
| Command | Does |
|---|---|
/sst | open or close the side pane |
/sst config | choose 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 debug | print 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.
We'd rather show nothing than a made-up number. Here's how each figure comes about:
/context), until the next turn brings the estimate back. The total and percentage are always the real ones.~ and drawn dim, because it's too soon to tell.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.
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.
hooks/register.tsx 56 lines1import 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}
56hooks/state.ts 12 lines1// 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
12hooks/minibar.tsx 72 lines1// 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}
72hooks/pane.tsx 96 lines1// 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}
96hooks/settings-view.tsx 69 lines1// 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}
69hooks/debug.ts 61 lines1// 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}
61hooks/settings.ts 50 lines1// 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' })
50hooks/strings.ts 80 lines1// 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
80hooks/track.ts 298 lines1// 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}
298hooks/cells.ts 65 lines1// 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}
65hooks/minibar-rows.ts 95 lines1// 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)
95hooks/parts.ts 33 lines1// 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