SLOPSHOPPER

loopline

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

newbandspinnerguardtoastprocess
★ 1v1.0.1MITupdated 2026-10-06nikitaCodeSave/Statusline_Claude-Code
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · loopline
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ⟨Claude Code's own drawing⟩ feat/auth-refresh +14 −3 (2) · Opus5.5 │ ctx ▰▰▰▰▰▱▱▱▱▱ 97k/200k 49% $0.42 │ 5h 31% │ cache 60m │ ⏱ 30m ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ feat/auth-refresh +14 −3 (2) · Opus5.5 │ ctx ▰▰▰▰▰▱▱▱▱▱ 97k/200k 49% $0.42 │ 5h 31% │ cache 60m │ ⏱ 30m
README

loopline: статус сессии Claude Code

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

loopline в терминале

Зачем

Документация Anthropic называет контекст главным ограничением агентной работы и прямо советует следить за ним через статус-лайн (best practices, context window). Остальное в полосе — то, что обычно узнаёшь слишком поздно:

  • контекст растёт с каждым ходом, а после сжатия часть его теряется навсегда;
  • prompt cache остывает в перерыве, и следующий запрос заново записывает весь контекст;
  • лимиты подписки кончаются раньше сброса, если тратить их быстрее среднего;
  • незакоммиченные правки копятся, хотя маленькие порции и частые коммиты проще ревьюить.

Что показывает

Полоса, строка 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. Мод их не запускает, не оценивает и не отображает.
  • Запреты опасных команд задаются permission rules и защитой веток.
  • Desktop-уведомлений, кнопок и панелей нет: полоса только сообщает.

Установка

Нужен 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

Раньше этот репозиторий был командным статус-лайном (statusline.js и statusline-subagent.js). Мод его заменяет; если оставить оба, информация будет дублироваться.

  1. Удалите ключи statusLine и subagentStatusLine из ~/.claude/settings.json.
  2. Удалите ~/.claude/statusline.js и ~/.claude/statusline-subagent.js.

Настройка

ПараметрПо умолчаниюЧто делает
stuckThreshold3сколько раз подряд должен упасть один и тот же вызов, чтобы появилось ⚠

Меняется в /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 . перечисляет все вызовы мода:

  • из Claude Code читает модель, настройку effort, заполнение контекста, стоимость, лимиты, порог автосжатия и список агентов;
  • внешние процессы только 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

Source 3 files
hooks/register.tsx 322 lines
1import { 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}
322
hooks/format.ts 338 lines
1// 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}
338
types/index.d.ts 63 lines
1// 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