Status band above the prompt with Claude Code session state: model and effort, uncommitted diff, context with growth and compactions, cost, rate-limit pace…

Мод для Claude Code, который показывает состояние сессии в полосе над промптом и дописывает счётчики хода в спиннер. Он отвечает за отображение и только за него: ничего не запускает, не блокирует и не отвлекает.

Документация Anthropic называет контекст главным ограничением агентной работы и прямо советует следить за ним через статус-лайн (best practices, context window). Остальное в полосе — то, что обычно узнаёшь слишком поздно:
Полоса, строка 1: ветка +строки −строки (файлы) · модель effort │ ctx бар токены/окно % прирост · compact in N · compacted ×N
Полоса, строка 2: $стоимость │ 5h % @сброс · 7d % @сброс │ cache Nm │ ⏱ длительность
Полоса, строка 3: ⧉ N agents │ ⚠ команда failed N× in a row
Спиннер: ✻ Flambéing · N tools · N edits/N files… (время и токены рисует Claude Code)
Каждый элемент появляется, только когда ему есть что сказать:
| Элемент | Что значит | Когда виден |
|---|---|---|
main +184 −37 (6) | ветка и незакоммиченные правки относительно HEAD: добавленные и удалённые строки, в скобках число изменённых и новых файлов | ветка всегда, правки при грязном дереве |
Opus5.5 xhigh | модель и effort, реально применённые к последнему запросу, уже после возможного тихого понижения | всегда |
ctx ▰▰▰▱▱ 258k/1m 26% | заполнение контекста; цвет: зелёный до 70%, жёлтый до 85%, дальше красный | всегда |
+12k | на сколько контекст вырос за последний ход (после сжатия будет минус) | после хода |
compact in 137k | сколько токенов осталось до автосжатия | в последней пятой части пути до него |
compacted ×1 | контекст уже сжимался, часть деталей потеряна: повод начать заново через /clear | после первого сжатия до /clear |
$6.46 | стоимость сессии | всегда |
5h 34% @3h11m | расход лимита и время до сброса | на подписке |
⇡ out ~1h17m | при текущем темпе лимит кончится раньше сброса; заменяет время сброса | только в этом случае, не раньше 5% окна и 10% расхода |
cache 41m / cache cold | сколько ещё тёплый prompt cache (1 час на подписке, 5 минут на API) | в простое, после первого ответа |
⏱ 25m | длительность сессии | всегда, если влезает |
⧉ 2 agents | фоновые агенты в работе | пока они есть |
⚠ npm test failed 3× in a row | один и тот же вызов падает подряд: агент, возможно, ходит по кругу | до начала следующего хода |
спиннер · 23 tools · 5 edits/3 files | что основной цикл успел сделать за текущий ход | пока Claude работает |
Ещё мод показывает всплывающие уведомления внутри Claude Code, по одному при пересечении порога: контекст 70% и 85%, лимиты 80% и 95%.

Картинки отрисованы настоящим кодом мода (hooks/format.ts) скриптом scripts/render-screens.ts. Цвета приближены к тёмной теме Claude Code; в вашем терминале цвета берутся из выбранной темы.
На узком терминале строки не переносятся. Первыми исчезают второстепенные части: бар, токены, прирост, время сброса, ⏱. Проценты и предупреждения остаются.
У мода одна ответственность: состояние сессии Claude Code. Всё, что относится к проекту или к вашему вниманию, остаётся за другими механизмами:
CLAUDE.md, хуки в .claude/settings.json проекта, /goal, CI. Мод их не запускает, не оценивает и не отображает.Нужен Claude Code 2.1.287+ в терминале или 2.1.286+ в Desktop-приложении. Node.js не нужен: мод исполняет сам Claude Code.
В сессии Claude Code:
/plugin install loopline --marketplace nikitaCodeSave/Statusline_Claude-Code
Подтвердите добавление marketplace (y), выберите scope user. Если откроется экран настроек, значение по умолчанию подходит. Мод начинает работать сразу, перезапуск не нужен.
Или из шелла:
claude plugin marketplace add nikitaCodeSave/Statusline_Claude-Code
claude plugin install loopline@nikitacodesave
Если сессия уже открыта, выполните в ней /reload-plugins. Проверка: /plugin показывает строку 1 mod active · loopline.
Раньше этот репозиторий был командным статус-лайном (statusline.js и statusline-subagent.js). Мод его заменяет; если оставить оба, информация будет дублироваться.
statusLine и subagentStatusLine из ~/.claude/settings.json.~/.claude/statusline.js и ~/.claude/statusline-subagent.js.| Параметр | По умолчанию | Что делает |
|---|---|---|
stuckThreshold | 3 | сколько раз подряд должен упасть один и тот же вызов, чтобы появилось ⚠ |
Меняется в /config или командой /plugin configure loopline@nikitacodesave.
| Где | Мод работает | Полоса видна |
|---|---|---|
claude в терминале, включая терминалы редакторов и JetBrains | да | да |
| Desktop-приложение, вкладка Code (кроме WSL-сессий) | да | да |
VS Code (панель чата), claude -p, Agent SDK, облачные сессии | да | нет: там ничего не рисуется |
Отключить: /plugin → вкладка Installed. На одну сессию все моды отключает claude --safe-mode.
Моды выполняются внутри Claude Code с вашими правами и без песочницы, поэтому стоит знать, что именно делает этот. claude plugin validate . перечисляет все вызовы мода:
git branch, git rev-parse, git status и git diff --shortstat в рабочей папке сессии;claude --plugin-dir . # загрузить мод из рабочей копии на одну сессию
claude plugin validate . # статическая проверка, как при загрузке
claude plugin test # тесты из tests/
node --experimental-strip-types scripts/render-screens.ts # перерисовать img/ (Node 22.6+, Chrome)
Чтобы рабочая копия грузилась в каждой сессии, добавьте в env файла ~/.claude/settings.json переменную CLAUDE_CODE_PLUGIN_DIRS с абсолютным путём к папке. Для Desktop-приложения нужна ещё CLAUDE_CODE_PLUGIN_DIR_WATCH=1. Это тот же --plugin-dir, только постоянный: Anthropic советует разрабатывать на папке, а не на установленной копии. Мод появится в claude plugin list как loopline@inline; правки подхватываются сами. Версию из marketplace одновременно не ставьте: два плагина с одним именем конфликтуют.
Публикация: поднимите version в .claude-plugin/plugin.json и запушьте в main. Установленные копии кэшируются по версии, без нового номера пользователи обновление не получат.
При загрузке через --plugin-dir Claude Code кладёт типы API своей версии в .claude-plugin/types/, после этого tsc -p . проверяет типы. API модов в статусе early access и меняется между версиями. Мод проверен на Claude Code 2.1.291.
MIT
hooks/register.tsx 322 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit, ToolCallInput, ToolCallResult } from 'claude-code'
3
4import {
5 bandLines,
6 callKey,
7 configuredEffort,
8 crossed,
9 editedPath,
10 fmtReset,
11 isActiveAgent,
12 limitLabel,
13 parseShortstat,
14 spinnerSuffix,
15 toUsage,
16} from './format'
17import type { Seg } from './format'
18
19const CONTEXT_MARKS = [70, 85]
20const LIMIT_MARKS = [80, 95]
21const POLL_MS = 5_000
22const GIT_STALE_MS = 15_000
23const GIT_MIN_GAP_MS = 3_000
24
25const usageAtom = atom({ plugin: 'loopline', key: 'usage' } as const, null)
26const identityAtom = atom({ plugin: 'loopline', key: 'identity' } as const, {
27 model: null,
28 effort: null,
29 branch: null,
30 dirty: 0,
31 added: 0,
32 removed: 0,
33})
34const turnAtom = atom({ plugin: 'loopline', key: 'turn' } as const, null)
35const agentsAtom = atom({ plugin: 'loopline', key: 'agents' } as const, { active: 0, total: 0 })
36const warningAtom = atom({ plugin: 'loopline', key: 'warning' } as const, null)
37const ctxDeltaAtom = atom({ plugin: 'loopline', key: 'ctxDelta' } as const, null)
38const cacheAtom = atom({ plugin: 'loopline', key: 'cacheAt' } as const, null)
39const compactionsAtom = atom({ plugin: 'loopline', key: 'compactions' } as const, 0)
40const compactAtAtom = atom({ plugin: 'loopline', key: 'compactAt' } as const, null)
41
42// Bookkeeping no drawing reads: a reload starts it over, which costs at most a repeated alert.
43let turnFiles = new Set<string>()
44const failStreaks = new Map<string, number>()
45const warnedKeys = new Set<string>()
46const limitSeen = new Map<string, number>()
47let ctxSeen: number | undefined
48let gitCheckedAt = 0
49let modelSeen = ''
50
51type Settings = { stuckThreshold: number }
52
53export const register: Register = (on, options) => {
54 const settings: Settings = {
55 stuckThreshold: Math.max(2, Number(options.stuckThreshold ?? 3)),
56 }
57
58 on('session.start', async ($, e, next) => {
59 await refreshAll($)
60 $.clock.every(POLL_MS, () => void poll($))
61 return next(e)
62 })
63
64 // /clear, /resume and /branch reset $.state without a new session.start.
65 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
66 resetBookkeeping()
67 await refreshAll($)
68 if (typeof e.seconds_since_last_response === 'number') {
69 const at = (await $.clock.now()) - e.seconds_since_last_response * 1000
70 await update($, cacheAtom, () => at)
71 }
72 return next(e)
73 })
74
75 on('classic.PostCompact', async ($, e, next) => {
76 await update($, compactionsAtom, n => n + 1)
77 return next(e)
78 })
79
80 on('turn.start', async ($, e, next) => {
81 const now = await $.clock.now()
82 const { context } = await $.session.usage()
83 turnFiles = new Set()
84 failStreaks.clear()
85 warnedKeys.clear()
86 await update($, turnAtom, () => ({
87 id: e.turnId,
88 startedAt: now,
89 tools: 0,
90 edits: 0,
91 files: 0,
92 ...(context.tokens !== undefined ? { ctxAtStart: context.tokens } : {}),
93 }))
94 await update($, warningAtom, () => null)
95 return next(e)
96 })
97
98 // Each model request carries the model and the effort actually used, after any downgrade;
99 // its response restarts the prompt cache's clock.
100 on('turn.step', async function* ($, e, next) {
101 const effort = e.effort === undefined ? null : String(e.effort)
102 const seen = `${e.model}|${effort}`
103 if (e.agentId === undefined && seen !== modelSeen) {
104 modelSeen = seen
105 await update($, identityAtom, id => ({ ...id, model: e.model, effort }))
106 }
107 const result = yield* next(e)
108 if (e.agentId === undefined) {
109 const now = await $.clock.now()
110 await update($, cacheAtom, () => now)
111 }
112 return result
113 })
114
115 on('tool.call', async ($, e, next) => {
116 const ran = await next(e)
117 await recordCall($, e, ran, settings)
118 return ran
119 }).catch(($, e, next) => next(e))
120
121 on('turn.complete', async ($, e, next) => {
122 const done = await next(e)
123 if (e.agentId === undefined) await finishTurn($)
124 return done
125 })
126
127 on('session.measure', async ($, e, next) => {
128 const prev = await read($, usageAtom)
129 const startedAt = prev?.startedAt ?? (await $.session.usage()).startedAt
130 await update($, usageAtom, () => toUsage(startedAt, e.context, e.rateLimits, e.cost))
131 await alertThresholds($, e.context.percent, e.rateLimits)
132 return next(e)
133 })
134
135 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
136 if (e.props.hasSurvey) return next(e)
137 const lines = bandLines({
138 usage: await read($, usageAtom),
139 identity: await read($, identityAtom),
140 turn: await read($, turnAtom),
141 ctxDelta: await read($, ctxDeltaAtom),
142 cacheAt: await read($, cacheAtom),
143 compactions: await read($, compactionsAtom),
144 compactAt: await read($, compactAtAtom),
145 agents: await read($, agentsAtom),
146 warning: await read($, warningAtom),
147 now: await $.clock.now(),
148 width: e.props.bodyColumns,
149 })
150 const theirs = await next(e)
151 const { Box, Text } = $.ui.resolve(e)
152 return (
153 <Box flexDirection="column">
154 {theirs}
155 {lines.map(line => (
156 <Text wrap="truncate-end">
157 {line.map(seg => <Text {...segProps(seg)}>{seg.text}</Text>)}
158 </Text>
159 ))}
160 </Box>
161 )
162 })
163
164 // The spinner already shows the turn's time and tokens; it gains what the turn did.
165 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
166 const suffix = spinnerSuffix(await read($, turnAtom))
167 if (suffix === null) return next(e)
168 const agents = await $.agent.list()
169 if (agents.some(a => a.id === e.requestId)) return next(e)
170 return next({ ...e, props: { ...e.props, suffix: suffix + e.props.suffix } })
171 })
172}
173
174function segProps(seg: Seg): { color?: string; dimColor?: true; bold?: true } {
175 return {
176 ...(seg.color !== undefined ? { color: seg.color } : {}),
177 ...(seg.dim === true ? { dimColor: true as const } : {}),
178 ...(seg.bold === true ? { bold: true as const } : {}),
179 }
180}
181
182function resetBookkeeping(): void {
183 turnFiles = new Set()
184 failStreaks.clear()
185 warnedKeys.clear()
186 limitSeen.clear()
187 ctxSeen = undefined
188 modelSeen = ''
189}
190
191async function refreshAll($: EngineInterface): Promise<void> {
192 const usage = await $.session.usage()
193 await update($, usageAtom, () => toUsage(usage.startedAt, usage.context, usage.rateLimits, usage.cost))
194 const model = await $.session.model()
195 const configured = await $.settings.read()
196 const effort = configuredEffort(configured, model)
197 await update($, identityAtom, id => ({ ...id, model: id.model ?? model, effort: id.effort ?? effort }))
198 await refreshCompactAt($)
199 await refreshGit($, await $.clock.now())
200 await refreshAgents($)
201}
202
203/** Where auto-compaction runs: from the local /context estimate, which sends no request. */
204async function refreshCompactAt($: EngineInterface): Promise<void> {
205 const { breakdown } = (await $.session.usage({ breakdown: 'summary' })).context
206 const at = breakdown?.isAutoCompactEnabled === true ? breakdown.autoCompactThreshold ?? null : null
207 await update($, compactAtAtom, () => at)
208}
209
210async function poll($: EngineInterface): Promise<void> {
211 await refreshAgents($)
212 const now = await $.clock.now()
213 if (now - gitCheckedAt >= GIT_STALE_MS) await refreshGit($, now)
214 // Nothing else redraws on time alone: keep the cache countdown and the session clock current.
215 $.ui.invalidate('ui.render')
216}
217
218async function refreshGit($: EngineInterface, now: number): Promise<void> {
219 gitCheckedAt = now
220 const current = await $.process.run(['git', 'branch', '--show-current'], { timeoutMs: 5_000 }).catch(() => null)
221 if (current === null || current.exitCode !== 0) {
222 await update($, identityAtom, id => ({ ...id, branch: null, dirty: 0, added: 0, removed: 0 }))
223 return
224 }
225 let branch = current.stdout.trim()
226 if (branch === '') {
227 const head = await $.process.run(['git', 'rev-parse', '--short', 'HEAD'], { timeoutMs: 5_000 }).catch(() => null)
228 branch = head?.stdout.trim() || 'HEAD'
229 }
230 const status = await $.process.run(['git', 'status', '--porcelain'], { timeoutMs: 5_000 }).catch(() => null)
231 const dirty = status === null ? 0 : status.stdout.split('\n').filter(Boolean).length
232 const stat = dirty === 0
233 ? null
234 : await $.process.run(['git', 'diff', 'HEAD', '--shortstat'], { timeoutMs: 5_000 }).catch(() => null)
235 const { added, removed } = parseShortstat(stat !== null && stat.exitCode === 0 ? stat.stdout : '')
236 await update($, identityAtom, id => ({ ...id, branch, dirty, added, removed }))
237}
238
239async function refreshAgents($: EngineInterface): Promise<void> {
240 const list = await $.agent.list()
241 const active = list.filter(a => isActiveAgent(a.status)).length
242 const prev = await read($, agentsAtom)
243 if (prev.active !== active || prev.total !== list.length) {
244 await update($, agentsAtom, () => ({ active, total: list.length }))
245 }
246}
247
248async function recordCall($: EngineInterface, e: ToolCallInput, ran: ToolCallResult, settings: Settings): Promise<void> {
249 if (ran.deny !== undefined) return
250 const input = e as unknown as Readonly<Record<string, unknown>>
251 const isFailed = ran.isError === true
252 const path = isFailed ? null : editedPath(e.tool, input)
253
254 // Any loop's edit or shell command may move the working tree.
255 if (path !== null || e.tool === 'Bash') {
256 const now = await $.clock.now()
257 if (now - gitCheckedAt >= GIT_MIN_GAP_MS) await refreshGit($, now)
258 }
259 if (e.agentId !== undefined) return
260
261 if ((await read($, turnAtom)) !== null) {
262 if (path !== null) turnFiles.add(path)
263 const files = turnFiles.size
264 await update($, turnAtom, t => (t === null ? t : {
265 ...t,
266 tools: t.tools + 1,
267 edits: t.edits + (path !== null ? 1 : 0),
268 files,
269 }))
270 }
271
272 const { key, label } = callKey(e.tool, input)
273 if (!isFailed) {
274 failStreaks.delete(key)
275 } else {
276 const streak = (failStreaks.get(key) ?? 0) + 1
277 failStreaks.set(key, streak)
278 if (streak >= settings.stuckThreshold && !warnedKeys.has(key)) {
279 warnedKeys.add(key)
280 const text = `${label} failed ${streak}× in a row`
281 await update($, warningAtom, () => text)
282 $.ui.toast(`loopline: ${text}. Is the agent stuck?`, { timeoutMs: 8_000 })
283 }
284 }
285 if (e.tool === 'Agent' || String(e.tool) === 'Task') await refreshAgents($)
286}
287
288async function finishTurn($: EngineInterface): Promise<void> {
289 const turn = await read($, turnAtom)
290 const usage = await $.session.usage()
291 const tokens = usage.context.tokens
292 const start = turn?.ctxAtStart
293 await update($, ctxDeltaAtom, () => (start !== undefined && tokens !== undefined ? tokens - start : null))
294 await update($, turnAtom, () => null)
295 await update($, usageAtom, () => toUsage(usage.startedAt, usage.context, usage.rateLimits, usage.cost))
296 const now = await $.clock.now()
297 await update($, cacheAtom, () => now)
298 await refreshCompactAt($)
299 await refreshGit($, now)
300 await refreshAgents($)
301}
302
303async function alertThresholds(
304 $: EngineInterface,
305 ctxPercent: number | undefined,
306 limits: readonly SessionRateLimit[],
307): Promise<void> {
308 if (ctxPercent !== undefined) {
309 const mark = crossed(ctxSeen ?? 0, ctxPercent, CONTEXT_MARKS)
310 ctxSeen = ctxPercent
311 if (mark !== null) $.ui.toast(`loopline: context is ${Math.round(ctxPercent)}% full. Consider /compact.`, { timeoutMs: 8_000 })
312 }
313 for (const limit of limits) {
314 const mark = crossed(limitSeen.get(limit.kind) ?? 0, limit.percentUsed, LIMIT_MARKS)
315 limitSeen.set(limit.kind, limit.percentUsed)
316 if (mark === null) continue
317 const reset = fmtReset(limit.resetsAt, await $.clock.now())
318 const text = `${limitLabel(limit.kind)} limit at ${Math.round(limit.percentUsed)}%${reset === null ? '' : `, resets in ${reset}`}`
319 $.ui.toast(`loopline: ${text}`, { timeoutMs: 8_000 })
320 }
321}
322hooks/format.ts 338 lines1// Pure helpers: no `$` here, so tests reach them directly.
2
3import type { SessionContextUsage, SessionCost, SessionRateLimit } from 'claude-code'
4
5import type {
6 LooplineAgents,
7 LooplineIdentity,
8 LooplineLimit,
9 LooplineTurn,
10 LooplineUsage,
11} from '../types'
12
13/** One styled run of text; `optional` runs are dropped first when a line is too wide. */
14export type Seg = {
15 text: string
16 color?: string
17 dim?: boolean
18 bold?: boolean
19 optional?: boolean
20}
21
22export type BandView = {
23 usage: LooplineUsage | null
24 identity: LooplineIdentity
25 turn: LooplineTurn | null
26 ctxDelta: number | null
27 cacheAt: number | null
28 compactions: number
29 compactAt: number | null
30 agents: LooplineAgents
31 warning: string | null
32 now: number
33 width: number
34}
35
36const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit', 'MultiEdit'])
37const ACTIVE_AGENT = new Set(['pending', 'running', 'waiting'])
38const WINDOW_MS: Readonly<Record<string, number>> = { five_hour: 5 * 3_600_000, seven_day: 7 * 86_400_000 }
39const HOUR = 3_600_000
40const SEP: Seg = { text: ' │ ', dim: true }
41const DOT: Seg = { text: ' · ', dim: true }
42
43export function tone(pct: number): 'success' | 'warning' | 'error' {
44 if (pct < 70) return 'success'
45 return pct < 85 ? 'warning' : 'error'
46}
47
48export function plural(n: number, word: string): string {
49 return `${n} ${word}${n === 1 ? '' : 's'}`
50}
51
52export function fmtTokens(n: number): string {
53 if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\.0$/, '') + 'm'
54 return n >= 1000 ? Math.round(n / 1000) + 'k' : String(n)
55}
56
57/** +12k, −80k; null under a thousand either way. */
58export function fmtDelta(n: number | null): string | null {
59 if (n === null || Math.abs(n) < 1000) return null
60 return (n > 0 ? '+' : '−') + fmtTokens(Math.abs(n))
61}
62
63/** <1m, 12m, 1h02m: a session's length at the grain the band redraws in. */
64export function fmtDuration(ms: number): string {
65 const m = Math.floor(Math.max(0, ms) / 60_000)
66 if (m < 1) return '<1m'
67 if (m < 60) return `${m}m`
68 return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
69}
70
71export function fmtReset(iso: string | undefined, now: number): string | null {
72 if (iso === undefined) return null
73 const diff = Date.parse(iso) - now
74 if (!Number.isFinite(diff)) return null
75 return diff <= 0 ? 'now' : fmtSpan(diff)
76}
77
78function fmtSpan(ms: number): string {
79 const d = Math.floor(ms / 86_400_000)
80 const h = Math.floor((ms % 86_400_000) / HOUR)
81 const m = Math.floor((ms % HOUR) / 60_000)
82 if (d > 0) return `${d}d${h}h`
83 return h > 0 ? `${h}h${m}m` : `${m}m`
84}
85
86export function fmtUsd(usd: number): string {
87 return '$' + usd.toFixed(2)
88}
89
90export function bar(pct: number, cells = 10): string {
91 const filled = Math.max(0, Math.min(cells, Math.round((pct / 100) * cells)))
92 return '▰'.repeat(filled) + '▱'.repeat(cells - filled)
93}
94
95/** claude-opus-5-5[1m] -> Opus5.5(1m); claude-haiku-4-5-20251001 -> Haiku4.5; display names pass through. */
96export function shortModel(id: string | null): string {
97 if (id === null || id === '') return 'Claude'
98 if (!id.startsWith('claude-')) return id
99 const isWide = /\[1m\]/.test(id)
100 const parts = id.replace(/\[[^\]]*\]$/, '').replace(/^claude-/, '').replace(/-\d{8}$/, '').split('-').filter(Boolean)
101 const [family = 'Claude', ...version] = parts
102 return family.charAt(0).toUpperCase() + family.slice(1) + version.join('.') + (isWide ? '(1m)' : '')
103}
104
105/**
106 * The effort the settings pin for this model: its `modelSettings` entry (keyed by the canonical
107 * name, which also stands for the dated and `[1m]` spellings), else the top-level `effortLevel`.
108 */
109export function configuredEffort(settings: Readonly<Record<string, unknown>>, model: string): string | null {
110 const perModel = asRecord(settings.modelSettings)
111 const canonical = model.replace(/\[[^\]]*\]$/, '').replace(/-\d{8}$/, '')
112 const level = asRecord(perModel?.[canonical] ?? perModel?.[model])?.effortLevel
113 if (typeof level === 'string') return level
114 return typeof settings.effortLevel === 'string' ? settings.effortLevel : null
115}
116
117function asRecord(value: unknown): Readonly<Record<string, unknown>> | undefined {
118 return value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : undefined
119}
120
121export function limitLabel(kind: string): string {
122 if (kind === 'five_hour') return '5h'
123 if (kind === 'seven_day') return '7d'
124 if (kind === 'spend_limit') return 'spend'
125 return kind
126}
127
128/**
129 * How long until the window runs out at the pace it has been used so far, or null when
130 * it lasts to its reset. Quiet early in a window and under 10% used, where one burst
131 * would extrapolate wildly.
132 */
133export function exhaustIn(limit: LooplineLimit, now: number): number | null {
134 const span = WINDOW_MS[limit.kind]
135 if (span === undefined || limit.resetsAt === undefined) return null
136 const resetAt = Date.parse(limit.resetsAt)
137 const elapsed = span - (resetAt - now)
138 if (!Number.isFinite(resetAt) || elapsed < span * 0.05 || limit.percentUsed < 10 || limit.percentUsed >= 100) return null
139 const left = ((100 - limit.percentUsed) * elapsed) / limit.percentUsed
140 return now + left < resetAt - 60_000 ? left : null
141}
142
143/** The prompt cache lives an hour on a subscription (which reports plan limits) and five minutes on the API. */
144export function cacheTtlMs(limits: readonly LooplineLimit[]): number {
145 return limits.some(l => l.kind === 'five_hour' || l.kind === 'seven_day') ? HOUR : 5 * 60_000
146}
147
148export function isActiveAgent(status: string): boolean {
149 return ACTIVE_AGENT.has(status)
150}
151
152/** The highest mark the value climbed past since the previous reading, or null. */
153export function crossed(prev: number, next: number, marks: readonly number[]): number | null {
154 let hit: number | null = null
155 for (const mark of marks) if (prev < mark && next >= mark) hit = mark
156 return hit
157}
158
159/** `git diff --shortstat` -> lines added and removed. */
160export function parseShortstat(text: string): { added: number; removed: number } {
161 const added = /(\d+) insertions?\(\+\)/.exec(text)?.[1]
162 const removed = /(\d+) deletions?\(-\)/.exec(text)?.[1]
163 return { added: Number(added ?? 0), removed: Number(removed ?? 0) }
164}
165
166export function toUsage(
167 startedAt: number,
168 context: SessionContextUsage,
169 rateLimits: readonly SessionRateLimit[],
170 cost: SessionCost | undefined,
171): LooplineUsage {
172 const usage: LooplineUsage = {
173 startedAt,
174 ctxWindow: context.window,
175 limits: rateLimits.map(l => (l.resetsAt === undefined
176 ? { kind: l.kind, percentUsed: l.percentUsed }
177 : { kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt })),
178 }
179 if (context.tokens !== undefined) usage.ctxTokens = context.tokens
180 if (context.percent !== undefined) usage.ctxPercent = context.percent
181 if (cost !== undefined) usage.usd = cost.usd
182 return usage
183}
184
185/** The file a successful edit touched, or null for any other tool. */
186export function editedPath(tool: string, input: Readonly<Record<string, unknown>>): string | null {
187 if (!EDIT_TOOLS.has(tool)) return null
188 const path = input.file_path ?? input.notebook_path
189 return typeof path === 'string' ? path : null
190}
191
192/** Identity of a call for the stuck detector, and a short label to show for it. */
193export function callKey(tool: string, input: Readonly<Record<string, unknown>>): { key: string; label: string } {
194 if (typeof input.command === 'string') {
195 const command = input.command.trim().replace(/\s+/g, ' ')
196 return { key: `${tool} ${command}`, label: clip(command, 48) }
197 }
198 const target = input.file_path ?? input.notebook_path ?? input.path ?? input.url ?? input.pattern
199 if (typeof target === 'string') return { key: `${tool} ${target}`, label: clip(`${tool} ${target}`, 48) }
200 const { tool_use_id: _id, agentId: _agent, tool: _tool, ...rest } = input
201 return { key: `${tool} ${clip(JSON.stringify(rest), 400)}`, label: tool }
202}
203
204export function clip(text: string, max: number): string {
205 const chars = [...text]
206 return chars.length <= max ? text : chars.slice(0, max - 1).join('') + '…'
207}
208
209function width(segs: readonly Seg[]): number {
210 return segs.reduce((sum, s) => sum + [...s.text].length, 0)
211}
212
213/** Drops optional runs from the end until the line fits; the surface truncates whatever is still too wide. */
214export function fit(segs: readonly Seg[], columns: number): Seg[] {
215 const out = [...segs]
216 for (let i = out.length - 1; i >= 0 && width(out) > columns; i--) {
217 if (out[i]?.optional === true) out.splice(i, 1)
218 }
219 return out
220}
221
222/** Joins the non-empty groups; a wholly optional group takes its separator with it when dropped. */
223function joinGroups(groups: readonly (readonly Seg[])[], sep: Seg): Seg[] {
224 const out: Seg[] = []
225 for (const group of groups) {
226 if (group.length === 0) continue
227 if (out.length > 0) out.push(group.every(s => s.optional === true) ? { ...sep, optional: true } : sep)
228 out.push(...group)
229 }
230 return out
231}
232
233function identityLine(v: BandView): Seg[] {
234 const { identity, usage } = v
235 const who: Seg[][] = []
236 if (identity.branch !== null) {
237 const diff: Seg[] = []
238 if (identity.dirty > 0) {
239 if (identity.added > 0) diff.push({ text: ` +${identity.added}`, color: 'success' })
240 if (identity.removed > 0) diff.push({ text: ` −${identity.removed}`, color: 'error' })
241 diff.push({ text: ` (${identity.dirty})`, dim: true })
242 }
243 who.push([{ text: identity.branch, color: 'merged' }, ...diff])
244 }
245 who.push([
246 { text: shortModel(identity.model), color: 'suggestion', bold: true },
247 ...(identity.effort !== null ? [{ text: ` ${identity.effort}`, dim: true }] : []),
248 ])
249
250 const ctx: Seg[] = [{ text: 'ctx ', dim: true }]
251 if (usage === null) {
252 ctx.push({ text: '—', dim: true })
253 } else {
254 const pct = usage.ctxPercent ?? 0
255 const color = tone(pct)
256 const delta = fmtDelta(v.ctxDelta)
257 ctx.push(
258 { text: bar(pct) + ' ', color, optional: true },
259 { text: `${usage.ctxTokens === undefined ? '—' : fmtTokens(usage.ctxTokens)}/${fmtTokens(usage.ctxWindow)} `, color, optional: true },
260 { text: `${usage.ctxPercent === undefined ? '—' : Math.round(pct)}%`, color, bold: true },
261 ...(delta !== null ? [{ text: ` ${delta}`, dim: true, optional: true }] : []),
262 )
263 }
264 const left = compactLeft(usage, v.compactAt)
265 if (left !== null) {
266 ctx.push({ text: left > 0 ? ` · compact in ${fmtTokens(left)}` : ' · compact due', color: 'warning' })
267 }
268 // What a compaction dropped is gone for good: the cue to start afresh with /clear and a plan.
269 if (v.compactions > 0) {
270 ctx.push({ text: ` · compacted ×${v.compactions}`, color: 'warning' })
271 }
272 return [...joinGroups(who, DOT), SEP, ...ctx]
273}
274
275/** Tokens left before auto-compaction runs, once fewer than a fifth of them are; null otherwise. */
276export function compactLeft(usage: LooplineUsage | null, compactAt: number | null): number | null {
277 if (usage === null || usage.ctxTokens === undefined || compactAt === null) return null
278 const left = compactAt - usage.ctxTokens
279 return left <= compactAt * 0.2 ? left : null
280}
281
282/** The spinner's suffix while the main loop works: what it has done so far this turn. */
283export function spinnerSuffix(turn: LooplineTurn | null): string | null {
284 if (turn === null || turn.tools === 0) return null
285 const edits = turn.edits > 0 ? ` · ${plural(turn.edits, 'edit')}/${plural(turn.files, 'file')}` : ''
286 return ` · ${plural(turn.tools, 'tool')}${edits}`
287}
288
289function limitSegs(limit: LooplineLimit, now: number): Seg[] {
290 const segs: Seg[] = [
291 { text: `${limitLabel(limit.kind)} ` },
292 { text: `${Math.round(limit.percentUsed)}%`, color: tone(limit.percentUsed) },
293 ]
294 const out = exhaustIn(limit, now)
295 if (out !== null) {
296 segs.push({ text: ` ⇡ out ~${fmtSpan(out)}`, color: out < 30 * 60_000 ? 'error' : 'warning' })
297 } else {
298 const reset = fmtReset(limit.resetsAt, now)
299 if (reset !== null) segs.push({ text: ` @${reset}`, dim: true, optional: true })
300 }
301 return segs
302}
303
304function cacheSegs(v: BandView): Seg[] {
305 if (v.cacheAt === null || v.turn !== null || v.usage === null) return []
306 const ttl = cacheTtlMs(v.usage.limits)
307 const left = v.cacheAt + ttl - v.now
308 if (left <= 0) return [{ text: 'cache cold', color: 'warning' }]
309 const text = `cache ${Math.ceil(left / 60_000)}m`
310 return [left < ttl * 0.2 ? { text, color: 'warning' } : { text, dim: true }]
311}
312
313function spendLine(v: BandView): Seg[] {
314 const { usage, now } = v
315 if (usage === null) return []
316 const usd = usage.usd ?? 0
317 return joinGroups([
318 [{ text: fmtUsd(usd), ...(usd > 0 ? { color: 'warning' } : { dim: true }) }],
319 joinGroups(usage.limits.map(l => limitSegs(l, now)), DOT),
320 cacheSegs(v),
321 [{ text: `⏱ ${fmtDuration(now - usage.startedAt)}`, optional: true }],
322 ], SEP)
323}
324
325function loopLine(v: BandView): Seg[] {
326 const groups: Seg[][] = []
327 if (v.agents.active > 0) groups.push([{ text: `⧉ ${plural(v.agents.active, 'agent')}`, color: 'suggestion' }])
328 if (v.warning !== null) groups.push([{ text: `⚠ ${v.warning}`, color: 'warning' }])
329 return joinGroups(groups, SEP)
330}
331
332/** The band's lines, each fitted to the band's width; an empty line is left out. */
333export function bandLines(v: BandView): Seg[][] {
334 return [identityLine(v), spendLine(v), loopLine(v)]
335 .filter(line => line.length > 0)
336 .map(line => fit(line, v.width))
337}
338types/index.d.ts 63 lines1// loopline's $.state contract: every value the band draws from.
2
3export type LooplineLimit = {
4 kind: string
5 percentUsed: number
6 resetsAt?: string
7}
8
9export type LooplineUsage = {
10 startedAt: number
11 ctxTokens?: number
12 ctxWindow: number
13 ctxPercent?: number
14 usd?: number
15 limits: LooplineLimit[]
16}
17
18export type LooplineIdentity = {
19 model: string | null
20 effort: string | null
21 branch: string | null
22 /** Changed and untracked files in the working tree. */
23 dirty: number
24 /** Lines added and removed against HEAD. */
25 added: number
26 removed: number
27}
28
29/** The main loop's turn while it runs. */
30export type LooplineTurn = {
31 id: string
32 startedAt: number
33 tools: number
34 edits: number
35 files: number
36 ctxAtStart?: number
37}
38
39export type LooplineAgents = {
40 active: number
41 total: number
42}
43
44declare module 'claude-code' {
45 interface PluginState {
46 loopline: {
47 usage: LooplineUsage | null
48 identity: LooplineIdentity
49 turn: LooplineTurn | null
50 agents: LooplineAgents
51 warning: string | null
52 /** Context tokens the last main-loop turn added (negative after a compaction). */
53 ctxDelta: number | null
54 /** When the main loop last got a model response: the prompt cache's clock. */
55 cacheAt: number | null
56 /** Compactions since the context began (a /clear starts it over). */
57 compactions: number
58 /** The context tokens at which auto-compaction runs; null when it is off. */
59 compactAt: number | null
60 }
61 }
62}
63