Live status under Claude Code's prompt, and receipts on the turns themselves: context jumps, cache rebuilds and tool failures marked on the turn's own closing…

Live status. Receipts on the turns.
g-line is a Claude Code mod that puts context jumps, cache rebuilds and tool failures on the turns where they happen, not just in a status bar. Model and effort, the prompt cache and your usage windows stay under the prompt.
In Claude Code 2.1.293 or later, type at the prompt:
/plugin install g-line --marketplace lifeofgurpreet/g-line

Worked for 20s · done 11:01 AM) gets a receipt. An ordinary turn says what it cost at API prices and how much of its input the prompt cache served: API $0.22 · cache read 49%. An unusual one leads with up to two of these, in colour: +86k context, cache cold · rebuilt 42k, cache write 84k, 3 failed Bash calls or compacted 182k → 31k. A cost several times your recent turns says so: API $1.42 · 4x recent. Scroll back to see which turn changed things.3 tools · +4.1k ctx · 5h 74%). When calls keep failing, it names the tool and the file: ⚠ 3× Edit failures on core.ts. The receipt keeps that record after the turn ends.ctx 13% · 5h 1% · wk 79%!). One coloured line under it has the model and its effort, the cache countdown and what happens next: Opus 5.5 high · cache ~59m · wk out ~Thu 19:00, resets Fri 16:00. Nothing goes above the prompt.Claude cache goes cold in 5m (148k of context at risk). Another tells you when a usage window resets after you came close to its limit.
Answer y to add the marketplace and pick a scope (user is the usual choice). The mod starts at once, with no restart. To remove it, run /plugin uninstall g-line.
A statusLine command is a separate program. Claude Code runs it and prints what it prints in a status area under the prompt. A mod runs inside Claude Code and can draw on Claude Code's own lines. That is what makes g-line possible:
/g-line command that runs without a Claude turn, a pane with buttons, and native notifications through your own notification channel.Read from each project's README on 2026-10-14. "Not documented" means not in its docs, not impossible.
| g-line | claude-hud | ccstatusline | claude-powerline | ccusage | |
|---|---|---|---|---|---|
| What it is | Claude Code mod | Plugin that sets up a statusLine command | statusLine command | statusLine command | Usage-report CLI with a statusline command |
| Where it draws | The hint line, one row under it, the spinner, each turn's closing line | Its status lines (two by default) | Its status lines, configurable | Its status lines, powerline style | Its status line, and separate reports |
| Receipt on each turn's own line | Yes | Not documented | Not documented | Not documented | Not documented |
| Model and effort | Yes | Yes (effort optional) | Yes (widget) | Yes | When Claude Code supplies it |
| Prompt-cache countdown | Yes, an estimate | Optional | Yes (widget) | Optional | Not documented |
| Limits with pace | Projected run-out time | Usage windows, pace colours | Usage and reset widgets | Usage with an even-spend pace marker | Block cost, time and burn rate |
| History | Receipts in scrollback; today and this week in the pane | Optional cost ledger | Session and compaction widgets | Today and month budgets | Daily, monthly and session reports |
| Where activity comes from | Engine events | Status data; optional lines read the transcript | Status data, transcript and usage API, by widget | Status data and local transcripts, by feature | Local usage records |
| Pick it to | See which turn changed things, beside the work | Get a configurable session overview | Get extensive widget customisation | Get a styled layout with budgets | Get retrospective usage analysis |
Already happy with one of those? Set lines to receipts: g-line then adds only the receipts, spinner and heads-ups, and leaves your status line alone.
| Receipt | When |
|---|---|
API $0.22 · cache read 49% | An ordinary turn: its cost at API prices and the share of its input the cache served |
+86k context | The turn grew the context by at least 8k tokens, or by 2% of the window when that is more |
cache cold · rebuilt 42k | The turn's first request came after the cache's lifetime and rewrote it |
cache write 84k | A request wrote at least 20k tokens to the cache, well beyond what the context grew |
3 failed Bash calls | Two or more tool calls failed in the turn (named when they were all one tool) |
compacted 182k → 31k | The context was compacted during the turn |
API $1.42 · 4x recent | At least three times the median of your recent turns, and at least 20 cents more |
These are observations, not diagnoses. g-line says a cold cache was rebuilt; it doesn't say whose fault that was. When a background agent ran alongside the turn, the session's cost growth isn't the turn's alone, so the dollar figure is left out. A narrow terminal drops the supporting facts first.
Line 1, at the end of Claude Code's hint line, drawn dim by Claude Code:
| Segment | Example | What it means |
|---|---|---|
| context | ctx 13% | How full the context window is, from the last response |
| 5h, week | 5h 1%, wk 79%! | Your usage windows. ! means on pace to run out before the reset |
| spend | spend 83% | Behind a Claude gateway that sets a spend limit |
Line 2, g-line's own row, in colour:
| Segment | Example | What it means |
|---|---|---|
| model | Opus 5.5 high | The model and the effort of the last request (fast when fast mode is on) |
| cache | cache ~59m, cache cold | How long the prompt cache stays warm, green, then amber in its last minutes. ~ because it's an estimate from when the last request ran and the cache's lifetime |
| timing | wk out ~Thu 19:00, resets Fri 16:00 | When a window on pace to run out gets there (rounded: it's a projection), else when it resets |
| agents, compaction | agents 2 running, compacts in ~3 turns | While subagents work; when a compaction is near |
When the terminal narrows, line 2 shortens first (Opus 5.5 to Opus, the effort kept), and what line 1 can't fit moves down rather than vanishing. A healthy window's reset time goes first; the warning keeps its projection the longest.

Prefer one line? Set lines to 1: everything rides on the hint line, and Claude Code's standing hint (shift+tab to cycle) shortens to ⇧⇥ to make room. Claude Code's own permission-mode label (⏵⏵ bypass permissions on) is drawn beside the hint where no mod can change it, so g-line leaves it as it is.
Sautéing… · 3 tools · +4.1k ctx · 5h 74% while a turn runs, and ⚠ 3× Edit failures on core.ts first once calls keep failing.

/g-line set pulse on (or p in /g-line settings) turns on Pulse, and a headline appears under the prompt within seconds, on a dim row of its own under line 2:
𝕏 @NeelNanda5 · 2h: This is interesting work, but I don't think it's a significant… · /g-line news
It moves to the next item every 8 seconds. The headline is a link: cmd-click (ctrl-click on Linux) opens it in terminals that draw OSC 8 links (iTerm2, Ghostty, WezTerm, kitty, VS Code; in tmux, add set -ga terminal-features "*:hyperlinks"). /g-line open opens whichever headline is showing; over SSH, where there's no browser to open, it copies the link instead. On a turn that runs longer than 15 seconds, the spinner shows the headlines too.
/g-line news is the reader. It lists every item in the feed, best first, as the feed ranks them. The picked one is shown in full: who and when, likes on 𝕏 or points on Hacker News, the story a post is about, the whole post, and its link. The rest are one headline each. j and k move, o or Enter opens, d opens a Hacker News item's comments, c copies the link, r refreshes, q closes. Items posted since you last opened it are marked new.
Headlines come from g-line's own static feed: posts on 𝕏 about AI, Claude Code releases, Anthropic's news pages, Hacker News stories about Claude, Simon Willison's AI posts, and a few items the maintainer picks. Each is labelled with where it came from. Posts on 𝕏 are the freshest part: under two hours old, or gaining likes fast enough to stay up to a day, or big enough to stay up to three days, and always about AI. No X API key is involved: the feed finds posts through the links Techmeme, AI subreddits and the Internet Archive carry, and rebuilds every 30 minutes.
When Pulse is on, g-line fetches one file, raw.githubusercontent.com/lifeofgurpreet/g-line/pulse/pulse.json, at most every 45 minutes. It sends nothing about you, it never fetches while drawing, and it gives up after five seconds. Headline text is stripped of escape and control characters before it reaches your terminal; the only escape g-line draws is its own link, to an https address it has checked. With Pulse off (the default), g-line makes no network requests at all.

/g-line opens a pane with every figure spelled out: limits with bars and reset times, warnings, agents, the last turns' receipts, what fills the context (counted locally, like /context), tools used and failed, and today's and this week's totals across sessions. Its top row flips the common settings with one key (l layout, r receipt, p Pulse), s opens all of them, and n opens the news reader. /g-line card shows a share card of the session with a copy button; it leaves out your repository, branch, paths and account. /g-line off hides everything g-line draws and keeps it quiet until /g-line on.
/g-line settings is the settings manager. Every setting is one row: its key, its value, and where the value comes from (default, config.json, /config, or your choice), with • marking the ones that differ from a fresh install. Press the key and the setting flips, at once, in this session and in every other open one, with no restart. z clears the choices you made here, so your files and the defaults decide again.
Show
l: Layout two lines default
p: Pulse on • config.json
x: Claude reads on • your choice
The same from the prompt: /g-line set pulse on, /g-line set receipt short, /g-line toggle git, /g-line reset pulse (back to what your files say), /g-line reset (all of them), and /g-line set all on. Names are forgiving: cold cache, cache and notify.cache are the same setting.

/g-line settings sets every on/off and layout choice without editing anything (see above). /config lists six: Layout (2, 1 or receipts), One-line preset, Icons, Heads-up toasts, Native notifications and Turn receipt. To set them for every session on a machine, or every machine you manage, write ~/.config/g-line/config.json (or put it under $XDG_CONFIG_HOME). Running sessions read it again within 15 seconds of an edit. Layers, lowest first: defaults, config.json, /config, then choices made with /g-line.
{
"lines": 2,
"git": true,
"notify": { "cache": true, "reset": true, "long_turn": true },
"today_budget": 50
}
| Key | Values | Default |
|---|---|---|
lines | 2, 1, or receipts (receipts, spinner and heads-ups only) | 2 |
receipt | adaptive, short (cost, cache, tools), full, off, or a list of cost, cache, context, tools, agents, output | adaptive |
pulse | true fetches the headline feed (see Pulse) | false |
git | true puts your branch on line 2; g-line then runs git status in your project | false |
notify | true, false, or per kind: cache, reset, long_turn (a turn longer than long_turn_seconds finished), agents (a background agent finished) | cache and reset on |
toasts | true, false | true |
spinner | true, false: the burn on the spinner | true |
tool_streak | Failures in a row of the same call before the toast; 0 turns it off | 3 |
today_budget | Dollars at API prices; today's total across sessions joins line 2 and a toast says when you pass it | none |
coach | true offers /effort or /model for Tab to take when the 5-hour window is on pace to run out, and /compact keep before a compaction. It never changes anything itself | false |
claude_reads | true gives Claude a status tool to read these figures when you ask it to (see below) | false |
footer_labels | true, false: agents and compaction in the footer (one-line layout) | true |
short_hints | true draws (shift+tab to cycle) as ⇧⇥ | true |
export_state | true writes the figures as JSON under ~/.cache/g-line/state/ for tmux and other bars | false |
reset_threshold, long_turn_seconds, cache_ttl, icons, segments, preset, account_command | See hooks/settings.ts. account_command runs a command you choose and shows its first line; off unless set |
~/.config/glanceline/config.json from 0.4 is still read, and g-line/config.json wins.
With claude_reads: true, g-line registers a tool, status, that returns a small JSON snapshot: context used and the room before auto-compact (when Claude Code reports it), the usage windows with projected run-out times, and the cache's estimated expiry, each with its scope. Unknown figures are null, and it gives no advice. Its description tells Claude to use it when you ask, and not to change model, effort or compaction on its own. Two honest notes: the result enters the conversation like any tool result, so it goes to your model provider with the rest of the conversation; and the tool is deferred only where tool search is available.
/g-line; when you last opened the news reader; and, with Pulse on, the last feed.git status only with git: true or while the pane is open; your account_command only if you set one; open (or xdg-open) with a feed link when you ask the reader to open one.claude_reads (the snapshot goes into your conversation).~/.cache/glanceline/live/<session id> so an older status line command can step aside, and with export_state, a JSON file for other bars.glanceline is now g-line. The repository moved to lifeofgurpreet/g-line (the old address redirects), and the plugin has a new name, so Claude Code treats it as a new plugin. An installed glanceline updates to a small stub that keeps loading and tells you, once per session, to switch:
/plugin uninstall glanceline
/plugin install g-line --marketplace lifeofgurpreet/g-line
Your ~/.config/glanceline/config.json keeps working. /glanceline still opens the pane until the next release. The day and week totals start again under the new name. If you used the old Python status line (glanceline install), run glanceline uninstall once to put your previous statusLine back. g-line no longer ships that fallback.
See AGENTS.md: the rules g-line stands on, a map of the code, and recipes for adding a figure or a receipt event.
claude --plugin-dir . # a session with your working copy; saving reloads it
claude plugin validate . # what the engine reads, the hooks and calls, what it would refuse
claude plugin test . # the tests
The screenshots and the demo are real captures (tmux capture-pane -e), drawn by scripts/ansi2png.py and cut by scripts/frames.py. tools/pulse/ builds the Pulse feed on the maintainer's machine; it isn't part of the mod.
MIT
hooks/register.tsx 1559 lines1// g-line, the mod: live status under the prompt, receipts on the turns.
2//
3// The live figures ride on lines Claude Code already draws: the hint line
4// under the prompt (context, 5-hour and weekly limits with pace, cache, git),
5// the footer's mode labels (agents at work, when the context will compact),
6// the working spinner (what the turn has cost so far) and the line that
7// closes each turn (`Baked for 1m 4s · $0.38 · 94% cached · 7 tools`).
8// Heads-ups arrive as toasts and native notifications; everything else is in
9// the `/g-line` pane and its share card.
10//
11// Every figure is Claude Code's own (`$.session.usage()`, `session.measure`,
12// `turn.complete`, `$.agent.list()`, `session.compact`, `tool.call`) or what
13// the mod watched happen. No transcript is parsed, nothing is estimated that
14// the engine reports, and nothing leaves the machine.
15
16import { atom, read, update } from 'claude-code'
17import type { EngineInterface, Register } from 'claude-code'
18
19import type { AgentRow, Receipt, Snap, Totals } from '../types'
20import { SEP, alerts, layoutKeys, modelName, pct, segments, str, tokens, width } from './core'
21import type { Options, Payload, Preset, Segment, Tone } from './core'
22import {
23 EMPTY_TOTALS, RECEIPT_PRESETS, agentsSegment, cacheHit, cardLines, coach, compactionText, contextView, costRatio,
24 dayOf, fitParts, forecast, forecastSegment, isActive, isEnded, ledger, loopWarning, money, receiptSpec,
25 receiptText, spark, spinnerParts, staleKeys, target, todaySegment, turnLength, turnLineWidth, unusedServers,
26} from './extras'
27import type { ReceiptSpec } from './extras'
28import { MOD_KEYS, accountSegment, inline, joined, modesWidth, shownHint, tailRoom, withText } from './inline'
29import { deck, nextRoom } from './deck'
30import { STATUS_DESCRIPTION, STATUS_SCHEMA, STATUS_TOOL, statusJson } from './glance'
31import { PULSE_EVERY_MS, PULSE_TTL_MS, PULSE_URL, ageOf, clean, countOf, pageOf, parseFeed, pulseRow, safeUrl, tickerText, xLead } from './pulse'
32import type { PulseItem } from './pulse'
33import { gitSegmentOf } from './core'
34import {
35 DEFAULTS, GROUPS, SETTINGS, changed, merge, nextValue, originOf, parseCommand, patchOf, said, withPatch, withoutSetting,
36} from './settings'
37import type { Config, Layer, Setting } from './settings'
38
39const snap = atom({ plugin: 'g-line', key: 'snap' } as const, {})
40const tick = atom({ plugin: 'g-line', key: 'tick' } as const, 0)
41const seen = atom({ plugin: 'g-line', key: 'seen' } as const, [])
42const hidden = atom({ plugin: 'g-line', key: 'hidden' } as const, false)
43const receipts = atom({ plugin: 'g-line', key: 'receipts' } as const, {})
44const turnNow = atom({ plugin: 'g-line', key: 'turn' } as const, null)
45const agents = atom({ plugin: 'g-line', key: 'agents' } as const, [])
46const totals = atom({ plugin: 'g-line', key: 'totals' } as const, EMPTY_TOTALS)
47const outlook = atom({ plugin: 'g-line', key: 'forecast' } as const, null)
48const contextAtom = atom({ plugin: 'g-line', key: 'context' } as const, null)
49const ledgerAtom = atom({ plugin: 'g-line', key: 'ledger' } as const, null)
50const view = atom({ plugin: 'g-line', key: 'view' } as const, 'details')
51const pulseAtom = atom({ plugin: 'g-line', key: 'pulse' } as const, null)
52const turnover = atom({ plugin: 'g-line', key: 'turnover' } as const, 0)
53const newsAt = atom({ plugin: 'g-line', key: 'newsAt' } as const, 0)
54
55const PANE = 'g-line'
56const INSTALL = '/plugin install g-line --marketplace lifeofgurpreet/g-line'
57/** The command's name, and the 0.4 name it still answers to for one release. */
58const COMMAND = 'g-line'
59const OLD_COMMAND = 'glanceline'
60const TICK_MS = 15_000
61/**
62 * A status line command steps aside while this session's file is under a
63 * minute old. The path keeps the 0.4 name: other tools read it.
64 */
65const LIVE_DIR = '.cache/glanceline/live'
66const STATE_DIR = '.cache/g-line/state'
67
68const COLORS: Record<Tone, string | undefined> = {
69 ok: 'success', warn: 'warning', bad: 'error', accent: 'claude', dim: undefined,
70}
71
72// ----------------------------------------------------------------- config
73
74async function readJson($: EngineInterface, path: string): Promise<unknown> {
75 try {
76 return JSON.parse(String(await $.fs.read(path)))
77 } catch {
78 return undefined
79 }
80}
81
82/** The files config is read from, lowest first, each with the name `/g-line settings` gives it. */
83async function configFiles($: EngineInterface): Promise<{ name: string; path: string }[]> {
84 const files = [{ name: 'plugin', path: `${$.plugin.root}/config.json` }]
85 const xdg = await $.env.get('XDG_CONFIG_HOME')
86 const home = await $.env.get('HOME')
87 const base = xdg || (home ? `${home}/.config` : undefined)
88 // The 0.4 name's file, then the new one: settings carry over across the rename.
89 if (base) {
90 files.push({ name: 'glanceline/config.json', path: `${base}/glanceline/config.json` })
91 files.push({ name: 'config.json', path: `${base}/g-line/config.json` })
92 }
93 return files
94}
95
96/** What /config sets; its rows left at their defaults say nothing. */
97function chosenOf(options: Record<string, unknown>): Record<string, unknown> {
98 const chosen: Record<string, unknown> = {}
99 if (options.lines !== undefined && options.lines !== '2') chosen.lines = options.lines
100 if (options.preset !== undefined && options.preset !== 'default') chosen.preset = options.preset
101 if (options.icons !== undefined && options.icons !== 'none') chosen.icons = options.icons
102 if (options.toasts === false) chosen.toasts = false
103 if (options.notifications === false) chosen.notify = false
104 if (options.receipt !== undefined && options.receipt !== 'adaptive') chosen.receipt = options.receipt
105 return chosen
106}
107
108/** The layer of choices made with /g-line, in the pane or by command. */
109const CHOSEN = 'your choice'
110
111/** Defaults, then the plugin's own config.json, the user's files, /config, then choices made with /g-line: the most recent. */
112async function loadConfig($: EngineInterface, options: Record<string, unknown>): Promise<{ config: Config; layers: Layer[] }> {
113 const found: Layer[] = []
114 for (const file of await configFiles($)) found.push({ name: file.name, raw: await readJson($, file.path) })
115 found.push({ name: '/config', raw: chosenOf(options) })
116 try {
117 found.push({ name: CHOSEN, raw: (await $.store.get(SETTINGS_KEY)) ?? {} })
118 } catch {
119 // no store: the files and /config decide
120 }
121 return { config: found.reduce((c, layer) => merge(c, layer.raw), DEFAULTS), layers: found }
122}
123
124const SETTINGS_KEY = 'settings'
125
126/**
127 * What changes when config is read again: the files' times and the saved
128 * choices (another session's pane writes them). Checked on the beat, so an
129 * edit to ~/.config/g-line/config.json reaches running sessions within 30 s.
130 */
131async function configStamp($: EngineInterface): Promise<string> {
132 const parts: string[] = []
133 for (const file of await configFiles($)) {
134 try {
135 parts.push(String((await $.fs.stat(file.path)).mtimeMs))
136 } catch {
137 parts.push('-')
138 }
139 }
140 try {
141 parts.push(JSON.stringify((await $.store.get(SETTINGS_KEY)) ?? null))
142 } catch {
143 parts.push('-')
144 }
145 return parts.join('|')
146}
147
148/** Reads every layer again and applies what changed. */
149async function reloadConfig($: EngineInterface): Promise<void> {
150 const before = config
151 ;({ config, layers } = await loadConfig($, startOptions))
152 stamp = await configStamp($)
153 await applied($, before)
154}
155
156/** Makes a change of settings take effect in this session, without a restart. */
157async function applied($: EngineInterface, before: Config): Promise<void> {
158 if (config.cacheTtl !== before.cacheTtl) ttl = await explicitTtl($, config)
159 if (config.exportState !== before.exportState) await setupLive($)
160 if (config.claudeReads && !statusRegistered) await registerStatus($)
161 if (config.pulse && !before.pulse) await refreshPulse($, true)
162 await update($, tick, n => n + 1)
163}
164
165/** Saves the choice for later sessions and applies it now. */
166async function choose($: EngineInterface, patch: Record<string, unknown>): Promise<void> {
167 const held = layers.find(layer => layer.name === CHOSEN)?.raw
168 let saved = (held && typeof held === 'object' ? held : {}) as Record<string, unknown>
169 try {
170 saved = ((await $.store.get(SETTINGS_KEY)) ?? {}) as Record<string, unknown>
171 } catch {
172 // the choice holds for this session
173 }
174 await keep($, withPatch(saved, patch))
175}
176
177/** Replaces the saved choices: they now decide, over the files and /config. */
178async function keep($: EngineInterface, saved: Record<string, unknown>): Promise<void> {
179 try {
180 await $.store.set(SETTINGS_KEY, saved)
181 } catch {
182 // the choice holds for this session
183 }
184 const before = config
185 layers = [...layers.filter(layer => layer.name !== CHOSEN), { name: CHOSEN, raw: saved }]
186 config = layers.reduce((c, layer) => merge(c, layer.raw), DEFAULTS)
187 stamp = await configStamp($)
188 await applied($, before)
189}
190
191/** The toast after a setting changes: its new value, and when it takes effect if not at once. */
192function changedText(setting: Setting): string {
193 const value = said(setting, config)
194 const note = setting.key === 'claude_reads'
195 ? (config.claudeReads ? ' Claude can call g-line\'s status tool from your next message.' : ' The status tool now answers that it is off, and is gone in new sessions.')
196 : setting.key === 'pulse' && config.pulse ? ' Headlines from g-line\'s feed on long turns and in /g-line; one static file, fetched every 45 minutes.'
197 : setting.key === 'pulse' ? ' No fetches.' : ''
198 return `${setting.label}: ${value}.${note}`
199}
200
201// ------------------------------------------------------------------ cache
202
203function truthy(value: string | undefined): boolean {
204 return value !== undefined && value !== '' && value !== '0' && value.toLowerCase() !== 'false'
205}
206
207/**
208 * The TTL Claude Code writes for the main conversation, by its documented
209 * rule: FORCE_PROMPT_CACHING_5M, CLAUDE_CODE_PROMPT_CACHE_TTL, the
210 * promptCacheTtl setting, ENABLE_PROMPT_CACHING_1H; unset, one hour on a
211 * subscription within its limits and five minutes otherwise. A hit or miss
212 * the mod observed after an idle gap overrides the rule.
213 */
214async function explicitTtl($: EngineInterface, config: Config): Promise<'5m' | '1h' | undefined> {
215 if (config.cacheTtl !== 'auto') return config.cacheTtl
216 if (truthy(await $.env.get('FORCE_PROMPT_CACHING_5M'))) return '5m'
217 const env = await $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL')
218 if (env === '5m' || env === '1h') return env
219 try {
220 const setting = (await $.settings.read()).promptCacheTtl
221 if (setting === '5m' || setting === '1h') return setting
222 } catch {
223 // settings unreadable: fall through to the automatic rule
224 }
225 if (truthy(await $.env.get('ENABLE_PROMPT_CACHING_1H'))) return '1h'
226 return undefined
227}
228
229function automaticTtl(s: Snap): '5m' | '1h' {
230 const limits = s.rateLimits ?? []
231 const subscription = limits.some(l => l.kind === 'five_hour' || l.kind === 'seven_day')
232 const within = limits.every(l => l.percentUsed < 100)
233 return subscription && within ? '1h' : '5m'
234}
235
236// ---------------------------------------------------------------- payload
237
238/** The statusLine-shaped view of what the mod knows, for the core. */
239export function payload(s: Snap, nowSeconds: number, ttl: '5m' | '1h' | undefined): Payload {
240 const p: Payload = {}
241 if (s.model) p.model = { display_name: modelName(s.model) }
242 if (s.effort) p.effort = { level: s.effort }
243 if (s.context && s.context.percent !== undefined) {
244 p.context_window = { used_percentage: s.context.percent, context_window_size: s.context.window,
245 total_input_tokens: s.context.tokens }
246 }
247 const limits: NonNullable<Payload['rate_limits']> = {}
248 for (const limit of s.rateLimits ?? []) {
249 if (limit.kind === 'five_hour' || limit.kind === 'seven_day' || limit.kind === 'spend_limit') {
250 limits[limit.kind] = { used_percentage: limit.percentUsed, resets_at: limit.resetsAt }
251 }
252 }
253 if (Object.keys(limits).length) p.rate_limits = limits
254 if (s.costUsd !== undefined || s.startedAt !== undefined) {
255 p.cost = { total_cost_usd: s.costUsd,
256 total_duration_ms: s.startedAt !== undefined ? nowSeconds * 1000 - s.startedAt : undefined }
257 }
258 if (s.cache?.cached) {
259 const effective = s.cache.learned ?? ttl ?? automaticTtl(s)
260 const expires = s.cache.lastAt + (effective === '1h' ? 3600 : 300)
261 p.prompt_cache = { caching_observed: true, ttl: effective, warm: expires > nowSeconds, expires_at: expires,
262 recache_tokens_if_cold: s.cache.prefix }
263 }
264 if (s.git) p.git = s.git
265 return p
266}
267
268// ------------------------------------------------------------------ mod
269
270let config: Config = DEFAULTS
271/** Where config came from, lowest first: `/g-line settings` names the layer each value is from. */
272let layers: Layer[] = []
273/** The config files' times and the saved choices when last read. */
274let stamp = ''
275let startOptions: Record<string, unknown> = {}
276let statusRegistered = false
277let ttl: '5m' | '1h' | undefined
278let beats = 0
279let liveFile: string | undefined
280let stateFile: string | undefined
281let sessionId: string | undefined
282/** The receipt of the main-loop turn that just ended, until its closing line is appended. */
283let pending: Promise<Receipt> | undefined
284/** The hint line's last text: `1 shell` and the like say background work is running. */
285let lastHint = ''
286/** Whether this turn's first request rebuilt a cold cache. */
287let coldThisTurn = false
288/** Consecutive failures per agent, tool and target. */
289const streaks = new Map<string, number>()
290
291const UNREADABLE = JSON.stringify({ schema: 1, state: 'unavailable' })
292const STATUS_OFF = JSON.stringify({ schema: 1, state: 'disabled', note: 'The person turned this tool off (/g-line set claude_reads on turns it back on).' })
293const BACKGROUND = /\b\d+ (?:shells?|monitors?|tasks?|agents?|workflows?|background)\b/i
294/** Columns a receipt leaves for Claude Code's own words on a closing line that reports background work. */
295const BACKGROUND_ROOM = 64
296/** The settings the details view offers at once; the rest are a key away. */
297const QUICK = SETTINGS.filter(setting => ['lines', 'receipt', 'pulse'].includes(setting.key))
298
299async function nowMs($: EngineInterface): Promise<number> {
300 return $.clock.now()
301}
302
303async function nowSeconds($: EngineInterface): Promise<number> {
304 return (await $.clock.now()) / 1000
305}
306
307const offsetAt = (ms: number) => -new Date(ms).getTimezoneOffset()
308
309function limitsOf(list: readonly { kind: string; percentUsed: number; resetsAt?: string }[]): Snap['rateLimits'] {
310 return list.map(l => ({ kind: l.kind, percentUsed: l.percentUsed,
311 resetsAt: l.resetsAt ? Math.floor(Date.parse(l.resetsAt) / 1000) : undefined }))
312}
313
314/** The highest percent each window has reached, so a reset can say what it freed. */
315function peaksOf(s: Snap, limits: Snap['rateLimits']): Snap['peaks'] {
316 const peaks = { ...(s.peaks ?? {}) }
317 for (const l of limits ?? []) {
318 if ((l.kind === 'five_hour' || l.kind === 'seven_day') && l.resetsAt) {
319 const key = `${l.kind}:${l.resetsAt}`
320 peaks[key] = Math.max(peaks[key] ?? 0, l.percentUsed)
321 }
322 }
323 return peaks
324}
325
326async function measure($: EngineInterface): Promise<void> {
327 const usage = await $.session.usage()
328 const limits = limitsOf(usage.rateLimits)
329 await update($, snap, s => ({
330 ...s,
331 startedAt: usage.startedAt,
332 context: { tokens: usage.context.tokens, window: usage.context.window, percent: usage.context.percent },
333 rateLimits: limits,
334 costUsd: usage.cost?.usd,
335 peaks: peaksOf(s, limits),
336 }))
337}
338
339async function refreshGit($: EngineInterface, force = false): Promise<void> {
340 if (!force && !layoutHas('git')) return
341 try {
342 const run = await $.process.run(
343 ['git', '--no-optional-locks', 'status', '--porcelain=v1', '--branch', '--untracked-files=no'],
344 { timeoutMs: 2000 })
345 if (run.exitCode !== 0) {
346 await update($, snap, s => ({ ...s, git: null }))
347 return
348 }
349 const [head = '', ...rest] = run.stdout.split('\n')
350 const name = head.replace(/^## /, '').replace(/^No commits yet on /, '').split('...')[0]?.split(' ')[0]
351 const branch = name === 'HEAD' ? 'detached' : str(name, 40)
352 await update($, snap, s => ({ ...s, git: { branch, dirty: rest.some(line => line.trim()) } }))
353 } catch {
354 // no git, or too slow: keep the last reading
355 }
356}
357
358async function refreshAccount($: EngineInterface): Promise<void> {
359 const command = config.accountCommand
360 if (!command) return
361 try {
362 const run = await $.process.run(command, { timeoutMs: 5000 })
363 const line = run.exitCode === 0 ? str(run.stdout.split('\n')[0], 120) : undefined
364 await update($, snap, s => ({ ...s, account: line ?? null }))
365 } catch {
366 await update($, snap, s => ({ ...s, account: null }))
367 }
368}
369
370function optionsAt(seconds: number): Options {
371 return { now: seconds, preset: config.preset, segments: config.segments, icons: config.icons,
372 utcOffsetMinutes: offsetAt(seconds * 1000) }
373}
374
375/** A heads-up that reaches you in another app, or a toast when no channel takes it. */
376async function heads($: EngineInterface, text: string, title: string, notify: boolean): Promise<void> {
377 if (await read($, hidden)) return
378 if (notify) {
379 try {
380 if ((await $.ui.notify(text, { title })).isSent) return
381 } catch {
382 // a hook refused it, or the write failed: fall back to the toast
383 }
384 }
385 await toast($, text, 12_000)
386}
387
388/** A terminal toast, unless toasts are off or `/g-line off` hid the mod. */
389async function toast($: EngineInterface, text: string, timeoutMs: number): Promise<void> {
390 if (config.toasts && !(await read($, hidden))) $.ui.toast(text, { timeoutMs })
391}
392
393/** Puts a command in the prompt box for Tab to take, unless the mod is hidden. */
394async function suggest($: EngineInterface, text: string): Promise<void> {
395 if (!(await read($, hidden))) await $.prompt.suggest({ text }).catch(() => undefined)
396}
397
398/** Marks an id as handled; true the first time. */
399async function once($: EngineInterface, id: string): Promise<boolean> {
400 if ((await read($, seen)).includes(id)) return false
401 await update($, seen, list => (list.includes(id) ? list : [...list, id].slice(-100)))
402 return true
403}
404
405/** True the first time any session asks about `key`; the store keeps it across sessions. */
406async function firstAcrossSessions($: EngineInterface, key: string): Promise<boolean> {
407 try {
408 if ((await $.store.get(key)) !== undefined) return false
409 await $.store.set(key, Math.floor(await nowSeconds($)))
410 return true
411 } catch {
412 return true
413 }
414}
415
416async function nudge($: EngineInterface): Promise<void> {
417 const seconds = await nowSeconds($)
418 const s = await read($, snap)
419 const p = payload(s, seconds, ttl)
420 for (const alert of alerts(p, optionsAt(seconds))) {
421 if (!(await once($, alert.id))) continue
422 if (alert.id.startsWith('cache:')) {
423 await heads($, alert.text, 'Prompt cache', config.notify.cache)
424 continue
425 }
426 // The coach speaks once per 5-hour window across every session, so a new
427 // session in a hot window doesn't offer the same command again.
428 const advice = config.coach && alert.id.startsWith('five_hour:') && (await firstAcrossSessions($, `coach:${alert.id}`))
429 ? coach('five_hour', p.rate_limits?.five_hour, seconds, s.effort, offsetAt(seconds * 1000)) : undefined
430 await toast($, advice ? advice.text : alert.text, 15_000)
431 if (advice) await suggest($, advice.suggest)
432 }
433 // A window that reset while you were near or at its limit.
434 for (const [key, peak] of Object.entries(s.peaks ?? {})) {
435 const [kind, at] = key.split(':')
436 const resetAt = Number(at)
437 if (!Number.isFinite(resetAt) || resetAt > seconds || seconds - resetAt > 6 * 3600) continue
438 if (peak < config.resetThreshold || !(await once($, `reset:${key}`))) continue
439 const name = kind === 'five_hour' ? '5-hour' : 'weekly'
440 await heads($, `Your ${name} usage window has reset. You'd reached ${pct(peak)} of it.`, 'Usage window reset', config.notify.reset)
441 }
442}
443
444/**
445 * Tells any status line command configured in this session (an older
446 * glanceline, a team's own) that the mod draws these figures, so they never
447 * show twice: such a command reads the file named after its session id and
448 * steps aside while it is under a minute old. Nothing else is in it.
449 */
450async function markLive($: EngineInterface): Promise<void> {
451 if (!liveFile) return
452 try {
453 await $.fs.write(liveFile, `${Math.floor(await nowSeconds($))}\n`)
454 } catch {
455 // unwritable home: such a command keeps drawing, which is only redundant
456 }
457}
458
459async function setupLive($: EngineInterface): Promise<void> {
460 try {
461 const home = await $.env.get('HOME')
462 const id = await $.session.id()
463 if (!/^[A-Za-z0-9_-]{1,128}$/.test(id)) return
464 sessionId = id
465 if (!home) return
466 liveFile = `${home}/${LIVE_DIR}/${id}`
467 stateFile = config.exportState ? `${home}/${STATE_DIR}/${id}.json` : undefined
468 await markLive($)
469 // Forget sessions that ended more than a day ago.
470 await $.process.run(['find', `${home}/${LIVE_DIR}`, '-type', 'f', '-mmin', '+1440', '-delete'], { timeoutMs: 2000 })
471 if (stateFile) await $.process.run(['find', `${home}/${STATE_DIR}`, '-type', 'f', '-mmin', '+1440', '-delete'], { timeoutMs: 2000 })
472 } catch {
473 // best effort
474 }
475}
476
477/** The figures as JSON for tmux, sketchybar and other bars, when `export_state` is on. */
478async function exportState($: EngineInterface): Promise<void> {
479 if (!stateFile) return
480 try {
481 const seconds = await nowSeconds($)
482 const s = await read($, snap)
483 const p = payload(s, seconds, ttl)
484 const active = (await read($, agents)).filter(row => isActive(row.status)).length
485 const body = {
486 updatedAt: Math.floor(seconds), line: joined(inline(p, optionsAt(seconds))),
487 model: p.model?.display_name, context: p.context_window?.used_percentage,
488 fiveHour: p.rate_limits?.five_hour?.used_percentage, sevenDay: p.rate_limits?.seven_day?.used_percentage,
489 cache: p.prompt_cache ? { warm: p.prompt_cache.warm, expiresAt: p.prompt_cache.expires_at } : undefined,
490 agents: active, cost: s.costUsd,
491 }
492 await $.fs.write(stateFile, `${JSON.stringify(body)}\n`)
493 } catch {
494 // best effort
495 }
496}
497
498// ----------------------------------------------------------------- agents
499
500/** Reads the engine's agent list and says when a background agent ends. */
501async function refreshAgents($: EngineInterface): Promise<void> {
502 let list
503 try {
504 list = await $.agent.list()
505 } catch {
506 return
507 }
508 const at = await nowMs($)
509 const before = await read($, agents)
510 const ended: AgentRow[] = []
511 const rows = before.map(row => {
512 const live = list.find(one => one.id === row.id)
513 const status = live?.status ?? (isActive(row.status) ? 'ended' : row.status)
514 const next = { ...row, status, endedAt: row.endedAt ?? (isEnded(status) ? at : undefined) }
515 if (isActive(row.status) && isEnded(status)) ended.push(next)
516 return next
517 })
518 for (const one of list) {
519 if (rows.some(row => row.id === one.id)) continue
520 rows.push({ id: one.id, description: one.description, type: one.type, status: one.status,
521 background: true, teammate: one.teammateId !== undefined, startedAt: at,
522 endedAt: isEnded(one.status) ? at : undefined })
523 }
524 await update($, agents, () => rows.slice(-50))
525 for (const row of ended) {
526 if (!row.background) continue
527 const what = `"${row.description.slice(0, 60)}"`
528 if (row.status === 'failed' || row.status === 'killed') {
529 await heads($, `Background agent ${what} ${row.status === 'failed' ? 'failed' : 'was stopped'}.`, 'Agent', config.notify.agents)
530 } else if (config.notify.agents) {
531 await heads($, `Background agent ${what} finished.`, 'Agent', true)
532 }
533 }
534}
535
536// ----------------------------------------------------------------- turns
537
538/** The receipt of a main-loop turn, from the engine's ledger and the turn's usage. */
539async function receiptOf($: EngineInterface, e: { durationMs: number; usage?: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number } }): Promise<Receipt> {
540 const live = await read($, turnNow)
541 let cost: number | undefined
542 let context: number | undefined
543 try {
544 const usage = await $.session.usage()
545 if (usage.cost && live?.costAt !== undefined) cost = Math.max(0, usage.cost.usd - live.costAt)
546 if (usage.context.tokens !== undefined && live?.contextAt !== undefined) context = usage.context.tokens - live.contextAt
547 } catch {
548 // no ledger: the receipt shows what the turn's usage says
549 }
550 const rows = await read($, agents)
551 const active = rows.some(row => isActive(row.status))
552 const overlap = (live?.overlap ?? false) || rows.some(row => row.background && isActive(row.status))
553 const earlier = Object.values(await read($, receipts)).sort((x, y) => x.endAt - y.endAt).slice(-20)
554 .filter(one => one.cost !== undefined && one.cost > 0 && !one.overlap).map(one => one.cost as number)
555 let window: number | undefined
556 try {
557 window = (await read($, snap)).context?.window
558 } catch {
559 window = undefined
560 }
561 return {
562 cost, context, cacheHit: e.usage ? cacheHit(e.usage) : undefined,
563 tools: live?.tools ?? 0, agents: live?.agents ?? 0, output: e.usage?.output_tokens,
564 endAt: await nowMs($), durationMs: e.durationMs, background: active || BACKGROUND.test(lastHint),
565 cacheWrite: live?.cacheWrite, warmBefore: live?.warmBefore, failures: live?.failures,
566 failedTool: live?.failedTool ?? undefined, overlap, cold: live?.cold, compacted: live?.compacted, window,
567 ratio: costRatio(cost, overlap, earlier),
568 }
569}
570
571/** After a main-loop turn: totals, the forecast, the context view and the day's ledger. */
572async function afterTurn($: EngineInterface, r: Receipt, aborted: boolean): Promise<void> {
573 let context: number | undefined
574 let percent: number | undefined
575 let breakdown
576 try {
577 const usage = await $.session.usage({ breakdown: 'summary' })
578 context = usage.context.tokens
579 percent = usage.context.percent
580 breakdown = usage.context.breakdown
581 await update($, snap, s => ({ ...s, context: { tokens: usage.context.tokens, window: usage.context.window,
582 percent: usage.context.percent }, costUsd: usage.cost?.usd ?? s.costUsd }))
583 } catch {
584 // keep the last figures
585 }
586 const cold = r.cold === true
587 const t = await update($, totals, x => ({
588 ...x, turns: x.turns + 1, colds: x.colds + (cold ? 1 : 0),
589 peakContext: Math.max(x.peakContext, percent ?? 0),
590 growth: r.context !== undefined && r.context >= 0 ? [...x.growth, r.context].slice(-8) : x.growth,
591 }))
592 const at = await nowMs($)
593 if (breakdown) await update($, contextAtom, () => contextView(breakdown, at))
594 const f = forecast(context, breakdown?.isAutoCompactEnabled ? breakdown.autoCompactThreshold : undefined, t.growth) ?? null
595 await update($, outlook, () => f)
596 if (f && f.turns <= 2 && (await once($, `compact:${t.compactions.length}`))) {
597 await toast($, `Context compacts ${f.turns <= 1 ? 'soon' : `in ~${f.turns} turns`}. To choose what's kept, run /compact with a note now${config.coach ? ' (Tab starts one)' : ''}.`, 15_000)
598 if (config.coach) await suggest($, '/compact keep ')
599 }
600 if (!aborted && r.durationMs >= config.longTurnSeconds * 1000 && config.notify.longTurn) {
601 const cost = r.cost !== undefined && r.cost > 0 ? ` · ${money(r.cost)}` : ''
602 await heads($, `Turn finished after ${turnLength(r.durationMs)}${cost}.`, 'Claude Code', true)
603 }
604 await addToDay($, r, cold, at)
605}
606
607/** Adds the turn to this session's entry for the day, then sums every session's. */
608async function addToDay($: EngineInterface, r: Receipt, cold: boolean, at: number): Promise<void> {
609 if (!sessionId) return
610 const today = dayOf(at, offsetAt(at))
611 const key = `d:${today}:${sessionId}`
612 try {
613 const old = ((await $.store.get(key)) ?? {}) as Record<string, number>
614 await $.store.set(key, {
615 cost: (old.cost ?? 0) + (r.cost ?? 0), turns: (old.turns ?? 0) + 1, tools: (old.tools ?? 0) + r.tools,
616 colds: (old.colds ?? 0) + (cold ? 1 : 0), agents: (old.agents ?? 0) + r.agents,
617 })
618 await refreshLedger($, at)
619 } catch {
620 // the store is full or unwritable: the line and pane go on without it
621 }
622}
623
624async function refreshLedger($: EngineInterface, at: number): Promise<void> {
625 const today = dayOf(at, offsetAt(at))
626 const keys = (await $.store.keys()).filter(key => key.startsWith('d:'))
627 const recent = keys.filter(key => !staleKeys([key], today, 7).length)
628 const entries = await Promise.all(recent.map(async key => ({ key, value: await $.store.get(key) })))
629 const l = ledger(entries, today)
630 await update($, ledgerAtom, () => l)
631 if (config.budget && l.today.cost >= config.budget && (await once($, `budget:${today}`))) {
632 await heads($, `Today's usage across sessions reached ${money(l.today.cost)} at API prices, past your ${money(config.budget)} budget.`, 'Daily budget', false)
633 }
634}
635
636async function pruneLedger($: EngineInterface): Promise<void> {
637 try {
638 const at = await nowMs($)
639 const keys = await $.store.keys()
640 for (const key of staleKeys(keys, dayOf(at, offsetAt(at)))) await $.store.delete(key)
641 // `coach:five_hour:<resets at>` marks outlive their window by a day at most.
642 for (const key of keys.filter(k => k.startsWith('coach:'))) {
643 const resets = Number(key.split(':').pop())
644 if (!Number.isFinite(resets) || resets * 1000 < at - 86_400_000) await $.store.delete(key)
645 }
646 await refreshLedger($, at)
647 } catch {
648 // best effort
649 }
650}
651
652/** Reads what the engine knows now: the session's id, model, usage, git and agents. */
653async function resync($: EngineInterface): Promise<void> {
654 await setupLive($)
655 try {
656 const id = await $.session.model()
657 await update($, snap, s => ({ ...s, model: s.model ?? id }))
658 } catch {
659 // the first request names the model
660 }
661 await Promise.all([measure($).catch(() => undefined), refreshGit($), refreshAccount($), refreshAgents($), pruneLedger($)])
662}
663
664async function heartbeat($: EngineInterface): Promise<void> {
665 beats += 1
666 await markLive($)
667 await update($, tick, () => beats)
668 await nudge($)
669 if (beats % 2 === 0) await refreshGit($)
670 if (beats % 40 === 0) await refreshAccount($)
671 if (beats % 20 === 0) void refreshPulse($)
672 if ((await configStamp($)) !== stamp) await reloadConfig($)
673 if ((await read($, agents)).some(row => isActive(row.status))) await refreshAgents($)
674 await exportState($)
675}
676
677// ------------------------------------------------------------- the line
678
679function layoutHas(key: string): boolean {
680 if (key === 'git' && config.lines === 2) return config.git
681 return layoutKeys({ now: 0, preset: config.preset, segments: config.segments }).flat().includes(key)
682}
683
684type Extras = Partial<Record<string, Segment | undefined>>
685
686async function extrasNow($: EngineInterface): Promise<Extras> {
687 const l = await read($, ledgerAtom)
688 return {
689 agents: agentsSegment(await read($, agents)),
690 compact: forecastSegment(await read($, outlook)),
691 today: todaySegment(l?.today, config.budget),
692 }
693}
694
695/** The labels g-line adds to the footer's mode labels: what the layout doesn't place on the hint. */
696function footerLabels(extras: Extras): string[] {
697 if (!config.footerLabels || config.lines === 2) return []
698 return (['agents', 'compact'] as const)
699 .filter(key => !layoutHas(key))
700 .map(key => extras[key]?.forms[0])
701 .filter((label): label is string => Boolean(label))
702}
703
704// ---------------------------------------------------------------- the pane
705
706type Row = { key: string; text: string; tone?: Tone; bold?: boolean }
707
708function ago(ms: number): string {
709 const s = Math.floor(ms / 1000)
710 if (s < 60) return `${s}s`
711 if (s < 3600) return `${Math.floor(s / 60)}m`
712 return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
713}
714
715const bar = (value: number, cells = 10) => {
716 const filled = Math.max(0, Math.min(cells, Math.round(value * cells)))
717 return '█'.repeat(filled) + '░'.repeat(cells - filled)
718}
719
720async function detailRows($: EngineInterface, seconds: number, columns?: number): Promise<Row[]> {
721 const s = await read($, snap)
722 const p = payload(s, seconds, ttl)
723 const opts = { ...optionsAt(seconds), preset: 'full' as const, segments: undefined }
724 const rows: Row[] = segments(p, opts).flat().map(row => ({ key: row.key, text: row.forms[0] ?? '', tone: row.tone }))
725 for (const l of s.rateLimits ?? []) {
726 if (!['five_hour', 'seven_day', 'spend_limit'].includes(l.kind)) rows.push({ key: `limit-${l.kind}`, text: `${l.kind.replace(/_/g, ' ')} ${pct(l.percentUsed)}` })
727 }
728 for (const alert of alerts(p, opts)) rows.push({ key: alert.id, text: alert.text, tone: 'warn' })
729
730 const t = await read($, totals)
731 const f = await read($, outlook)
732 const now = seconds * 1000
733
734 const list = await read($, agents)
735 if (list.length) {
736 rows.push({ key: 'h-agents', text: 'Agents', bold: true })
737 for (const row of list.slice(-8)) {
738 const age = ago((row.endedAt ?? now) - row.startedAt)
739 const kind = row.teammate ? 'teammate' : row.type
740 const model = row.model ? ` · ${modelName(row.model)}` : ''
741 rows.push({ key: `a-${row.id}`, text: `${row.status.padEnd(9)} ${row.description.slice(0, 48)} (${kind}${model}, ${age})`,
742 tone: row.status === 'failed' || row.status === 'killed' ? 'bad' : isActive(row.status) ? 'ok' : 'dim' })
743 }
744 }
745
746 const recent = Object.values(await read($, receipts)).sort((x, y) => x.endAt - y.endAt).slice(-5)
747 if (recent.length) {
748 rows.push({ key: 'h-turns', text: 'Last turns', bold: true })
749 for (const r of recent) {
750 rows.push({ key: `t-${r.endAt}`, tone: 'dim',
751 text: `${turnLength(r.durationMs).padStart(8)}${receiptText(r, RECEIPT_PRESETS.full ?? [], columns === undefined ? undefined : columns - 8)}` })
752 }
753 }
754
755 const c = await read($, contextAtom)
756 if (c) {
757 rows.push({ key: 'h-context', text: `What fills the context (local estimate, ${tokens(c.total)} of ${tokens(c.max)})`, bold: true })
758 for (const row of c.rows.slice(0, 7)) {
759 rows.push({ key: `c-${row.name}`, text: `${bar(row.tokens / Math.max(1, c.total))} ${tokens(row.tokens).padStart(5)} ${row.name}` })
760 }
761 if (c.memoryFiles) rows.push({ key: 'c-mem', tone: 'dim', text: `${c.memoryFiles} memory ${c.memoryFiles === 1 ? 'file' : 'files'}, ${tokens(c.memoryTokens)}` })
762 if (c.skills) rows.push({ key: 'c-skills', tone: 'dim', text: `${c.skills.count} skills listed, ${tokens(c.skills.tokens)}` })
763 for (const u of unusedServers(c, t.byTool).slice(0, 4)) {
764 rows.push({ key: `c-mcp-${u.server}`, tone: 'warn', text: `MCP ${u.server}: ${tokens(u.tokens)} of tool definitions, none called this session` })
765 }
766 if (f) rows.push({ key: 'c-forecast', tone: f.turns <= 2 ? 'warn' : undefined, text: `${pct(f.percent)} of the auto-compact threshold; at this rate it compacts in ~${f.turns} turns` })
767 }
768 for (const k of t.compactions.slice(-3)) rows.push({ key: `k-${k.at}`, tone: 'dim', text: compactionText(k) })
769
770 const tools = Object.entries(t.byTool).sort((x, y) => y[1].calls - x[1].calls).slice(0, 6)
771 if (tools.length) {
772 rows.push({ key: 'h-tools', text: `Tools (${t.tools} calls, ${t.failures} failed)`, bold: true })
773 for (const [name, v] of tools) {
774 rows.push({ key: `tool-${name}`, tone: v.failures ? 'warn' : 'dim', text: `${String(v.calls).padStart(4)} ${name}${v.failures ? ` (${v.failures} failed)` : ''}` })
775 }
776 }
777
778 const l = await read($, ledgerAtom)
779 if (l && l.week.some(d => d.turns)) {
780 rows.push({ key: 'h-day', text: 'Today and this week, every session on this machine', bold: true })
781 rows.push({ key: 'd-today', text: `today ${money(l.today.cost)} at API prices · ${l.today.turns} turns · ${l.today.sessions} ${l.today.sessions === 1 ? 'session' : 'sessions'}${l.today.colds ? ` · ${l.today.colds} cold-cache rebuilds` : ''}` })
782 const week = l.week.reduce((sum, d) => sum + d.cost, 0)
783 rows.push({ key: 'd-week', text: `7 days ${spark(l.week.map(d => d.cost))} ${money(week)}` })
784 }
785 rows.push({ key: 'note', tone: 'dim', text: `Pace projects your average rate since each window opened. Totals count only sessions g-line ran in. Layout: ${layoutKeys(optionsAt(seconds)).map(x => x.join(',')).join(' | ')}.` })
786 return rows
787}
788
789async function cardText($: EngineInterface, seconds: number): Promise<string[]> {
790 const s = await read($, snap)
791 const t = await read($, totals)
792 const p = payload(s, seconds, ttl)
793 return cardLines({
794 model: p.model?.display_name,
795 elapsedSeconds: s.startedAt !== undefined ? seconds - s.startedAt / 1000 : undefined,
796 totals: t, cost: s.costUsd,
797 // Weighted by tokens over the whole session, not an average of turns.
798 cacheHit: t.inputTokens ? ((t.cacheRead ?? 0) / t.inputTokens) * 100 : undefined,
799 fiveHour: p.rate_limits?.five_hour?.used_percentage,
800 agentsNow: (await read($, agents)).filter(row => isActive(row.status)).length,
801 today: (await read($, ledgerAtom))?.today,
802 })
803}
804
805async function openPane($: EngineInterface, mode: 'details' | 'card' | 'settings' | 'news'): Promise<Record<string, never>> {
806 await update($, view, () => mode)
807 await Promise.all([pruneLedger($), refreshGit($, true)])
808 try {
809 const usage = await $.session.usage({ breakdown: 'summary' })
810 const b = usage.context.breakdown
811 if (b) {
812 const at = await nowMs($)
813 await update($, contextAtom, () => contextView(b, at))
814 }
815 } catch {
816 // the pane shows the rest
817 }
818 void refreshPulse($)
819 const title = mode === 'card' ? 'g-line card' : mode === 'settings' ? 'g-line settings' : mode === 'news' ? 'Pulse' : 'g-line'
820 // The reader and the settings answer keys: they take the keyboard, asked for by the command.
821 const opened = await $.ui.open({ id: PANE, title, ...(mode === 'news' || mode === 'settings' ? { focus: true as const, rows: 40 } : {}) })
822 if (!opened.isPlaced) $.ui.toast('g-line: widen the terminal to see the pane.', { timeoutMs: 5000 })
823 // Nothing goes back into the conversation: a command's text is read by Claude.
824 return {}
825}
826
827// ------------------------------------------------------------------ pulse
828
829/** Fetches g-line's static headline feed when it is on and the copy is old. Never on the drawing path. */
830async function refreshPulse($: EngineInterface, force = false): Promise<void> {
831 if (!config.pulse) return
832 try {
833 const at = await nowMs($)
834 const held = await read($, pulseAtom)
835 if (!force && held && at - held.fetchedAt < PULSE_TTL_MS) return
836 const stored = (await $.store.get('pulse')) as { fetchedAt?: number; raw?: string } | undefined
837 if (!force && stored?.raw && stored.fetchedAt && at - stored.fetchedAt < PULSE_TTL_MS) {
838 const feed = parseFeed(stored.raw, stored.fetchedAt)
839 if (feed) await update($, pulseAtom, () => feed)
840 return
841 }
842 // One GET of a static file, nothing sent; given up after five seconds.
843 const response = await Promise.race([$.http.fetch(PULSE_URL), $.clock.sleep(5000).then(() => undefined)])
844 const feed = response && response.ok && response.text.length <= 262_144 ? parseFeed(response.text, at) : undefined
845 if (feed) {
846 await $.store.set('pulse', { fetchedAt: at, raw: response?.text })
847 await update($, pulseAtom, () => feed)
848 } else if (!held && stored?.raw && stored.fetchedAt) {
849 // Offline: the last copy fetched, rather than nothing.
850 const old = parseFeed(stored.raw, stored.fetchedAt)
851 if (old) await update($, pulseAtom, () => old)
852 }
853 } catch {
854 // offline or refused: the spinner and pane go on without headlines
855 }
856}
857
858/** The item Pulse's row shows now. */
859async function shownItem($: EngineInterface): Promise<PulseItem | undefined> {
860 const feed = await read($, pulseAtom)
861 if (!feed?.items.length) return undefined
862 return feed.items[(await read($, turnover)) % feed.items.length]
863}
864
865/**
866 * Pulse's row under the prompt, while Pulse is on: whose post, how long ago,
867 * and the headline as a link (cmd-click or ctrl-click opens it where the
868 * terminal draws OSC 8 links). It moves to the next item every 8 seconds.
869 */
870async function pulseLine($: EngineInterface, e: Parameters<EngineInterface['ui']['resolve']>[0], columns: number | undefined): Promise<JSX.Element | null> {
871 if (!config.pulse || config.lines === 0 || columns === undefined) return null
872 const feed = await read($, pulseAtom)
873 if (!feed?.items.length) return null
874 const item = feed.items[(await read($, turnover)) % feed.items.length]
875 const row = pulseRow(item, columns - 4, await nowMs($))
876 if (!item || !row) return null
877 const { Box, Text, Link } = $.ui.resolve(e)
878 return (
879 <Box key="pulse" flexDirection="row">
880 {row.lead ? <Text dimColor>{`${row.lead}: `}</Text> : null}
881 <Link href={item.url} label={row.title} />
882 {row.tail ? <Text dimColor>{row.tail}</Text> : null}
883 </Box>
884 )
885}
886
887/** Moves Pulse's row on to the next item, while there is a row to move. */
888async function turnPulse($: EngineInterface): Promise<void> {
889 if (!config.pulse || config.lines === 0 || (await read($, hidden))) return
890 const feed = await read($, pulseAtom)
891 if (feed && feed.items.length > 1) await update($, turnover, n => n + 1)
892}
893
894/**
895 * Opens a link in the browser where the session runs on this machine: `open`
896 * on macOS, `xdg-open` elsewhere. Over SSH, or when neither works, the link
897 * is copied instead, which reaches the clipboard of the terminal in front of you.
898 */
899async function openLink($: EngineInterface, url: string, surface?: string): Promise<void> {
900 const safe = safeUrl(url)
901 if (!safe) return
902 const remote = !!((await $.env.get('SSH_CONNECTION')) || (await $.env.get('SSH_TTY')))
903 if (!remote) {
904 for (const opener of ['open', 'xdg-open']) {
905 try {
906 const ran = await $.process.run([opener, safe], { timeoutMs: 4000 })
907 if (ran.exitCode === 0) {
908 $.ui.toast(`Opened ${hostOf(safe)} in your browser.`, { timeoutMs: 3000 })
909 return
910 }
911 } catch {
912 // try the next opener
913 }
914 }
915 }
916 const copied = await $.ui.copy({ text: safe, ...(surface ? { surface } : {}) } as never)
917 $.ui.toast(copied.isCopied ? `Link copied (${remote ? 'this session runs over SSH' : 'no browser to open here'}): ${safe}` : safe, { timeoutMs: 8000 })
918}
919
920function hostOf(url: string): string {
921 try {
922 return new URL(url).host.replace(/^www\./, '')
923 } catch {
924 return url
925 }
926}
927
928/** When the reader was last opened, and what it held then: items newer are marked new. */
929const NEWS_SEEN = 'news_seen'
930let seenBefore = 0
931
932/** Posted after the reader was last opened. */
933function isNew(item: PulseItem): boolean {
934 if (!seenBefore || !item.at) return false
935 const at = Date.parse(item.at)
936 return Number.isFinite(at) && at > seenBefore
937}
938
939async function openNews($: EngineInterface): Promise<Record<string, never>> {
940 try {
941 seenBefore = Number((await $.store.get(NEWS_SEEN)) ?? 0) || 0
942 await $.store.set(NEWS_SEEN, await nowMs($))
943 } catch {
944 // every item reads as seen
945 }
946 await update($, newsAt, () => 0)
947 if (!config.pulse) {
948 $.ui.toast('Pulse is off, so there is no news yet. /g-line set pulse on turns it on.', { timeoutMs: 8000 })
949 return {}
950 }
951 await refreshPulse($)
952 return openPane($, 'news')
953}
954
955/** A setting changed from the pane or a command: applied now, kept for later sessions. */
956async function setSetting($: EngineInterface, setting: Setting, value: boolean | string): Promise<void> {
957 await choose($, patchOf(setting, value))
958}
959
960/** Registers the `status` tool Claude can call; from the next prompt on. */
961async function registerStatus($: EngineInterface): Promise<void> {
962 try {
963 // Deferred where tool search is available: Claude sees the name and
964 // loads the description when it looks for such a tool.
965 await $.tool.register({ name: STATUS_TOOL, description: STATUS_DESCRIPTION, inputSchema: STATUS_SCHEMA, isDeferred: true })
966 statusRegistered = true
967 } catch {
968 // the line and the rest work without the tool
969 }
970}
971
972// ---------------------------------------------------------------- command
973
974const HELP = 'Open the g-line pane; `news` reads Pulse, `open` opens the headline shown, `settings` lists every setting, `set <name> on|off`, `reset [name]`, `card`, `off`/`on`'
975
976/** `/g-line [settings|set|reset|card|off|on]`, and the 0.4 name for one release. Nothing goes back into the conversation. */
977async function runCommand($: EngineInterface, e: { command: string; args: string }): Promise<Record<string, never>> {
978 if (e.command === OLD_COMMAND) $.ui.toast('glanceline is now g-line: use /g-line. /glanceline works until the next release.', { timeoutMs: 8000 })
979 const asked = parseCommand(e.args)
980 switch (asked.kind) {
981 case 'show':
982 case 'hide':
983 await update($, hidden, () => asked.kind === 'hide')
984 $.ui.toast(asked.kind === 'hide' ? 'g-line hidden, and quiet until /g-line on.' : 'g-line shown.', { timeoutMs: 5000 })
985 return {}
986 case 'set': {
987 const value = asked.value === 'toggle' ? nextValue(asked.setting, config) : asked.value
988 await setSetting($, asked.setting, value)
989 $.ui.toast(changedText(asked.setting), { timeoutMs: 8000 })
990 return {}
991 }
992 case 'all': {
993 let patch: Record<string, unknown> = {}
994 for (const setting of SETTINGS) if (setting.kind === 'toggle') patch = withPatch(patch, patchOf(setting, asked.value))
995 await choose($, patch)
996 $.ui.toast(`Every g-line toggle is ${asked.value ? 'on' : 'off'}. /g-line reset goes back to your files and the defaults.`, { timeoutMs: 8000 })
997 return {}
998 }
999 case 'reset': {
1000 const held = layers.find(layer => layer.name === CHOSEN)?.raw
1001 let saved = (held && typeof held === 'object' ? held : {}) as Record<string, unknown>
1002 try {
1003 saved = ((await $.store.get(SETTINGS_KEY)) ?? {}) as Record<string, unknown>
1004 } catch {
1005 // what this session holds
1006 }
1007 await keep($, asked.setting ? withoutSetting(saved, asked.setting) : {})
1008 $.ui.toast(asked.setting
1009 ? `${asked.setting.label}: ${said(asked.setting, config)}, from ${originOf(asked.setting, layers)}.`
1010 : 'Choices made with /g-line cleared: your config files, /config and the defaults decide again.', { timeoutMs: 8000 })
1011 return {}
1012 }
1013 case 'error':
1014 $.ui.toast(asked.text, { timeoutMs: 10_000 })
1015 return {}
1016 case 'settings':
1017 return openPane($, 'settings')
1018 case 'news':
1019 return openNews($)
1020 case 'open': {
1021 const item = await shownItem($)
1022 if (item) await openLink($, item.url)
1023 else $.ui.toast(config.pulse ? 'No headline yet: the feed is still on its way.' : 'Pulse is off. /g-line set pulse on turns it on.', { timeoutMs: 6000 })
1024 return {}
1025 }
1026 case 'card':
1027 return openPane($, 'card')
1028 default:
1029 return openPane($, 'details')
1030 }
1031}
1032
1033// -------------------------------------------------------------- register
1034
1035export const register: Register = (on, options) => {
1036 on('session.start', async ($, e, next) => {
1037 startOptions = options as Record<string, unknown>
1038 ;({ config, layers } = await loadConfig($, startOptions))
1039 stamp = await configStamp($)
1040 ttl = await explicitTtl($, config)
1041 if (config.claudeReads) await registerStatus($)
1042 for (const [name, description] of [
1043 [COMMAND, HELP],
1044 [OLD_COMMAND, 'Renamed to /g-line; this name works until the next release'],
1045 ] as const) {
1046 try {
1047 await $.command.register({ name, description, argumentHint: '[news|open|settings|set <name> on|off|reset|card|off|on]' })
1048 } catch {
1049 // the line and toasts work without the command
1050 }
1051 }
1052 void refreshPulse($)
1053 await resync($)
1054 $.clock.every(TICK_MS, () => void heartbeat($))
1055 $.clock.every(PULSE_EVERY_MS, () => void turnPulse($))
1056 return next(e)
1057 })
1058
1059 // A /clear starts a new conversation in the same process, with no
1060 // `session.start`: read the fresh session's figures straight away.
1061 on('session.end', async ($, e, next) => {
1062 if (e.reason === 'clear') $.clock.after(300, () => void resync($))
1063 return next(e)
1064 })
1065
1066 on('session.measure', async ($, e, next) => {
1067 const limits = limitsOf(e.rateLimits)
1068 await update($, snap, s => ({
1069 ...s,
1070 context: { tokens: e.context.tokens, window: e.context.window, percent: e.context.percent },
1071 rateLimits: limits,
1072 costUsd: e.cost?.usd ?? s.costUsd,
1073 peaks: peaksOf(s, limits),
1074 }))
1075 void nudge($)
1076 return next(e)
1077 })
1078
1079 on('turn.start', async ($, e, next) => {
1080 let costAt: number | undefined
1081 let contextAt: number | undefined
1082 try {
1083 const usage = await $.session.usage()
1084 costAt = usage.cost?.usd ?? 0
1085 contextAt = usage.context.tokens
1086 } catch {
1087 // the receipt leaves cost and context out
1088 }
1089 coldThisTurn = false
1090 const startAt = await nowMs($)
1091 const warmBefore = (await read($, snap)).cache?.cached === true
1092 const overlap = (await read($, agents)).some(row => row.background && isActive(row.status))
1093 await update($, turnNow, () => ({ id: e.turnId, startAt, costAt, contextAt, tools: 0, agents: 0, failures: 0,
1094 cacheWrite: 0, warmBefore, overlap }))
1095 return next(e)
1096 })
1097
1098 // Each main-loop request: its model and effort, and whether the prompt
1099 // cache served it. Subagents keep caches of their own and are not counted.
1100 on('turn.step', async function* ($, e, next) {
1101 const started = await nowSeconds($)
1102 const result = yield* next(e)
1103 if (e.agentId === undefined && result.usage) {
1104 const u = result.usage
1105 const cached = u.cache_read_input_tokens + u.cache_creation_input_tokens > 0
1106 const prefix = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens + u.output_tokens
1107 await update($, snap, s => {
1108 let learned = s.cache?.learned
1109 const gap = s.cache ? started - s.cache.lastAt : 0
1110 if (s.cache?.cached && gap > 330 && gap < 3500) {
1111 // After 5+ idle minutes a read means the hour TTL; a full rewrite of
1112 // a large prefix means five minutes.
1113 if (u.cache_read_input_tokens > s.cache.prefix * 0.5) learned = '1h'
1114 else if (u.cache_creation_input_tokens > s.cache.prefix * 0.5) learned = '5m'
1115 }
1116 const life = (learned ?? ttl ?? automaticTtl(s)) === '1h' ? 3600 : 300
1117 if (s.cache?.cached && gap > life && u.cache_creation_input_tokens > s.cache.prefix * 0.5) coldThisTurn = true
1118 return { ...s, model: e.model, effort: typeof e.effort === 'string' ? e.effort : s.effort,
1119 cache: { lastAt: started, cached, prefix, learned } }
1120 })
1121 const cold = coldThisTurn
1122 await update($, turnNow, live => (live ? { ...live, cold: live.cold || cold,
1123 cacheWrite: Math.max(live.cacheWrite ?? 0, u.cache_creation_input_tokens),
1124 contextGrowth: live.contextAt !== undefined ? prefix - live.contextAt : live.contextGrowth } : live))
1125 await update($, totals, t => ({ ...t, cacheRead: (t.cacheRead ?? 0) + u.cache_read_input_tokens,
1126 inputTokens: (t.inputTokens ?? 0) + u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens }))
1127 }
1128 return result
1129 })
1130
1131 // Claude's own read of the budget: the `glance` tool's answer.
1132 on('tool.call', { tool: /^mcp__g-line__status$/ }, async ($, e) => {
1133 if (!config.claudeReads) return { result: STATUS_OFF, text: STATUS_OFF }
1134 const seconds = await nowSeconds($)
1135 const s = await read($, snap)
1136 const c = await read($, contextAtom)
1137 const text = statusJson({
1138 p: payload(s, seconds, ttl), now: seconds, observedAt: s.cache?.lastAt,
1139 compactAt: c?.threshold, compactOn: c ? c.threshold !== undefined : undefined,
1140 section: typeof (e as unknown as Record<string, unknown>).section === 'string' ? String((e as unknown as Record<string, unknown>).section) : undefined,
1141 disabled: await read($, hidden),
1142 })
1143 return { result: text, text }
1144 }).catch(() => ({ result: UNREADABLE, text: UNREADABLE }))
1145
1146 // Observes every tool call (never gates or rewrites one): counts, failures,
1147 // and a toast when the same call fails several times in a row.
1148 on('tool.call', async ($, e, next) => {
1149 if (e.agentId === undefined && e.tool !== 'Agent') await update($, turnNow, live => (live ? { ...live, tools: live.tools + 1 } : live))
1150 const result = await next(e)
1151 if (result.deny !== undefined || e.tool === 'Agent') return result
1152 const failed = result.isError === true
1153 const tool = String(e.tool)
1154 await update($, totals, t => {
1155 const was = t.byTool[tool] ?? { calls: 0, failures: 0 }
1156 return { ...t, tools: t.tools + 1, failures: t.failures + (failed ? 1 : 0),
1157 byTool: { ...t.byTool, [tool]: { calls: was.calls + 1, failures: was.failures + (failed ? 1 : 0) } } }
1158 })
1159 if (failed && e.agentId === undefined) {
1160 const what = target(tool, e as unknown as Record<string, unknown>)
1161 await update($, turnNow, live => (live ? { ...live, failures: (live.failures ?? 0) + 1,
1162 failedTool: live.failedTool === undefined || live.failedTool === tool ? tool : null,
1163 failedTarget: live.failedTarget === undefined || live.failedTarget === what ? what : null } : live))
1164 }
1165 if (config.toolStreak > 0) {
1166 const what = target(tool, e as unknown as Record<string, unknown>)
1167 const key = `${e.agentId ?? 'main'}|${tool}|${what}`
1168 const streak = failed ? (streaks.get(key) ?? 0) + 1 : 0
1169 if (failed) streaks.set(key, streak)
1170 else streaks.delete(key)
1171 if (streak === config.toolStreak) await toast($, loopWarning(tool, what, streak), 12_000)
1172 }
1173 return result
1174 }).catch(($, e, next) => next(e))
1175
1176 on('agent.spawn', async ($, e, next) => {
1177 const result = await next(e)
1178 if (result.agentId === undefined) return result
1179 const at = await nowMs($)
1180 const row: AgentRow = { id: result.agentId, description: e.description, type: e.subagentType, model: result.model,
1181 status: 'running', background: e.background, teammate: e.isTeammate === true, startedAt: at }
1182 await update($, agents, list => [...list.filter(one => one.id !== row.id), row].slice(-50))
1183 await update($, totals, t => ({ ...t, agents: t.agents + 1 }))
1184 if (e.parentAgentId === undefined) {
1185 await update($, turnNow, live => (live ? { ...live, agents: live.agents + 1, overlap: live.overlap || e.background === true } : live))
1186 }
1187 return result
1188 }).catch(($, e, next) => next(e))
1189
1190 on('turn.complete', async ($, e, next) => {
1191 if (e.agentId !== undefined) {
1192 await refreshAgents($)
1193 return next(e)
1194 }
1195 // The closing line is appended while this hook runs: the append waits
1196 // for this promise, so the line is drawn with its receipt.
1197 const ready = receiptOf($, e)
1198 pending = ready
1199 const r = await ready
1200 await update($, turnNow, () => null)hooks/core.ts 435 lines1// glanceline core: turns Claude Code's own session figures into short,
2// width-aware segments, and spots what is about to go wrong.
3//
4// Pure functions only (no `$`, no clock, no I/O), so the tests call them
5// directly against the golden file. The input is shaped like Claude Code's
6// session figures; the mod builds it from `$.session.usage()` and the turns
7// it watches.
8
9export type Tone = 'ok' | 'warn' | 'bad' | 'dim' | 'accent'
10
11export type Payload = {
12 model?: { id?: string; display_name?: string }
13 effort?: { level?: string }
14 fast_mode?: boolean
15 context_window?: {
16 used_percentage?: number | null
17 context_window_size?: number
18 total_input_tokens?: number
19 }
20 rate_limits?: {
21 five_hour?: Window
22 seven_day?: Window
23 spend_limit?: Window & { used_usd?: number; limit_usd?: number; period?: string }
24 }
25 prompt_cache?: {
26 warm?: boolean
27 caching_observed?: boolean
28 ttl?: string
29 expires_at?: number | null
30 recache_tokens_if_cold?: number | null
31 }
32 cost?: {
33 total_cost_usd?: number
34 total_duration_ms?: number
35 total_lines_added?: number
36 total_lines_removed?: number
37 }
38 pr?: { number?: number; review_state?: string; kind?: string }
39 vim?: { mode?: string }
40 git?: { branch?: string; dirty?: boolean } | null
41}
42
43export type Window = { used_percentage?: number; resets_at?: number }
44
45export type Segment = { key: string; priority: number; forms: string[]; tone?: Tone }
46export type Placed = { key: string; text: string; tone?: Tone }
47export type Preset = 'minimal' | 'default' | 'full'
48
49export type Options = {
50 /** Unix seconds. */
51 now: number
52 preset?: Preset
53 /** Custom layout: segment keys, `|` starts a new line. Overrides preset. */
54 segments?: string
55 icons?: 'none' | 'nerd'
56 /** Minutes east of UTC for clock times; the caller's local offset. */
57 utcOffsetMinutes?: number
58}
59
60export type Alert = { id: string; text: string; tone: Tone }
61
62export const SEP = ' · '
63
64/** Added to a segment's priority while it warns, so it outlasts the model name when space runs out. */
65export const URGENT = 50
66
67export const PRESETS: Record<Preset, string> = {
68 minimal: 'model,context,five_hour,cache',
69 default: 'model,context,five_hour,seven_day,spend,cache',
70 full: 'model,context,five_hour,seven_day,spend,cache|git,pr,cost,time,lines,vim',
71}
72
73const NERD: Record<string, string> = {
74 model: '\u{f06a9} ', context: '\u{f035b} ', five_hour: '\u{f0150} ', seven_day: '\u{f00f0} ',
75 spend: '\u{f01c1} ', cache: '\u{f0e57} ', git: '\u{e725} ', pr: '\u{e726} ', cost: '\u{f01c1} ',
76 time: '\u{f051f} ', lines: '\u{f0c7e} ',
77}
78
79// ------------------------------------------------------------ formatting
80
81/** A finite number (booleans and NaN are not), else undefined. */
82export function num(value: unknown): number | undefined {
83 return typeof value === 'number' && Number.isFinite(value) ? value : undefined
84}
85
86/** A short printable string, else undefined. Control characters never pass. */
87export function str(value: unknown, limit = 48): string | undefined {
88 if (typeof value !== 'string') return undefined
89 const clean = value.trim()
90 if (!clean || /[\u0000-\u001f\u007f-\u009f]/.test(clean)) return undefined
91 return clean.slice(0, limit)
92}
93
94/** 4d 10h · 2h 5m · 7m · <1m. Floored: a countdown never overstates. */
95export function duration(seconds: number, compact = false): string {
96 const s = Math.floor(seconds)
97 if (s < 60) return '<1m'
98 const days = Math.floor(s / 86400)
99 const hours = Math.floor((s % 86400) / 3600)
100 const minutes = Math.floor((s % 3600) / 60)
101 const gap = compact ? '' : ' '
102 if (days) return hours ? `${days}d${gap}${hours}h` : `${days}d`
103 if (hours) return minutes ? `${hours}h${gap}${minutes}m` : `${hours}h`
104 return `${minutes}m`
105}
106
107export function tokens(count: number): string {
108 const n = Math.floor(count)
109 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
110 if (n >= 1000) return `${Math.round(n / 1000)}k`
111 return String(n)
112}
113
114export function pct(value: number): string {
115 return `${Math.round(value)}%`
116}
117
118const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
119
120/** 16:40 when within a day of `now`, else Thu 16:40, in the caller's zone. */
121export function clock(at: number, now: number, offsetMinutes = 0): string {
122 const local = new Date((Math.floor(at) + offsetMinutes * 60) * 1000)
123 const hh = String(local.getUTCHours()).padStart(2, '0')
124 const mm = String(local.getUTCMinutes()).padStart(2, '0')
125 return at - now < 20 * 3600 ? `${hh}:${mm}` : `${DAYS[local.getUTCDay()]} ${hh}:${mm}`
126}
127
128export function bar(percent: number, cells = 5): string {
129 const filled = Math.max(0, Math.min(cells, Math.round((percent / 100) * cells)))
130 return '▰'.repeat(filled) + '▱'.repeat(cells - filled)
131}
132
133function pressure(percent: number): Tone | undefined {
134 if (percent >= 95) return 'bad'
135 if (percent >= 80) return 'warn'
136 return undefined
137}
138
139// ------------------------------------------------------------------ pace
140
141/**
142 * A projection rounded to the precision it deserves: to the hour when it is
143 * more than six hours off, to the quarter hour beyond ninety minutes.
144 */
145export function roundEta(eta: number, now: number): number {
146 const ahead = eta - now
147 const step = ahead > 6 * 3600 ? 3600 : ahead > 90 * 60 ? 900 : 60
148 return Math.round(eta / step) * step
149}
150
151export type Pace = { kind: 'full' } | { kind: 'hot'; eta: number } | { kind: 'ok' }
152
153const WINDOW_SECONDS: Record<string, number> = { five_hour: 5 * 3600, seven_day: 7 * 86400 }
154const MIN_ELAPSED: Record<string, number> = { five_hour: 15 * 60, seven_day: 12 * 3600 }
155
156/**
157 * Where the average rate since the window opened lands: past 100% before the
158 * reset (`hot`, with the moment it would), or not (`ok`). Undefined until the
159 * window has run long enough for the rate to mean something.
160 */
161export function pace(kind: string, window: Window | undefined, now: number): Pace | undefined {
162 const used = num(window?.used_percentage)
163 const resets = num(window?.resets_at)
164 const length = WINDOW_SECONDS[kind]
165 if (used === undefined || resets === undefined || length === undefined || resets <= now) return undefined
166 if (used >= 100) return { kind: 'full' }
167 const elapsed = now - (resets - length)
168 if (elapsed < (MIN_ELAPSED[kind] ?? 0) || used < 1) return undefined
169 const eta = now + ((100 - used) * elapsed) / used
170 return eta < resets - 60 ? { kind: 'hot', eta } : { kind: 'ok' }
171}
172
173// -------------------------------------------------------------- segments
174
175function modelSegment(p: Payload): Segment | undefined {
176 const name = str(p.model?.display_name) ?? str(p.model?.id)
177 if (!name) return undefined
178 const level = str(p.effort?.level, 12)
179 const fast = p.fast_mode === true
180 const detail = [level ? `${level} effort` : '', fast ? 'fast' : ''].filter(Boolean).join(', ')
181 const family = name.split(' ')[0] ?? name
182 // The effort stays in every form: it is what you change most often.
183 const forms = detail ? [`${name} (${detail})`, level ? `${name} ${level}` : name, level ? `${family} ${level}` : family] : [name]
184 return { key: 'model', priority: 82, forms, tone: 'accent' }
185}
186
187function contextSegment(p: Payload, full: boolean): Segment | undefined {
188 const w = p.context_window
189 const used = num(w?.used_percentage)
190 if (used === undefined || used < 0) return undefined
191 const size = num(w?.context_window_size)
192 const inside = num(w?.total_input_tokens)
193 const left = size !== undefined && inside !== undefined && size > inside ? size - inside : undefined
194 const forms = [`ctx ${pct(used)}`]
195 if (left !== undefined) forms.unshift(`ctx ${pct(used)} (${tokens(left)} left)`)
196 if (full) forms.unshift(`context ${bar(used)} ${pct(used)}` + (left !== undefined ? ` (${tokens(left)} left)` : ''))
197 return { key: 'context', priority: 90, forms, tone: pressure(used) }
198}
199
200function windowSegment(p: Payload, kind: 'five_hour' | 'seven_day', o: Options, full: boolean): Segment | undefined {
201 const window = p.rate_limits?.[kind]
202 const used = num(window?.used_percentage)
203 if (used === undefined || used < 0) return undefined
204 const [name, short, priority] = kind === 'five_hour' ? ['5h', '5h', 85] : ['week', 'wk', 70]
205 const resets = num(window?.resets_at)
206 const left = resets !== undefined && resets > o.now ? resets - o.now : undefined
207 const state = pace(kind, window, o.now)
208 const head = full ? `${name} ${bar(used)} ${pct(used)}` : `${name} ${pct(used)}`
209 if (state?.kind === 'full') {
210 const after = left !== undefined ? ` (resets in ${duration(left)})` : ''
211 return { key: kind, priority, tone: 'bad',
212 forms: [`${name} limit reached${after}`, `${short} full` + (left !== undefined ? ` (${duration(left, true)})` : ''), `${short} full`] }
213 }
214 if (state?.kind === 'hot') {
215 // `!` and `~` carry the meaning where the line is drawn dim: a warning, and
216 // a projection rather than an appointment.
217 const at = clock(roundEta(state.eta, o.now), o.now, o.utcOffsetMinutes)
218 const reset = resets !== undefined ? ` (resets ${clock(resets, o.now, o.utcOffsetMinutes)})` : ''
219 const soon = state.eta - o.now < 30 * 60
220 return { key: kind, priority, tone: soon ? 'bad' : 'warn',
221 forms: [`${head} ! out ~${at}${reset}`, `${short} ${pct(used)} ! out ~${at}`, `${short} ${pct(used)}!`] }
222 }
223 if (left === undefined) return { key: kind, priority, tone: pressure(used), forms: [head, `${short} ${pct(used)}`] }
224 return { key: kind, priority, tone: pressure(used),
225 forms: [`${head} (resets in ${duration(left)})`, `${short} ${pct(used)} (${duration(left, true)})`, `${short} ${pct(used)}`] }
226}
227
228function spendSegment(p: Payload): Segment | undefined {
229 const spend = p.rate_limits?.spend_limit
230 const used = num(spend?.used_percentage)
231 if (used === undefined || used < 0) return undefined
232 const forms = [`spend ${pct(used)}`]
233 const usd = num(spend?.used_usd)
234 const cap = num(spend?.limit_usd)
235 if (usd !== undefined && cap) {
236 const period = ['daily', 'weekly', 'monthly'].includes(spend?.period ?? '') ? ` ${spend?.period}` : ''
237 forms.unshift(`spend $${Math.round(usd)} of $${Math.round(cap)}${period}`)
238 }
239 return { key: 'spend', priority: 75, forms, tone: pressure(used) }
240}
241
242function cacheSegment(p: Payload, now: number): Segment | undefined {
243 const cache = p.prompt_cache
244 if (!cache || cache.caching_observed !== true) return undefined
245 const expires = num(cache.expires_at)
246 if (cache.warm === true && expires !== undefined && expires > now) {
247 const left = expires - now
248 const soon = left <= (cache.ttl === '1h' ? 300 : 60)
249 return { key: 'cache', priority: 80, tone: soon ? 'warn' : 'ok',
250 forms: left < 60 ? ['cache warm (<1m left)', 'cache <1m'] : [`cache warm (~${duration(left)} left)`, `cache ~${duration(left, true)}`] }
251 }
252 const recache = num(cache.recache_tokens_if_cold)
253 const forms = recache && recache > 0 ? [`cache cold (next turn rebuilds ${tokens(recache)})`, 'cache cold'] : ['cache cold']
254 return { key: 'cache', priority: 80, tone: 'warn', forms }
255}
256
257function costSegment(p: Payload): Segment | undefined {
258 const usd = num(p.cost?.total_cost_usd)
259 if (usd === undefined || usd <= 0) return undefined
260 return { key: 'cost', priority: 50, tone: 'dim', forms: [`$${usd.toFixed(2)} at API prices`, `$${usd.toFixed(2)}`] }
261}
262
263function timeSegment(p: Payload): Segment | undefined {
264 const ms = num(p.cost?.total_duration_ms)
265 if (ms === undefined || ms < 60_000) return undefined
266 return { key: 'time', priority: 45, tone: 'dim', forms: [`${duration(ms / 1000)} session`, duration(ms / 1000, true)] }
267}
268
269function linesSegment(p: Payload): Segment | undefined {
270 const added = num(p.cost?.total_lines_added) ?? 0
271 const removed = num(p.cost?.total_lines_removed) ?? 0
272 if (added <= 0 && removed <= 0) return undefined
273 return { key: 'lines', priority: 40, tone: 'dim', forms: [`+${added} −${removed} lines`, `+${added} −${removed}`] }
274}
275
276function gitSegment(p: Payload): Segment | undefined {
277 return gitSegmentOf(p.git)
278}
279
280/** The branch, `*` when tracked files have changes. */
281export function gitSegmentOf(git: Payload['git'] | undefined): Segment | undefined {
282 const branch = str(git?.branch, 40)
283 if (!branch) return undefined
284 const mark = git?.dirty ? '*' : ''
285 return { key: 'git', priority: 60, forms: [`${branch}${mark}`, `${branch.slice(0, 16)}${mark}`] }
286}
287
288function prSegment(p: Payload): Segment | undefined {
289 const n = num(p.pr?.number)
290 if (n === undefined) return undefined
291 const label = p.pr?.kind === 'mr' ? `MR !${n}` : `PR #${n}`
292 const state = str(p.pr?.review_state, 20)?.replace(/_/g, ' ')
293 return { key: 'pr', priority: 55, forms: state ? [`${label} ${state}`, label] : [label] }
294}
295
296function vimSegment(p: Payload): Segment | undefined {
297 const mode = str(p.vim?.mode, 12)
298 return mode ? { key: 'vim', priority: 30, tone: 'dim', forms: [mode] } : undefined
299}
300
301/** The layout's segment keys, one list per line. */
302export function layoutKeys(o: Options): string[][] {
303 const spec = o.segments && /^[a-z_,| ]+$/.test(o.segments) ? o.segments : PRESETS[o.preset ?? 'default'] ?? PRESETS.default
304 return spec.split('|').map(line => line.split(',').map(key => key.trim()).filter(Boolean)).filter(line => line.length)
305}
306
307/** Segments for each line, in order, with anything unknown left out. */
308export function segments(p: Payload, o: Options): Segment[][] {
309 const full = (o.preset ?? 'default') === 'full' && !o.segments
310 const build: Record<string, () => Segment | undefined> = {
311 model: () => modelSegment(p),
312 context: () => contextSegment(p, full),
313 five_hour: () => windowSegment(p, 'five_hour', o, full),
314 seven_day: () => windowSegment(p, 'seven_day', o, full),
315 spend: () => spendSegment(p),
316 cache: () => cacheSegment(p, o.now),
317 cost: () => costSegment(p),
318 time: () => timeSegment(p),
319 lines: () => linesSegment(p),
320 git: () => gitSegment(p),
321 pr: () => prSegment(p),
322 vim: () => vimSegment(p),
323 }
324 const icons = o.icons === 'nerd'
325 return layoutKeys(o).map(keys => keys.flatMap(key => {
326 const segment = build[key]?.()
327 if (!segment) return []
328 // What is pressing outranks what is merely useful, so it survives a narrow terminal.
329 if (segment.tone === 'warn' || segment.tone === 'bad') segment.priority += URGENT
330 if (icons && NERD[key]) segment.forms = segment.forms.map(form => NERD[key] + form)
331 return [segment]
332 })).filter(line => line.length)
333}
334
335// ---------------------------------------------------------------- layout
336
337/** Display columns. Everything glanceline prints is one column per code point. */
338export function width(text: string): number {
339 return [...text].length
340}
341
342export function truncate(text: string, room: number): string {
343 if (room <= 0) return ''
344 const chars = [...text]
345 return chars.length <= room ? text : chars.slice(0, Math.max(room - 1, 0)).join('') + '…'
346}
347
348/**
349 * One form per segment so the line fits `room` columns. Calm segments narrow
350 * together, one tier at a time, so the line keeps one voice; a segment that
351 * warns keeps its words longer: it narrows to its middle form only once the
352 * calm ones are at their shortest, and to its shortest only after every calm
353 * segment that doesn't fit has gone, lowest priority first. The last one left
354 * is cut.
355 */
356export function fit(items: Segment[], room?: number): Placed[] {
357 if (room === undefined) return items.map(item => ({ key: item.key, text: item.forms[0] ?? '', tone: item.tone }))
358 const live = [...items]
359 const loud = (item: Segment) => item.tone === 'warn' || item.tone === 'bad'
360 const deepest = Math.max(1, ...live.map(item => item.forms.length))
361 let calmTier = 0
362 let loudTier = 0
363 const forms = () => live.map(item => item.forms[Math.min(loud(item) ? loudTier : calmTier, item.forms.length - 1)] ?? '')
364 const dropLowest = () => {
365 let lowest = 0
366 live.forEach((item, index) => { if (item.priority < (live[lowest]?.priority ?? 0)) lowest = index })
367 live.splice(lowest, 1)
368 }
369 while (width(forms().join(SEP)) > room) {
370 if (calmTier < deepest - 1) calmTier += 1
371 else if (loudTier < Math.max(1, deepest - 2)) loudTier += 1
372 else if (live.length > 1 && live.some(item => !loud(item))) dropLowest()
373 else if (loudTier < deepest - 1) loudTier += 1
374 else if (live.length > 1) dropLowest()
375 else break
376 }
377 return live.map((item, index) => ({ key: item.key, text: truncate(forms()[index] ?? '', room), tone: item.tone }))
378}
379
380/** One extra line (a plug-in's): the widest of its forms that fits. */
381export function fitExtra(forms: string[], room?: number): string {
382 const clean = forms.filter(Boolean)
383 if (!clean.length) return ''
384 const chosen = clean.find(form => room === undefined || width(form) <= room) ?? clean[clean.length - 1] ?? ''
385 return room === undefined ? chosen : truncate(chosen, room)
386}
387
388/** Every line, fitted. */
389export function render(p: Payload, o: Options & { columns?: number }): Placed[][] {
390 return segments(p, o).map(line => fit(line, o.columns))
391}
392
393export function plain(lines: Placed[][]): string[] {
394 return lines.map(line => line.map(part => part.text).join(SEP))
395}
396
397// ---------------------------------------------------------------- alerts
398
399/** What deserves a heads-up now: each with an id that is stable per event. */
400export function alerts(p: Payload, o: Options): Alert[] {
401 const found: Alert[] = []
402 const cache = p.prompt_cache
403 const expires = num(cache?.expires_at)
404 if (cache?.caching_observed === true && cache.warm === true && expires !== undefined) {
405 const left = expires - o.now
406 if (left > 0 && left <= (cache.ttl === '1h' ? 300 : 60)) {
407 const recache = num(cache.recache_tokens_if_cold)
408 const stake = recache && recache > 0 ? ` (${tokens(recache)} of context at risk)` : ''
409 found.push({ id: `cache:${Math.floor(expires)}`, tone: 'warn',
410 text: `Claude cache goes cold in ${duration(left)}${stake}. Reply before then to keep it cached; after, the next turn rebuilds it.` })
411 }
412 }
413 for (const [kind, name] of [['five_hour', '5-hour'], ['seven_day', 'weekly']] as const) {
414 const window = p.rate_limits?.[kind]
415 const state = pace(kind, window, o.now)
416 const resets = num(window?.resets_at)
417 if (state?.kind === 'hot' && resets !== undefined) {
418 found.push({ id: `${kind}:${Math.floor(resets)}`, tone: 'warn',
419 text: `At this pace you'll hit your ${name} limit around ${clock(roundEta(state.eta, o.now), o.now, o.utcOffsetMinutes)}. It resets ${clock(resets, o.now, o.utcOffsetMinutes)}.` })
420 }
421 }
422 return found
423}
424
425// ------------------------------------------------------------ model names
426
427/** claude-opus-5-5[1m] -> Opus 5.5; anything unrecognised is returned as is. */
428export function modelName(id: string): string {
429 const match = /^(?:[a-z]+\.)?(?:anthropic\.)?claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(?:-v\d+(?::\d+)?)?(\[1m\])?$/.exec(id)
430 if (!match) return id
431 const [, family = '', major, minor, wide] = match
432 const name = family.charAt(0).toUpperCase() + family.slice(1)
433 return `${name} ${major}${minor ? '.' + minor : ''}${wide ? ' (1M)' : ''}`
434}
435hooks/extras.ts 441 lines1// What only the mod can show: a receipt for each turn, the agents running,
2// when the context will compact, what fills it, repeated tool failures, the
3// day's totals across sessions, a share card and a pace coach. Pure, like
4// core.ts: the mod hands in what the engine reported, these turn it into
5// words, and the tests call them directly.
6
7import { SEP, clock, duration, pace, pct, tokens, width } from './core'
8import type { Segment, Tone, Window } from './core'
9import type { AgentRow, ContextView, Day, Forecast, Ledger, Receipt, Totals } from '../types'
10
11export const EMPTY_TOTALS: Totals = {
12 turns: 0, tools: 0, failures: 0, agents: 0, colds: 0, peakContext: 0, byTool: {}, compactions: [], growth: [],
13 cacheRead: 0, inputTokens: 0,
14}
15
16export function emptyDay(day: string): Day {
17 return { day, cost: 0, turns: 0, tools: 0, sessions: 0, colds: 0, agents: 0 }
18}
19
20const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
21
22// --------------------------------------------------------------- receipts
23
24/** The fields a receipt can show, in the order they are written. */
25export const RECEIPT_FIELDS = ['cost', 'cache', 'context', 'tools', 'agents', 'output'] as const
26export type ReceiptField = (typeof RECEIPT_FIELDS)[number]
27/** `adaptive` picks what the turn did that was out of the ordinary; a field list prints the same fields every time. */
28export type ReceiptSpec = 'adaptive' | ReceiptField[]
29export const RECEIPT_PRESETS: Record<string, ReceiptField[]> = {
30 short: ['cost', 'cache', 'tools'],
31 full: ['cost', 'cache', 'context', 'tools', 'agents', 'output'],
32 off: [],
33}
34
35/** `adaptive`, `short`, `full`, `off`, or a comma list of fields. */
36export function receiptFields(spec: unknown): ReceiptSpec {
37 if (typeof spec !== 'string') return 'adaptive'
38 const name = spec.trim()
39 if (name === 'adaptive') return 'adaptive'
40 const preset = RECEIPT_PRESETS[name]
41 if (preset) return preset
42 const picked = name.split(',').map(s => s.trim()).filter((s): s is ReceiptField => (RECEIPT_FIELDS as readonly string[]).includes(s))
43 return picked.length ? picked : 'adaptive'
44}
45
46/** The share of a request's input the prompt cache served, 0 to 100. */
47export function cacheHit(u: { input_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }): number | undefined {
48 const total = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
49 return total > 0 ? (u.cache_read_input_tokens / total) * 100 : undefined
50}
51
52export function money(usd: number): string {
53 if (usd < 0.005) return '<$0.01'
54 return usd < 100 ? `$${usd.toFixed(2)}` : `$${Math.round(usd)}`
55}
56
57/** One piece of a receipt: its words, and how loud it is (`accent` and up stand out). */
58export type Part = { text: string; tone?: Tone }
59
60/** The turn's cost at API prices, or nothing when background work makes it someone else's too. */
61function costPart(r: Receipt): Part | undefined {
62 return r.cost !== undefined && r.cost > 0 && !r.overlap ? { text: `API ${money(r.cost)}` } : undefined
63}
64
65function cachePart(r: Receipt): Part | undefined {
66 return r.cacheHit !== undefined ? { text: `cache read ${Math.floor(r.cacheHit)}%` } : undefined
67}
68
69/** The receipt's parts for a fixed field list, in field order; zero and unknown figures are left out. */
70export function receiptParts(r: Receipt, fields: readonly ReceiptField[]): Part[] {
71 const parts: Part[] = []
72 for (const field of fields) {
73 const part = field === 'cost' ? costPart(r)
74 : field === 'cache' ? cachePart(r)
75 : field === 'context' && r.context !== undefined && r.context !== 0
76 ? { text: r.context > 0 ? `+${tokens(r.context)} context` : `−${tokens(-r.context)} context` }
77 : field === 'tools' && r.tools > 0 ? { text: plural(r.tools, 'tool') }
78 : field === 'agents' && r.agents > 0 ? { text: plural(r.agents, 'agent') }
79 : field === 'output' && r.output ? { text: `${tokens(r.output)} out` } : undefined
80 if (part) parts.push(part)
81 }
82 return parts
83}
84
85/** A context jump worth a mention: 8k tokens, or 2% of the window when that is more. */
86export function bigJump(r: Receipt): boolean {
87 if (r.context === undefined || r.compacted) return false
88 return r.context >= Math.max(8000, 0.02 * (r.window ?? 0))
89}
90
91/** How much more than the recent median a turn has to cost, and by how many dollars, to be called out. */
92export const OUTLIER = { ratio: 3, dollars: 0.2, history: 5 }
93
94/**
95 * The turn's cost over the median of earlier attributable turns: undefined
96 * until there are `OUTLIER.history` of them, or when the turn's own cost is
97 * not attributable. Fixed when the turn closes, so an old receipt never
98 * changes its mind.
99 */
100export function costRatio(cost: number | undefined, overlap: boolean, earlier: readonly number[]): number | undefined {
101 if (cost === undefined || cost <= 0 || overlap || earlier.length < OUTLIER.history) return undefined
102 const sorted = [...earlier].sort((a, b) => a - b)
103 const middle = sorted.length / 2
104 const median = sorted.length % 2 ? sorted[Math.floor(middle)] ?? 0 : ((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2
105 if (median <= 0 || cost - median < OUTLIER.dollars) return undefined
106 const ratio = cost / median
107 return ratio >= OUTLIER.ratio ? ratio : undefined
108}
109
110/**
111 * What about a turn is worth reading, at most two facts, most telling first:
112 * failed calls, a compaction, a cold cache the turn rebuilt, a big context
113 * jump, a large cache write that the context's growth doesn't explain.
114 * Observations, never diagnoses.
115 */
116export function receiptEvents(r: Receipt): Part[] {
117 const events: Part[] = []
118 if (r.failures !== undefined && r.failures >= 2) {
119 events.push({ tone: 'bad', text: r.failedTool ? `${r.failures} failed ${r.failedTool} calls` : `${r.failures} failed calls` })
120 }
121 if (r.compacted) {
122 const { before, after } = r.compacted
123 events.push({ tone: 'accent', text: before && after !== undefined ? `compacted ${tokens(before)} → ${tokens(after)}` : 'compacted' })
124 }
125 const write = r.cacheWrite ?? 0
126 // The turn's first request came after the cache's lifetime and rewrote most of it.
127 const cold = r.cold === true && write >= 10_000
128 if (cold) events.push({ tone: 'warn', text: `cache cold · rebuilt ${tokens(write)}` })
129 if (bigJump(r)) events.push({ tone: 'accent', text: `+${tokens(r.context ?? 0)} context` })
130 if (!cold && r.warmBefore && write >= 20_000 && write >= Math.max(0, r.context ?? 0) + 10_000) {
131 events.push({ tone: 'warn', text: `cache write ${tokens(write)}` })
132 }
133 return events.slice(0, 2)
134}
135
136/** The first of the turn's unusual facts, if any. */
137export function receiptEvent(r: Receipt): Part | undefined {
138 return receiptEvents(r)[0]
139}
140
141/**
142 * The adaptive receipt: an ordinary turn says what it cost and how much the
143 * cache served; an unusual one leads with what was unusual (at most two
144 * facts), then the cost. A cost several times the recent median says so
145 * after the cost.
146 */
147export function adaptiveParts(r: Receipt): Part[] {
148 const events = receiptEvents(r)
149 const cost = costPart(r)
150 const outlier = cost && r.ratio !== undefined ? { tone: 'accent' as Tone, text: `${Math.round(r.ratio)}x recent` } : undefined
151 if (events.length) return [...events, cost, outlier].filter((p): p is Part => p !== undefined)
152 if (outlier && cost) return [cost, outlier]
153 return [cost, cachePart(r)].filter((p): p is Part => p !== undefined)
154}
155
156export function receiptSpec(r: Receipt, spec: ReceiptSpec): Part[] {
157 return spec === 'adaptive' ? adaptiveParts(r) : receiptParts(r, spec)
158}
159
160/** The parts that fit `room` (with their ` · ` leads), the last dropped first, so the lead fact stays. */
161export function fitParts(parts: readonly Part[], room?: number): Part[] {
162 const kept = [...parts]
163 while (kept.length) {
164 if (room === undefined || width(SEP + kept.map(p => p.text).join(SEP)) <= room) return kept
165 kept.pop()
166 }
167 return []
168}
169
170/** ` · API $0.38 · cache read 94%`, the last parts dropped to fit `room`. */
171export function receiptText(r: Receipt, spec: ReceiptSpec, room?: number): string {
172 const parts = fitParts(receiptSpec(r, spec), room)
173 return parts.length ? SEP + parts.map(p => p.text).join(SEP) : ''
174}
175
176/** The spinner's words for the running turn: cost so far, tools, agents, and failures once there are two. */
177/**
178 * The spinner's words for the running turn: the burn so far (tools, agents,
179 * context added, the 5-hour window), and a warning once calls keep failing,
180 * naming the tool and what it failed on when they were all the same.
181 */
182export function spinnerParts(live: { tools: number; agents: number; failures?: number; failedTool?: string | null;
183 failedTarget?: string | null; contextGrowth?: number }, fiveHour?: number): string[] {
184 const parts: string[] = []
185 if (live.failures && live.failures >= 2) {
186 const on = live.failedTool && live.failedTarget && live.failedTarget !== live.failedTool ? ` on ${live.failedTarget}` : ''
187 parts.push(live.failedTool ? `⚠ ${live.failures}× ${live.failedTool} failures${on}` : `⚠ ${live.failures} failed calls`)
188 }
189 if (live.tools) parts.push(plural(live.tools, 'tool'))
190 if (live.agents) parts.push(plural(live.agents, 'agent'))
191 if (live.contextGrowth !== undefined && live.contextGrowth >= 1000) parts.push(`+${tokensOne(live.contextGrowth)} ctx`)
192 if (fiveHour !== undefined) parts.push(`5h ${pct(fiveHour)}`)
193 return parts
194}
195
196/** `4.1k` under ten thousand, `42k` above: a growth figure that moves visibly while a turn runs. */
197export function tokensOne(count: number): string {
198 return count >= 1000 && count < 10_000 ? `${(count / 1000).toFixed(1)}k` : tokens(count)
199}
200
201/** A turn's length as Claude Code writes it on that line: `11s`, `1m 4s`, `1h 2m 3s`. */
202export function turnLength(ms: number): string {
203 const s = Math.floor(ms / 1000)
204 const h = Math.floor(s / 3600)
205 const m = Math.floor((s % 3600) / 60)
206 const rest = s % 60
207 if (h) return `${h}h ${m}m ${rest}s`
208 if (m) return `${m}m ${rest}s`
209 return `${rest}s`
210}
211
212/** Columns Claude Code's own `✻ Baked for 3s · done 3:45 PM` takes, the glyph's two included. */
213export function turnLineWidth(word: string, durationMs: number, doneAt?: string): number {
214 return 2 + width(`${word} for ${turnLength(durationMs)}${doneAt ? ` · done ${doneAt}` : ''}`)
215}
216
217// ----------------------------------------------------------------- agents
218
219const ACTIVE = new Set(['pending', 'running', 'waiting'])
220const ENDED = new Set(['completed', 'failed', 'killed'])
221
222export const isActive = (status: string) => ACTIVE.has(status)
223export const isEnded = (status: string) => ENDED.has(status)
224
225/** `agents 3 (2 running, 1 waiting)`, only while some are at work. */
226export function agentsSegment(rows: readonly AgentRow[]): Segment | undefined {
227 const active = rows.filter(row => isActive(row.status))
228 if (!active.length) return undefined
229 const running = active.filter(row => row.status !== 'waiting').length
230 const waiting = active.length - running
231 const detail = waiting && running ? ` (${running} running, ${waiting} waiting)` : waiting ? ' waiting' : ' running'
232 return { key: 'agents', priority: 68, forms: [`agents ${active.length}${detail}`, `agents ${active.length}`, `ag ${active.length}`] }
233}
234
235// ------------------------------------------------------------- compaction
236
237/**
238 * When auto-compaction will run: the room left before its threshold over the
239 * average context growth of the last turns. Undefined below 60% of the
240 * threshold, with too little history, or when it is more than 30 turns off.
241 */
242export function forecast(contextTokens: number | undefined, threshold: number | undefined, growth: readonly number[]): Forecast | undefined {
243 if (!contextTokens || !threshold || threshold <= 0) return undefined
244 const percent = (contextTokens / threshold) * 100
245 const recent = growth.slice(-5)
246 if (percent < 60 || recent.length < 2) return undefined
247 const average = recent.reduce((sum, n) => sum + Math.max(0, n), 0) / recent.length
248 if (average <= 0) return undefined
249 const turns = Math.max(1, Math.ceil((threshold - contextTokens) / average))
250 return turns > 30 ? undefined : { turns, percent, threshold }
251}
252
253export function forecastSegment(f?: Forecast | null): Segment | undefined {
254 if (!f) return undefined
255 const tone: Tone | undefined = f.turns <= 2 ? 'warn' : undefined
256 // One turn out is said as "soon": the average of recent growth is rough.
257 if (f.turns <= 1) return { key: 'compact', priority: 72, tone, forms: ['compacts soon', 'compact soon', 'cmp soon'] }
258 return { key: 'compact', priority: 72, tone,
259 forms: [`compacts in ~${f.turns} turns`, `compact ~${f.turns} turns`, `cmp ~${f.turns}`] }
260}
261
262export function compactionText(c: { trigger: string; before?: number; after?: number }): string {
263 const how = c.trigger === 'auto' ? 'automatically' : c.trigger === 'manual' ? 'by /compact' : `by ${c.trigger}`
264 if (c.before && c.after !== undefined) return `Context compacted ${how}: ${tokens(c.before)} → ${tokens(c.after)} tokens.`
265 return `Context compacted ${how}.`
266}
267
268// ----------------------------------------------------------- context view
269
270type Breakdown = {
271 categories: readonly { name: string; tokens: number; kind: string }[]
272 totalTokens: number
273 rawMaxTokens: number
274 autoCompactThreshold?: number
275 isAutoCompactEnabled: boolean
276 mcpTools: readonly { serverName: string; tokens: number; isLoaded: boolean }[]
277 memoryFiles: readonly { tokens: number }[]
278 skills?: { includedSkills: number; tokens: number }
279}
280
281/** The breakdown, cut to what the pane shows: no file paths, no tool names. */
282export function contextView(b: Breakdown, at: number): ContextView {
283 const servers = new Map<string, { tokens: number; tools: number }>()
284 for (const tool of b.mcpTools) {
285 if (!tool.isLoaded) continue
286 const s = servers.get(tool.serverName) ?? { tokens: 0, tools: 0 }
287 servers.set(tool.serverName, { tokens: s.tokens + tool.tokens, tools: s.tools + 1 })
288 }
289 return {
290 at, total: b.totalTokens, max: b.rawMaxTokens,
291 threshold: b.isAutoCompactEnabled ? b.autoCompactThreshold : undefined,
292 rows: b.categories.filter(c => c.kind === 'used' && c.tokens > 0).map(c => ({ name: c.name, tokens: c.tokens, kind: c.kind }))
293 .sort((x, y) => y.tokens - x.tokens),
294 mcp: [...servers].map(([server, s]) => ({ server, ...s })).sort((x, y) => y.tokens - x.tokens),
295 memoryFiles: b.memoryFiles.length,
296 memoryTokens: b.memoryFiles.reduce((sum, f) => sum + f.tokens, 0),
297 skills: b.skills ? { count: b.skills.includedSkills, tokens: b.skills.tokens } : undefined,
298 }
299}
300
301/** The MCP servers whose tool definitions ride in the context but none of whose tools ran. */
302export function unusedServers(view: ContextView, byTool: Totals['byTool']): { server: string; tokens: number }[] {
303 const used = new Set(Object.keys(byTool).filter(t => t.startsWith('mcp__')).map(t => t.split('__')[1]))
304 return view.mcp.filter(s => !used.has(s.server)).map(s => ({ server: s.server, tokens: s.tokens }))
305}
306
307// ------------------------------------------------------------ tool vitals
308
309/** What a call worked on, short: a file's name, a command's first words. */
310export function target(tool: string, input: Record<string, unknown>): string {
311 const pick = (k: string) => (typeof input[k] === 'string' ? (input[k] as string) : undefined)
312 const path = pick('file_path') ?? pick('notebook_path') ?? pick('path')
313 if (path) return path.split('/').filter(Boolean).pop() ?? path
314 const command = pick('command')
315 if (command) return command.trim().split(/\s+/).slice(0, 3).join(' ').slice(0, 40)
316 const other = pick('pattern') ?? pick('url') ?? pick('query') ?? pick('skill')
317 return other ? other.slice(0, 40) : tool
318}
319
320/** The toast after the same call has failed `streak` times in a row. */
321export function loopWarning(tool: string, what: string, streak: number): string {
322 const on = what && what !== tool ? ` on ${what}` : ''
323 return `⚠ ${streak}× ${tool} failures in a row${on}. Worth a look before it burns more of your limit.`
324}
325
326// ----------------------------------------------------------------- ledger
327
328/** `2026-10-13` in the caller's zone. */
329export function dayOf(ms: number, offsetMinutes = 0): string {
330 return new Date(ms + offsetMinutes * 60_000).toISOString().slice(0, 10)
331}
332
333const KEY = /^d:(\d{4}-\d{2}-\d{2}):/
334
335/** Store keys from before `keepDays` days ago. */
336export function staleKeys(keys: readonly string[], today: string, keepDays = 35): string[] {
337 const cutoff = new Date(Date.parse(`${today}T00:00:00Z`) - keepDays * 86_400_000).toISOString().slice(0, 10)
338 return keys.filter(key => { const m = KEY.exec(key); return m !== null && (m[1] ?? '') < cutoff })
339}
340
341/** Today and the last seven days, each summed over the sessions' entries. */
342export function ledger(entries: readonly { key: string; value: unknown }[], today: string): Ledger {
343 const days: string[] = []
344 for (let i = 6; i >= 0; i--) days.push(new Date(Date.parse(`${today}T00:00:00Z`) - i * 86_400_000).toISOString().slice(0, 10))
345 const sums = new Map(days.map(day => [day, emptyDay(day)]))
346 for (const { key, value } of entries) {
347 const day = KEY.exec(key)?.[1]
348 const sum = day ? sums.get(day) : undefined
349 if (!sum || !value || typeof value !== 'object') continue
350 const v = value as Record<string, unknown>
351 const n = (k: string) => (typeof v[k] === 'number' && Number.isFinite(v[k]) ? (v[k] as number) : 0)
352 sum.cost += n('cost'); sum.turns += n('turns'); sum.tools += n('tools'); sum.colds += n('colds'); sum.agents += n('agents')
353 sum.sessions += 1
354 }
355 const week = days.map(day => sums.get(day) ?? emptyDay(day))
356 return { today: week[week.length - 1] ?? emptyDay(today), week }
357}
358
359/** `today $41` (`of $50` with a budget), for the line. */
360export function todaySegment(day: Day | undefined, budget?: number): Segment | undefined {
361 if (!day || day.cost <= 0) return undefined
362 const spent = money(day.cost)
363 if (budget && budget > 0) {
364 const share = (day.cost / budget) * 100
365 const tone: Tone | undefined = share >= 100 ? 'bad' : share >= 80 ? 'warn' : undefined
366 return { key: 'today', priority: 58, tone, forms: [`today ${spent} of ${money(budget)}`, `today ${spent}`] }
367 }
368 return { key: 'today', priority: 58, tone: 'dim', forms: [`today ${spent}`] }
369}
370
371const BLOCKS = '▁▂▃▄▅▆▇█'
372
373/** One block per value, scaled to the largest. */
374export function spark(values: readonly number[]): string {
375 const top = Math.max(0, ...values)
376 return values.map(v => (top <= 0 || v <= 0 ? ' ' : BLOCKS[Math.min(7, Math.round((v / top) * 7))])).join('')
377}
378
379// ------------------------------------------------------------------ coach
380
381const LOWER: Record<string, string> = { max: 'xhigh', xhigh: 'high', high: 'medium' }
382
383/**
384 * Advice for a usage window on pace to run out: how much slower the rest of
385 * the window would have to go to last until it resets, and the one command
386 * that usually gets there (a lower effort, else a lighter model), offered
387 * for Tab to take. It changes nothing itself.
388 */
389export function coach(kind: 'five_hour' | 'seven_day', window: Window | undefined, now: number,
390 effort?: string, offsetMinutes = 0): { text: string; suggest: string } | undefined {
391 const state = pace(kind, window, now)
392 const used = window?.used_percentage
393 const resets = window?.resets_at
394 if (state?.kind !== 'hot' || used === undefined || resets === undefined) return undefined
395 const length = kind === 'five_hour' ? 5 * 3600 : 7 * 86400
396 const rate = used / (now - (resets - length))
397 const affordable = (100 - used) / (resets - now)
398 const cut = Math.round((1 - affordable / rate) * 100)
399 if (cut <= 0) return undefined
400 const lower = effort ? LOWER[effort] : undefined
401 const suggest = lower ? `/effort ${lower}` : '/model'
402 const name = kind === 'five_hour' ? '5-hour' : 'Weekly'
403 const at = (t: number) => clock(t, now, offsetMinutes)
404 // Short enough for a toast's three lines.
405 return {
406 suggest,
407 text: `${name} limit: out at ${at(state.eta)} at this pace, resets ${at(resets)}. To last, use ~${cut}% less per hour: Tab takes ${suggest}.`,
408 }
409}
410
411// ------------------------------------------------------------------- card
412
413export type CardInput = {
414 model?: string
415 elapsedSeconds?: number
416 totals: Totals
417 cost?: number
418 cacheHit?: number
419 fiveHour?: number
420 agentsNow: number
421 today?: Day
422}
423
424/** The share card's lines. No repository, branch, path or account. */
425export function cardLines(c: CardInput): string[] {
426 const t = c.totals
427 const head = [c.model, c.elapsedSeconds !== undefined ? duration(c.elapsedSeconds) : undefined, plural(t.turns, 'turn')]
428 .filter(Boolean).join(SEP)
429 const work = [plural(t.tools, 'tool call'), t.agents ? plural(t.agents, 'agent') : undefined,
430 t.failures ? plural(t.failures, 'failed call') : undefined].filter(Boolean).join(SEP)
431 const cache = [c.cacheHit !== undefined ? `cache read ${pct(c.cacheHit)}` : undefined,
432 plural(t.compactions.length, 'compaction'), plural(t.colds, 'cold-cache rebuild')].filter(Boolean).join(SEP)
433 const lines = ['g-line · session card', head, work, cache]
434 const money_ = [c.cost !== undefined ? `${money(c.cost)} at API prices` : undefined,
435 t.peakContext ? `peak context ${pct(t.peakContext)}` : undefined,
436 c.fiveHour !== undefined ? `5h window ${pct(c.fiveHour)}` : undefined].filter(Boolean).join(SEP)
437 if (money_) lines.push(money_)
438 if (c.today && c.today.sessions > 0) lines.push(`today: ${money(c.today.cost)} over ${plural(c.today.sessions, 'session')} on this machine`)
439 return lines
440}
441hooks/inline.ts 101 lines1// The one line glanceline adds under the prompt: every segment of the layout,
2// then an optional account, fitted to the room left at the end of Claude
3// Code's own hint line. Pure, like core.ts, so tests can call it directly.
4
5import { SEP, fit, layoutKeys, segments, width } from './core'
6import type { Options, Payload, Placed, Segment } from './core'
7
8/**
9 * Columns the engine's permission-mode label takes before the hint when a
10 * mode is on (`⏵⏵ bypass permissions on `, the widest), the hint then reading
11 * `(shift+tab to cycle)`.
12 */
13export const MODE_LABEL = 26
14
15/**
16 * Columns of the label a quieter mode draws before the hint (`⏸ manual mode
17 * on · `). The hint the mod is handed leaves it out, and it stays while you
18 * type, so these columns are always kept free.
19 */
20export const QUIET_MODE_LABEL = 19
21
22/** The hint line's two-column indent, and the three columns the engine keeps free at its right end. */
23const EDGES = 5
24
25/**
26 * The line an `account_command` printed, as one short segment:
27 * `account you@example.com (via CAAM)` reads `you@example.com`.
28 */
29export function accountSegment(line?: string | null): Segment | undefined {
30 const text = line?.trim()
31 if (!text) return undefined
32 const short = text.replace(/^account\s+/i, '').replace(/\s*\([^()]*\)$/, '').trim()
33 return { key: 'account', priority: 65, tone: 'dim', forms: [short || text] }
34}
35
36/**
37 * Columns left for glanceline after the engine's hint (which it joins to the
38 * tail with ` · `), or undefined when the surface has not measured.
39 */
40export function tailRoom(columns: number | undefined, hint: string, shown = hint): number | undefined {
41 if (columns === undefined) return undefined
42 const mode = /shift\+tab/i.test(hint) ? MODE_LABEL : QUIET_MODE_LABEL
43 return columns - EDGES - mode - width(shown) - SEP.length
44}
45
46/**
47 * Claude Code's two standing hints, shortened. Any other hint (`esc to
48 * interrupt`, a background shell's pill, a key to press twice) is left whole.
49 */
50export const SHORT_HINTS: Record<string, string> = { '(shift+tab to cycle)': '⇧⇥', '? for shortcuts': '?' }
51
52/**
53 * The hint line's props with glanceline's text added: after a shortened
54 * standing hint (a rewritten hint replaces the engine's tail, so only when
55 * there is none), else as the tail the engine draws after its own hint.
56 */
57export function withText<P extends { hint: string; tail?: string }>(props: P, text: string, short: boolean): P {
58 if (!text) return props
59 const brief = short && !props.tail ? SHORT_HINTS[props.hint] : undefined
60 if (brief !== undefined) return { ...props, hint: `${brief} · ${text}` }
61 return { ...props, tail: props.tail ? `${props.tail} · ${text}` : text }
62}
63
64/** The hint as it will show: shortened where `withText` would shorten it. */
65export function shownHint(hint: string, short: boolean): string {
66 return (short && SHORT_HINTS[hint]) || hint
67}
68
69/** Keys only the mod can fill, placed where a custom `segments` layout names them. */
70export const MOD_KEYS = ['agents', 'compact', 'today'] as const
71
72/**
73 * Every segment on one line, the account last, fitted to `room`: segments
74 * narrow together and the least important go first. Mod-only segments
75 * (`extras`) stand where the layout names them. Empty under four columns,
76 * where the engine would leave the tail out anyway.
77 */
78export function inline(p: Payload, o: Options, account?: string | null, room?: number,
79 extras: Partial<Record<string, Segment | undefined>> = {}, trailing: Segment[] = []): Placed[] {
80 const core = new Map(segments(p, o).flat().map(segment => [segment.key, segment]))
81 const items: Segment[] = []
82 for (const key of layoutKeys(o).flat()) {
83 const segment = core.get(key) ?? extras[key]
84 if (segment) items.push(segment)
85 }
86 items.push(...trailing)
87 const extra = accountSegment(account)
88 if (extra) items.push(extra)
89 if (!items.length || (room !== undefined && room < 4)) return []
90 return fit(items, room)
91}
92
93/** Columns to keep free for the footer's mode labels at the right of the hint row, with a clear gap before them. */
94export function modesWidth(labels: readonly string[]): number {
95 return labels.length ? width(labels.join(' & ')) + 4 : 0
96}
97
98export function joined(parts: Placed[]): string {
99 return parts.map(part => part.text).join(SEP)
100}
101hooks/deck.ts 157 lines1// The two lines under the prompt. Line 1 rides at the end of Claude Code's own
2// hint line and says how full things are: context, and how much of each usage
3// window is gone. Line 2 sits directly under it, in colour: the model and its
4// effort, then what happens next: when the prompt cache goes cold, when each
5// window resets or runs out, what is running, what compacts.
6//
7// Pure, like core.ts: the mod hands in what it knows, the tests call it.
8
9import { SEP, clock, duration, fit, num, pace, pct, roundEta, str, tokens } from './core'
10import type { Options, Payload, Placed, Segment, Tone } from './core'
11
12/** Added to a segment's priority while it warns, as core's segments do. */
13const URGENT = 50
14
15function urgent(segment: Segment): Segment {
16 return segment.tone === 'warn' || segment.tone === 'bad' ? { ...segment, priority: segment.priority + URGENT } : segment
17}
18
19function pressure(percent: number): Tone | undefined {
20 if (percent >= 95) return 'bad'
21 if (percent >= 80) return 'warn'
22 return undefined
23}
24
25const WINDOW_NAME = { five_hour: '5h', seven_day: 'wk' } as const
26
27// ------------------------------------------------------------------ line 1
28
29/** `Opus 5.5 xhigh`: the effort stays even in the narrowest form. */
30export function modelNext(p: Payload): Segment | undefined {
31 const name = str(p.model?.display_name) ?? str(p.model?.id)
32 if (!name) return undefined
33 const level = str(p.effort?.level, 12)
34 const fast = p.fast_mode === true ? ' fast' : ''
35 const family = name.split(' ')[0] ?? name
36 const forms = level
37 ? [`${name} ${level}${fast}`, `${family} ${level}${fast}`, `${family} ${level}`]
38 : [`${name}${fast}`, family]
39 return { key: 'model', priority: 95, tone: 'accent', forms }
40}
41
42export function contextNow(p: Payload): Segment | undefined {
43 const w = p.context_window
44 const used = num(w?.used_percentage)
45 if (used === undefined || used < 0) return undefined
46 // The window's headroom isn't the room before auto-compact, so the line
47 // shows the percentage alone; the `/g-line` pane says the rest.
48 return { key: 'context', priority: 90, tone: pressure(used), forms: [`ctx ${pct(used)}`] }
49}
50
51/** `5h 64%`, with `!` once the window is on pace to run out (the line is drawn dim). */
52export function windowNow(p: Payload, kind: 'five_hour' | 'seven_day', now: number): Segment | undefined {
53 const window = p.rate_limits?.[kind]
54 const used = num(window?.used_percentage)
55 if (used === undefined || used < 0) return undefined
56 const state = pace(kind, window, now)
57 const name = WINDOW_NAME[kind]
58 const priority = kind === 'five_hour' ? 85 : 70
59 if (state?.kind === 'full') return { key: kind, priority, tone: 'bad', forms: [`${name} full`] }
60 if (state?.kind === 'hot') return { key: kind, priority, tone: 'warn', forms: [`${name} ${pct(used)}!`] }
61 return { key: kind, priority, tone: pressure(used), forms: [`${name} ${pct(used)}`] }
62}
63
64export function spendNow(p: Payload): Segment | undefined {
65 const used = num(p.rate_limits?.spend_limit?.used_percentage)
66 if (used === undefined || used < 0) return undefined
67 return { key: 'spend', priority: 75, tone: pressure(used), forms: [`spend ${pct(used)}`] }
68}
69
70/** Line 1's segments, in order. */
71export function lineNow(p: Payload, o: Options): Segment[] {
72 return [contextNow(p), windowNow(p, 'five_hour', o.now), windowNow(p, 'seven_day', o.now), spendNow(p)]
73 .filter((s): s is Segment => s !== undefined).map(urgent)
74}
75
76// ------------------------------------------------------------------ line 2
77
78/** `cache 58m`, green while warm, amber in its last minutes, then `cache cold`. */
79export function cacheNext(p: Payload, now: number): Segment | undefined {
80 const cache = p.prompt_cache
81 if (!cache || cache.caching_observed !== true) return undefined
82 const expires = num(cache.expires_at)
83 if (cache.warm === true && expires !== undefined && expires > now) {
84 const left = expires - now
85 const soon = left <= (cache.ttl === '1h' ? 300 : 60)
86 // `~`: the mod knows when the last request ran and the cache's lifetime,
87 // not the provider's own expiry.
88 const about = left < 60 ? '<1m' : `~${duration(left, true)}`
89 return { key: 'cache', priority: 90, tone: soon ? 'warn' : 'ok',
90 forms: [`cache warm ${left < 60 ? '<1m' : `~${duration(left)}`}`, `cache ${about}`] }
91 }
92 const recache = num(cache.recache_tokens_if_cold)
93 return { key: 'cache', priority: 90, tone: 'warn',
94 forms: recache && recache > 0 ? [`cache cold, next turn rebuilds ${tokens(recache)}`, 'cache cold'] : ['cache cold'] }
95}
96
97/** When a window resets, or (on pace to run out) roughly when it runs out. */
98export function windowNext(p: Payload, kind: 'five_hour' | 'seven_day', o: Options): Segment | undefined {
99 const window = p.rate_limits?.[kind]
100 const used = num(window?.used_percentage)
101 const resets = num(window?.resets_at)
102 if (used === undefined || used < 0 || resets === undefined || resets <= o.now) return undefined
103 const name = WINDOW_NAME[kind]
104 const state = pace(kind, window, o.now)
105 const at = (t: number) => clock(t, o.now, o.utcOffsetMinutes)
106 const left = resets - o.now
107 // A healthy window's reset is the first thing to go when room is short.
108 const priority = kind === 'five_hour' ? 40 : 35
109 if (state?.kind === 'full') {
110 return { key: `${kind}_next`, priority, tone: 'bad', forms: [`${name} back in ${duration(left)}`, `${name} back ${duration(left, true)}`] }
111 }
112 if (state?.kind === 'hot') {
113 const tone: Tone = state.eta - o.now < 30 * 60 ? 'bad' : 'warn'
114 return { key: `${kind}_next`, priority, tone,
115 forms: [`${name} out ~${at(roundEta(state.eta, o.now))}, resets ${at(resets)}`, `${name} out ~${at(roundEta(state.eta, o.now))}`] }
116 }
117 return { key: `${kind}_next`, priority, forms: [`${name} resets in ${duration(left)}`, `${name} resets ${duration(left, true)}`] }
118}
119
120export function spendNext(p: Payload): Segment | undefined {
121 const spend = p.rate_limits?.spend_limit
122 const usd = num(spend?.used_usd)
123 const cap = num(spend?.limit_usd)
124 if (usd === undefined || !cap) return undefined
125 const period = ['daily', 'weekly', 'monthly'].includes(spend?.period ?? '') ? ` ${spend?.period}` : ''
126 return { key: 'spend_next', priority: 75, forms: [`$${Math.round(usd)} of $${Math.round(cap)}${period}`, `$${Math.round(usd)}/$${Math.round(cap)}`] }
127}
128
129/** Line 2's segments: the model and effort, the cache, the windows' timing, then what the mod adds (agents, compaction, today, branch, account). */
130export function lineNext(p: Payload, o: Options, extras: Segment[] = []): Segment[] {
131 return [modelNext(p), cacheNext(p, o.now), windowNext(p, 'five_hour', o), windowNext(p, 'seven_day', o), spendNext(p), ...extras]
132 .filter((s): s is Segment => s !== undefined).map(urgent)
133}
134
135// ---------------------------------------------------------------- the deck
136
137/** Columns line 2 has: it starts at the row's two-column indent, and the engine keeps three columns free at its end. */
138export function nextRoom(columns: number | undefined): number | undefined {
139 return columns === undefined ? undefined : columns - 5
140}
141
142export type Deck = { now: Placed[]; next: Placed[] }
143
144/**
145 * Both lines, each fitted to its own room. What line 1 has no room for moves
146 * down to line 2 instead of vanishing; line 2 drops its least important
147 * segments only when both lines are full.
148 */
149export function deck(p: Payload, o: Options, rooms: { now?: number; next?: number }, extras: Segment[] = []): Deck {
150 const now = lineNow(p, o)
151 const placed = rooms.now !== undefined && rooms.now < 4 ? [] : fit(now, rooms.now)
152 const kept = new Set(placed.map(part => part.key))
153 const moved = now.filter(segment => !kept.has(segment.key))
154 const next = [...lineNext(p, o, extras), ...moved]
155 return { now: placed, next: rooms.next !== undefined && rooms.next < 4 ? [] : fit(next, rooms.next) }
156}
157hooks/glance.ts 92 lines1// What Claude reads when it calls g-line's `status` tool, which is off unless
2// turned on (`claude_reads: true`): a small JSON snapshot of the session's
3// reported context, the account's usage windows and the estimated prompt-cache
4// expiry. Observations only, with their scope and basis; never advice.
5//
6// Pure, like core.ts; the mod hands in the figures, the tests call it.
7
8import { num, pace, roundEta } from './core'
9import type { Payload } from './core'
10
11export const STATUS_TOOL = 'status'
12
13export const STATUS_DESCRIPTION = [
14 "Read this Claude Code session's reported context use and automatic-compaction headroom, the account's usage windows,",
15 'and the estimated prompt-cache expiry, as g-line observed them.',
16 'Use when the user asks about these figures or explicitly requests a capacity check.',
17 'This tool observes state; it changes no settings. Estimates may be stale and are not task budgets.',
18 'Do not poll, send keep-alive prompts, reduce requested work, or change model, effort or compaction settings',
19 'based on this result without the user\'s authorization.',
20].join(' ')
21
22export const STATUS_SCHEMA = {
23 type: 'object',
24 properties: { section: { type: 'string', enum: ['all', 'context', 'limits', 'cache'], description: 'Which part to read; all by default.' } },
25}
26
27export type StatusInput = {
28 p: Payload
29 /** Unix seconds now, and when the figures were last observed. */
30 now: number
31 observedAt?: number
32 /** Tokens at which auto-compact runs, when it is on; undefined when off or unknown. */
33 compactAt?: number
34 compactOn?: boolean
35 section?: string
36 disabled?: boolean
37}
38
39const iso = (seconds: number | undefined) => (seconds === undefined ? null : new Date(Math.floor(seconds) * 1000).toISOString())
40
41function window(p: Payload, kind: 'five_hour' | 'seven_day', now: number) {
42 const w = p.rate_limits?.[kind]
43 const used = num(w?.used_percentage)
44 if (used === undefined) return null
45 const state = pace(kind, w, now)
46 return {
47 used_percent: Math.round(used),
48 resets_at: iso(num(w?.resets_at)),
49 projected_limit_at: state?.kind === 'hot' ? iso(roundEta(state.eta, now)) : state?.kind === 'full' ? 'reached' : null,
50 }
51}
52
53/** The snapshot as a JSON string. Unknown stays null; nothing is guessed. */
54export function statusJson(s: StatusInput): string {
55 if (s.disabled) return JSON.stringify({ schema: 1, state: 'disabled', note: 'g-line is turned off in this session (/g-line on turns it back on).' })
56 const section = ['context', 'limits', 'cache'].includes(s.section ?? '') ? s.section : 'all'
57 const out: Record<string, unknown> = { schema: 1, captured_at: iso(s.now), observed_at: iso(s.observedAt) }
58 if (section === 'all' || section === 'context') {
59 const w = s.p.context_window
60 const used = num(w?.total_input_tokens)
61 out.context = {
62 scope: 'main_session',
63 used_tokens: used ?? null,
64 window_tokens: num(w?.context_window_size) ?? null,
65 used_percent: num(w?.used_percentage) === undefined ? null : Math.round(num(w?.used_percentage) ?? 0),
66 auto_compact: s.compactOn === false
67 ? { enabled: false }
68 : { enabled: s.compactAt !== undefined ? true : null, threshold_tokens: s.compactAt ?? null,
69 headroom_tokens: s.compactAt !== undefined && used !== undefined ? Math.max(0, s.compactAt - used) : null,
70 basis: 'engine_local_estimate' },
71 }
72 }
73 if (section === 'all' || section === 'limits') {
74 out.limits = {
75 scope: 'account',
76 five_hour: window(s.p, 'five_hour', s.now),
77 seven_day: window(s.p, 'seven_day', s.now),
78 projection_basis: 'rounded_linear_since_window_start',
79 }
80 }
81 if (section === 'all' || section === 'cache') {
82 const c = s.p.prompt_cache
83 const expires = num(c?.expires_at)
84 out.cache = c?.caching_observed
85 ? { scope: 'main_session_last_request', state: c.warm && expires !== undefined && expires > s.now ? 'estimated_warm' : 'estimated_cold',
86 estimated_expires_at: iso(expires), lifetime: c.ttl ?? null, rebuild_tokens_if_cold: num(c.recache_tokens_if_cold) ?? null,
87 basis: 'configured_ttl_and_observed_request' }
88 : null
89 }
90 return JSON.stringify(out)
91}
92hooks/pulse.ts 151 lines1// Pulse: a few AI-dev headlines from g-line's own static feed, for the time a
2// long turn keeps you waiting. Off unless turned on (`/g-line pulse`). When
3// on, the mod fetches one static JSON file now and then; it sends nothing
4// about you, and it never fetches while drawing.
5//
6// Pure: parsing and cleaning the feed. Feed text is untrusted, so every
7// string is stripped of escapes and control characters and cut to length
8// before it can reach the terminal.
9
10import { width } from './core'
11import type { PulseFeed, PulseItem } from '../types'
12
13export type { PulseFeed, PulseItem }
14
15export const PULSE_URL = 'https://raw.githubusercontent.com/lifeofgurpreet/g-line/pulse/pulse.json'
16/** How long a fetched feed is used before the next fetch. */
17export const PULSE_TTL_MS = 45 * 60_000
18/** How long a turn runs before the spinner starts showing headlines. */
19export const PULSE_AFTER_MS = 15_000
20/** How long each headline stays on the spinner and on Pulse's row. */
21export const PULSE_EVERY_MS = 8_000
22/** The longest text the reader shows for one item. */
23export const TEXT_MAX = 600
24/** How many items the reader shows at once. */
25export const READER_PAGE = 5
26
27// CSI and OSC escape sequences, then any C0/C1 control, then bidi and zero-width controls.
28const ESCAPES = /\u001b\[[0-?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)|\u001b[@-_]/g
29const CONTROLS = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]/g
30
31/** Text safe to draw: no escapes or control characters, one line, at most `max` columns. */
32export function clean(value: unknown, max: number): string {
33 if (typeof value !== 'string') return ''
34 const text = value.replace(ESCAPES, '').replace(CONTROLS, ' ').replace(/\s+/g, ' ').trim()
35 const chars = [...text]
36 return chars.length <= max ? text : `${chars.slice(0, Math.max(0, max - 1)).join('').trimEnd()}…`
37}
38
39/** Only plain https links, short enough to be a link and not a payload. */
40export function safeUrl(value: unknown): string | undefined {
41 if (typeof value !== 'string' || value.length > 400) return undefined
42 try {
43 const url = new URL(value)
44 if (url.protocol !== 'https:' || url.username || url.password) return undefined
45 return url.href
46 } catch {
47 return undefined
48 }
49}
50
51const count = (value: unknown): number | undefined =>
52 (typeof value === 'number' && Number.isFinite(value) && value >= 0 ? Math.floor(value) : undefined)
53
54/** The feed's items, cleaned; anything malformed is left out. At most `limit`. */
55export function parseFeed(raw: string, fetchedAt: number, limit = 40): PulseFeed | undefined {
56 let data: unknown
57 try {
58 data = JSON.parse(raw)
59 } catch {
60 return undefined
61 }
62 if (!data || typeof data !== 'object') return undefined
63 const d = data as Record<string, unknown>
64 if (d.schema !== 1 || !Array.isArray(d.items)) return undefined
65 const items: PulseItem[] = []
66 for (const entry of d.items) {
67 if (!entry || typeof entry !== 'object') continue
68 const e = entry as Record<string, unknown>
69 const title = clean(e.title, 100)
70 const url = safeUrl(e.url)
71 const source = clean(e.source, 28)
72 if (!title || !url || !source) continue
73 const text = clean(e.text, TEXT_MAX)
74 const likes = count(e.likes)
75 const points = count(e.points)
76 const author = clean(e.author_name, 40)
77 const story = clean(e.story, 200)
78 const via = clean(e.via, 28)
79 const discussion = safeUrl(e.discussion)
80 items.push({ title, url, source, at: clean(e.published_at, 32) || undefined,
81 ...(text && text !== title ? { text } : {}), ...(likes !== undefined ? { likes } : {}), ...(author ? { author } : {}),
82 ...(story ? { story } : {}), ...(via ? { via } : {}), ...(points !== undefined ? { points } : {}),
83 ...(discussion ? { discussion } : {}) })
84 if (items.length >= limit) break
85 }
86 return { fetchedAt, generatedAt: clean(d.generated_at, 32) || undefined, items }
87}
88
89/** An X post leads with its author and age: `𝕏 @claudeai · 40m`, `· 3h`, `· 2d`. Other items: nothing. */
90export function xLead(item: PulseItem, nowMs?: number): string {
91 if (!item.source.startsWith('𝕏 @')) return ''
92 const at = item.at ? Date.parse(item.at) : Number.NaN
93 if (nowMs === undefined || !Number.isFinite(at)) return item.source
94 const minutes = Math.max(1, Math.floor((nowMs - at) / 60_000))
95 const age = minutes < 60 ? `${minutes}m` : minutes < 1440 ? `${Math.floor(minutes / 60)}h` : `${Math.floor(minutes / 1440)}d`
96 return `${item.source} · ${age}`
97}
98
99/** The headline the spinner shows `elapsedMs` into a turn, cut to `room` columns, or nothing yet. */
100export function tickerText(feed: PulseFeed | null | undefined, elapsedMs: number, room: number, nowMs?: number): string {
101 if (!feed?.items.length || elapsedMs < PULSE_AFTER_MS || room < 24) return ''
102 const item = feed.items[Math.floor((elapsedMs - PULSE_AFTER_MS) / PULSE_EVERY_MS) % feed.items.length]
103 if (!item) return ''
104 // A headline cut to a stub says nothing: drop the source before the title, and skip it if still too narrow.
105 const lead = xLead(item, nowMs)
106 if (lead && room - width(lead) - 2 >= 24) return `${lead}: ${clean(item.title, room - width(lead) - 2)}`
107 if (lead) return clean(item.title, room)
108 const tail = ` (${item.source})`
109 if (room - width(tail) >= 24) return `${clean(item.title, room - width(tail))}${tail}`
110 return clean(item.title, room)
111}
112
113/** An item's age: `40m`, `3h`, `2d`; nothing when the feed gave no time. */
114export function ageOf(item: PulseItem, nowMs: number): string {
115 const at = item.at ? Date.parse(item.at) : Number.NaN
116 if (!Number.isFinite(at)) return ''
117 const minutes = Math.max(1, Math.floor((nowMs - at) / 60_000))
118 return minutes < 60 ? `${minutes}m` : minutes < 1440 ? `${Math.floor(minutes / 60)}h` : `${Math.floor(minutes / 1440)}d`
119}
120
121/** `1.2k` likes and the like: a count a glance can take in. */
122export function countOf(n: number): string {
123 if (n < 1000) return String(n)
124 if (n < 10_000) return `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k`
125 return n < 1_000_000 ? `${Math.round(n / 1000)}k` : `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
126}
127
128/**
129 * Pulse's row under the prompt: who, how long ago, the headline, and a dim
130 * pointer to the reader when there is room. Each part is plain text: the
131 * headline is drawn as a link by the caller. Nothing when too narrow.
132 */
133export function pulseRow(item: PulseItem | undefined, room: number, nowMs: number):
134 { lead: string; title: string; tail: string } | undefined {
135 if (!item || room < 30) return undefined
136 const age = ageOf(item, nowMs)
137 const lead = `${item.source}${age ? ` · ${age}` : ''}`
138 const tailText = ' · /g-line news'
139 const tail = room - width(lead) - 2 - width(tailText) >= 40 ? tailText : ''
140 const titleRoom = room - width(lead) - 2 - width(tail)
141 if (titleRoom < 24) return { lead: '', title: clean(item.title, room), tail: '' }
142 return { lead, title: clean(item.title, titleRoom), tail }
143}
144
145/** The items the reader shows around the selected one: `[start, end)`, at most `size`. */
146export function pageOf(count: number, selected: number, size = READER_PAGE): [number, number] {
147 if (count <= size) return [0, count]
148 const start = Math.min(Math.max(0, selected - Math.floor(size / 2)), count - size)
149 return [start, start + size]
150}
151hooks/settings.ts 319 lines1// Settings: one table every surface reads. The defaults, merging a layer
2// (a config file, /config, a choice made in the pane), the pane's toggles,
3// `/g-line set` and `/g-line settings` all come from SETTINGS below, so a
4// setting added here has a toggle, a command name and an origin at once.
5//
6// Pure: no `$`. The layers are read in register.tsx, lowest first:
7// defaults, the plugin's config.json, ~/.config/glanceline/config.json (0.4),
8// ~/.config/g-line/config.json, /config, then the pane. The highest layer
9// that names a setting is where its value comes from.
10
11import { num } from './core'
12import type { Preset } from './core'
13import { receiptFields } from './extras'
14import type { ReceiptSpec } from './extras'
15
16export type Notify = { cache: boolean; reset: boolean; longTurn: boolean; agents: boolean }
17
18export type Config = {
19 /** 2: line 1 at the end of the hint line, line 2 under it; 1: the hint line only; 0: receipts, spinner and heads-ups, no footer. */
20 lines: 0 | 1 | 2
21 /** Headlines from g-line's feed on long turns' spinner and in the pane; fetches one static file. */
22 pulse: boolean
23 /** Shorten Claude Code's standing hints: `(shift+tab to cycle)` to `⇧⇥`, `? for shortcuts` to `?`. */
24 shortHints: boolean
25 /** The branch on line 2 (two lines; one line names `git` in `segments`). */
26 git: boolean
27 preset: Preset
28 segments?: string
29 icons: 'none' | 'nerd'
30 toasts: boolean
31 cacheTtl: 'auto' | '5m' | '1h'
32 accountCommand?: string[]
33 notify: Notify
34 longTurnSeconds: number
35 resetThreshold: number
36 receipt: ReceiptSpec
37 spinner: boolean
38 footerLabels: boolean
39 coach: boolean
40 /** Give Claude the `status` tool to read these figures when asked (off by default). */
41 claudeReads: boolean
42 toolStreak: number
43 budget?: number
44 exportState: boolean
45}
46
47export const DEFAULTS: Config = {
48 lines: 2, pulse: false, shortHints: true, git: false, preset: 'default', icons: 'none', toasts: true, cacheTtl: 'auto',
49 notify: { cache: true, reset: true, longTurn: false, agents: false },
50 longTurnSeconds: 60, resetThreshold: 80, receipt: 'adaptive', spinner: true, footerLabels: true,
51 coach: false, claudeReads: false, toolStreak: 3, exportState: false,
52}
53
54/** One layer over `base`: config.json's snake_case keys; anything malformed is ignored. */
55export function merge(base: Config, raw: unknown): Config {
56 if (!raw || typeof raw !== 'object') return base
57 const r = raw as Record<string, unknown>
58 const next = { ...base, notify: { ...base.notify } }
59 if ([0, 1, 2, '0', '1', '2'].includes(r.lines as number)) next.lines = Number(r.lines) as 0 | 1 | 2
60 if (r.lines === 'receipts') next.lines = 0
61 if (typeof r.pulse === 'boolean') next.pulse = r.pulse
62 if (typeof r.git === 'boolean') next.git = r.git
63 if (typeof r.short_hints === 'boolean') next.shortHints = r.short_hints
64 if (r.preset === 'minimal' || r.preset === 'default' || r.preset === 'full') next.preset = r.preset
65 // A custom layout is one line's: naming segments picks the one-line layout.
66 if (typeof r.segments === 'string' && r.segments.trim()) {
67 next.segments = r.segments
68 if (r.lines === undefined) next.lines = 1
69 }
70 if (r.icons === 'none' || r.icons === 'nerd') next.icons = r.icons
71 if (typeof r.toasts === 'boolean') next.toasts = r.toasts
72 if (r.cache_ttl === '5m' || r.cache_ttl === '1h' || r.cache_ttl === 'auto') next.cacheTtl = r.cache_ttl
73 const command = r.account_command
74 if (Array.isArray(command) && command.length && command.every(word => typeof word === 'string')) {
75 next.accountCommand = command as string[]
76 }
77 if (typeof r.notify === 'boolean') next.notify = { cache: r.notify, reset: r.notify, longTurn: r.notify && base.notify.longTurn, agents: r.notify && base.notify.agents }
78 else if (r.notify && typeof r.notify === 'object') {
79 const n = r.notify as Record<string, unknown>
80 if (typeof n.cache === 'boolean') next.notify.cache = n.cache
81 if (typeof n.reset === 'boolean') next.notify.reset = n.reset
82 if (typeof n.long_turn === 'boolean') next.notify.longTurn = n.long_turn
83 if (typeof n.agents === 'boolean') next.notify.agents = n.agents
84 }
85 const seconds = num(r.long_turn_seconds)
86 if (seconds !== undefined && seconds >= 5) next.longTurnSeconds = seconds
87 const threshold = num(r.reset_threshold)
88 if (threshold !== undefined && threshold >= 0 && threshold <= 100) next.resetThreshold = threshold
89 if (r.receipt !== undefined) next.receipt = r.receipt === false ? [] : receiptFields(r.receipt)
90 if (typeof r.spinner === 'boolean') next.spinner = r.spinner
91 if (typeof r.footer_labels === 'boolean') next.footerLabels = r.footer_labels
92 if (typeof r.coach === 'boolean') next.coach = r.coach
93 if (typeof r.claude_reads === 'boolean') next.claudeReads = r.claude_reads
94 const streak = num(r.tool_streak)
95 if (streak !== undefined && streak >= 0) next.toolStreak = Math.floor(streak)
96 const budget = num(r.today_budget)
97 if (budget !== undefined && budget > 0) next.budget = budget
98 if (typeof r.export_state === 'boolean') next.exportState = r.export_state
99 return next
100}
101
102// ------------------------------------------------------------- the table
103
104export type Group = 'Show' | 'Heads-ups' | 'Extras'
105
106type Common = {
107 /** The config.json key; `notify.cache` is `{ "notify": { "cache": … } }`. */
108 key: string
109 /** Other names `/g-line set` takes. */
110 aliases: readonly string[]
111 label: string
112 group: Group
113 /** The pane's key for it: unique among the settings view's buttons. */
114 hotkey: string
115 help: string
116}
117
118/** On or off. */
119export type Toggle = Common & { kind: 'toggle'; get: (c: Config) => boolean }
120
121/** One of a few named values, cycled in the pane. */
122export type Choice = Common & { kind: 'choice'; options: readonly string[]; get: (c: Config) => string; words?: Readonly<Record<string, string>> }
123
124export type Setting = Toggle | Choice
125
126const receiptName = (c: Config): string => {
127 if (c.receipt === 'adaptive') return 'adaptive'
128 if (!c.receipt.length) return 'off'
129 const short = receiptFields('short')
130 const full = receiptFields('full')
131 const same = (a: ReceiptSpec, b: ReceiptSpec) => Array.isArray(a) && Array.isArray(b) && a.join() === b.join()
132 return same(c.receipt, short) ? 'short' : same(c.receipt, full) ? 'full' : 'custom'
133}
134
135const toggle = (key: string, label: string, group: Group, hotkey: string, get: (c: Config) => boolean, help: string,
136 aliases: readonly string[] = []): Toggle => ({ kind: 'toggle', key, label, group, hotkey, get, help, aliases })
137
138export const SETTINGS: readonly Setting[] = [
139 { kind: 'choice', key: 'lines', aliases: ['layout'], label: 'Layout', group: 'Show', hotkey: 'l',
140 options: ['2', '1', 'receipts'], get: c => (c.lines === 0 ? 'receipts' : String(c.lines)),
141 words: { 2: 'two lines', 1: 'one line', receipts: 'receipts only' },
142 help: '2: the hint line plus one row under it; 1: the hint line only; receipts: no footer' },
143 { kind: 'choice', key: 'receipt', aliases: ['receipts'], label: 'Receipt', group: 'Show', hotkey: 'r',
144 options: ['adaptive', 'short', 'full', 'off'], get: receiptName,
145 help: 'what each turn\'s closing line adds' },
146 toggle('spinner', 'Spinner extras', 'Show', 's', c => c.spinner, 'the running turn\'s cost and tools on the spinner'),
147 toggle('footer_labels', 'Footer labels', 'Show', 'f', c => c.footerLabels, 'agents and compaction in the footer (one-line layout)', ['footer']),
148 toggle('short_hints', 'Short hints', 'Show', 'h', c => c.shortHints, '`(shift+tab to cycle)` drawn as `⇧⇥`', ['hints']),
149 toggle('git', 'Git branch', 'Show', 'g', c => c.git, 'the branch on line 2', ['branch', 'git_branch']),
150 toggle('toasts', 'Toasts', 'Heads-ups', 't', c => c.toasts, 'heads-ups as toasts in the terminal'),
151 toggle('notify.cache', 'Cold cache', 'Heads-ups', 'k', c => c.notify.cache, 'a notification before the prompt cache goes cold', ['cache', 'cold_cache']),
152 toggle('notify.reset', 'Window reset', 'Heads-ups', 'w', c => c.notify.reset, 'a notification when a usage window resets', ['reset', 'window_reset']),
153 toggle('notify.long_turn', 'Long turn', 'Heads-ups', 'u', c => c.notify.longTurn, 'a notification when a long turn ends', ['long_turn']),
154 toggle('notify.agents', 'Agents', 'Heads-ups', 'a', c => c.notify.agents, 'a notification when a background agent ends', ['agents']),
155 toggle('pulse', 'Pulse', 'Extras', 'p', c => c.pulse, 'AI dev headlines and posts on X; fetches one static file every 45 minutes'),
156 toggle('coach', 'Coach', 'Extras', 'o', c => c.coach, 'offers /effort or /compact for Tab to take; never changes anything'),
157 toggle('claude_reads', 'Claude reads', 'Extras', 'x', c => c.claudeReads, 'gives Claude a read-only `status` tool for these figures', ['status', 'status_tool']),
158 toggle('export_state', 'Export state', 'Extras', 'e', c => c.exportState, 'writes the figures as JSON for tmux and other bars', ['export']),
159]
160
161export const GROUPS: readonly Group[] = ['Show', 'Heads-ups', 'Extras']
162
163const normal = (name: string) => name.trim().toLowerCase().replace(/[-\s]+/g, '_')
164
165/** The setting a name means: its key, an alias, or its label. */
166export function findSetting(name: string): Setting | undefined {
167 const n = normal(name)
168 return SETTINGS.find(s => normal(s.key) === n || s.aliases.includes(n) || normal(s.label) === n)
169}
170
171/** The value in words, for the pane and toasts: `on`, `two lines`. */
172export function said(setting: Setting, c: Config): string {
173 const value = shown(setting, c)
174 return setting.kind === 'choice' ? setting.words?.[value] ?? value : value
175}
176
177/** The value as config.json spells it: `on`/`off` or the choice's name. */
178export function shown(setting: Setting, c: Config): string {
179 return setting.kind === 'toggle' ? (setting.get(c) ? 'on' : 'off') : setting.get(c)
180}
181
182/** What a layer gets to say `setting` is `value`: `{ pulse: true }`, `{ notify: { cache: false } }`. */
183export function patchOf(setting: Setting, value: boolean | string): Record<string, unknown> {
184 if (setting.key.startsWith('notify.')) return { notify: { [setting.key.slice('notify.'.length)]: value } }
185 if (setting.key === 'lines') return { lines: value === '2' || value === '1' ? Number(value) : value }
186 return { [setting.key]: value }
187}
188
189/** A saved layer with a patch laid over it; `notify` is merged field by field. */
190export function withPatch(saved: Record<string, unknown>, patch: Record<string, unknown>): Record<string, unknown> {
191 const next = { ...saved, ...patch }
192 if (patch.notify && typeof patch.notify === 'object') {
193 const was = saved.notify
194 const before = typeof was === 'boolean'
195 ? { cache: was, reset: was, ...(was ? {} : { long_turn: false, agents: false }) }
196 : was && typeof was === 'object' ? was as Record<string, unknown> : {}
197 next.notify = { ...before, ...(patch.notify as Record<string, unknown>) }
198 }
199 return next
200}
201
202/** A saved layer without `setting`: what the layers beneath it then decide. */
203export function withoutSetting(saved: Record<string, unknown>, setting: Setting): Record<string, unknown> {
204 const next = { ...saved }
205 if (setting.key.startsWith('notify.')) {
206 const sub = setting.key.slice('notify.'.length)
207 const was = saved.notify
208 if (typeof was === 'boolean') next.notify = (withPatch({ notify: was }, { notify: {} })).notify
209 if (next.notify && typeof next.notify === 'object') {
210 const rest = { ...(next.notify as Record<string, unknown>) }
211 delete rest[sub]
212 if (Object.keys(rest).length) next.notify = rest
213 else delete next.notify
214 }
215 return next
216 }
217 delete next[setting.key]
218 return next
219}
220
221/** Whether a layer names `setting`. */
222export function names(raw: unknown, setting: Setting): boolean {
223 if (!raw || typeof raw !== 'object') return false
224 const r = raw as Record<string, unknown>
225 if (setting.key.startsWith('notify.')) {
226 if (typeof r.notify === 'boolean') return true
227 return !!r.notify && typeof r.notify === 'object' && typeof (r.notify as Record<string, unknown>)[setting.key.slice(7)] === 'boolean'
228 }
229 if (setting.key === 'lines') return r.lines !== undefined || (typeof r.segments === 'string' && !!r.segments.trim())
230 return r[setting.key] !== undefined
231}
232
233export type Layer = { name: string; raw: unknown }
234
235/** Where a setting's value comes from: the highest layer that names it, else `default`. */
236export function originOf(setting: Setting, layers: readonly Layer[]): string {
237 for (let i = layers.length - 1; i >= 0; i--) {
238 const layer = layers[i]
239 if (layer && names(layer.raw, setting)) return layer.name
240 }
241 return 'default'
242}
243
244/** Whether the value differs from what a fresh install shows. */
245export function changed(setting: Setting, c: Config): boolean {
246 return shown(setting, c) !== shown(setting, DEFAULTS)
247}
248
249/** The next value a press in the pane moves to. */
250export function nextValue(setting: Setting, c: Config): boolean | string {
251 if (setting.kind === 'toggle') return !setting.get(c)
252 const at = setting.options.indexOf(setting.get(c))
253 return setting.options[(at + 1) % setting.options.length] ?? setting.options[0] ?? ''
254}
255
256// --------------------------------------------------------------- command
257
258export type Command =
259 | { kind: 'pane' }
260 | { kind: 'card' }
261 | { kind: 'settings' }
262 | { kind: 'news' }
263 | { kind: 'open' }
264 | { kind: 'show' }
265 | { kind: 'hide' }
266 | { kind: 'set'; setting: Setting; value: boolean | string | 'toggle' }
267 | { kind: 'all'; value: boolean }
268 | { kind: 'reset'; setting?: Setting }
269 | { kind: 'error'; text: string }
270
271const ON = ['on', 'true', 'yes', '1', 'enable', 'enabled']
272const OFF = ['off', 'false', 'no', '0', 'disable', 'disabled']
273
274const unknownName = (name: string) => ({
275 kind: 'error' as const,
276 text: `g-line has no setting "${name}". /g-line settings lists them: ${SETTINGS.map(s => s.key).join(', ')}.`,
277})
278
279/** What `/g-line <args>` asks for. */
280export function parseCommand(args: string): Command {
281 const words = args.trim().split(/\s+/).filter(Boolean).map(w => w.toLowerCase())
282 const [verb = '', ...rest] = words
283 if (verb === '' || verb === 'details') return { kind: 'pane' }
284 if (verb === 'card') return { kind: 'card' }
285 if (verb === 'settings' || verb === 'config' || verb === 'list') return { kind: 'settings' }
286 if ((verb === 'news' || verb === 'read' || verb === 'reader') && !rest.length) return { kind: 'news' }
287 if (verb === 'open' && !rest.length) return { kind: 'open' }
288 if (verb === 'on' && !rest.length) return { kind: 'show' }
289 if (verb === 'off' && !rest.length) return { kind: 'hide' }
290 if (verb === 'reset') {
291 if (!rest.length) return { kind: 'reset' }
292 const setting = findSetting(rest.join('_'))
293 return setting ? { kind: 'reset', setting } : unknownName(rest.join(' '))
294 }
295 // `/g-line set pulse on`, `/g-line toggle pulse`, and the bare `/g-line pulse [on|off]` of 0.5.
296 const named = verb === 'set' || verb === 'toggle' ? rest : words
297 if (!named.length) return { kind: 'error', text: 'Name a setting: /g-line set pulse on. /g-line settings lists them.' }
298 const last = named[named.length - 1] ?? ''
299 const hasValue = named.length > 1 && verb !== 'toggle'
300 const name = hasValue ? named.slice(0, -1).join('_') : named.join('_')
301 const raw = hasValue ? last : 'toggle'
302 if (name === 'all' || name === 'everything') {
303 if (ON.includes(raw)) return { kind: 'all', value: true }
304 if (OFF.includes(raw)) return { kind: 'all', value: false }
305 return { kind: 'error', text: '/g-line set all on, or /g-line set all off.' }
306 }
307 const setting = findSetting(name)
308 if (!setting) return unknownName(name.replace(/_/g, ' '))
309 if (raw === 'toggle') return { kind: 'set', setting, value: 'toggle' }
310 if (setting.kind === 'toggle') {
311 if (ON.includes(raw)) return { kind: 'set', setting, value: true }
312 if (OFF.includes(raw)) return { kind: 'set', setting, value: false }
313 return { kind: 'error', text: `${setting.key} is on or off.` }
314 }
315 const choice = raw === 'two' ? '2' : raw === 'one' ? '1' : raw
316 if (setting.options.includes(choice)) return { kind: 'set', setting, value: choice }
317 return { kind: 'error', text: `${setting.key} is one of ${setting.options.join(', ')}.` }
318}
319types/index.d.ts 185 lines1/** What the mod has seen of the session, kept by the host across reloads. */
2export type Snap = {
3 /** Model id the last main-loop request named. */
4 model?: string
5 effort?: string
6 context?: { tokens?: number; window?: number; percent?: number }
7 rateLimits?: { kind: string; percentUsed: number; resetsAt?: number }[]
8 costUsd?: number
9 /** Milliseconds since the epoch. */
10 startedAt?: number
11 cache?: {
12 /** Unix seconds the last main-loop request started. */
13 lastAt: number
14 /** Whether that response reported cache tokens. */
15 cached: boolean
16 /** Tokens the next request rewrites if the cache is cold. */
17 prefix: number
18 /** TTL learned from a hit or miss after an idle gap. */
19 learned?: '5m' | '1h'
20 }
21 git?: { branch?: string; dirty?: boolean } | null
22 account?: string | null
23 /** Highest percent seen per usage window, keyed `<kind>:<resets at>`. */
24 peaks?: Record<string, number>
25}
26
27/** One finished main-loop turn, drawn at the end of its "Baked for 3s" line. */
28export type Receipt = {
29 /** Dollars the session's cost ledger grew by during the turn (API prices). */
30 cost?: number
31 /** Share of the turn's input read from the prompt cache, 0 to 100. */
32 cacheHit?: number
33 /** Context tokens the turn added (negative after a compaction). */
34 context?: number
35 tools: number
36 agents: number
37 /** Output tokens the main loop produced. */
38 output?: number
39 /** Milliseconds since the epoch the turn ended. */
40 endAt: number
41 durationMs: number
42 /** Background work was still running when the turn ended. */
43 background: boolean
44 /** The largest prompt-cache write of one main-loop request in the turn. */
45 cacheWrite?: number
46 /** Whether the session had a cache before the turn, so a first write is no news. */
47 warmBefore?: boolean
48 /** Failed tool calls in the main loop; `tool` names the tool when they were all one. */
49 failures?: number
50 failedTool?: string
51 /** A background agent overlapped the turn, so the session's cost growth is not the turn's alone. */
52 overlap?: boolean
53 /** The turn's first request rebuilt a prompt cache that had gone cold. */
54 cold?: boolean
55 /** A compaction ran during the turn, with its size before and after. */
56 compacted?: { before?: number; after?: number }
57 /** The context window size, for what counts as a big jump. */
58 window?: number
59 /** The turn's cost over the median of the recent attributable turns, fixed when it closed. */
60 ratio?: number
61}
62
63/** The turn running now, for the spinner. */
64export type TurnLive = {
65 id: string
66 startAt: number
67 costAt?: number
68 contextAt?: number
69 tools: number
70 agents: number
71 failures?: number
72 /** The tool that failed, while every failure was the same tool; null once they differ. */
73 failedTool?: string | null
74 /** What the failing calls worked on (a file's name, a command's first words), while it was the same. */
75 failedTarget?: string | null
76 /** Context tokens the turn has added so far, from its latest request. */
77 contextGrowth?: number
78 cacheWrite?: number
79 warmBefore?: boolean
80 cold?: boolean
81 overlap?: boolean
82 compacted?: { before?: number; after?: number }
83}
84
85export type AgentRow = {
86 id: string
87 description: string
88 type: string
89 model?: string
90 status: string
91 background: boolean
92 teammate: boolean
93 /** Milliseconds since the epoch. */
94 startedAt: number
95 endedAt?: number
96}
97
98export type Compaction = { at: number; trigger: string; before?: number; after?: number }
99
100export type Forecast = { turns: number; percent: number; threshold: number }
101
102/** What the session has done, for the pane and the card. */
103export type Totals = {
104 turns: number
105 tools: number
106 failures: number
107 agents: number
108 /** Turns whose first request rebuilt a cold prompt cache. */
109 colds: number
110 peakContext: number
111 /** Per tool: calls and failures. */
112 byTool: Record<string, { calls: number; failures: number }>
113 compactions: Compaction[]
114 /** Context growth of the last main-loop turns, for the forecast. */
115 growth: number[]
116 /** Main-loop input tokens read from the cache, and all main-loop input tokens, for a weighted hit rate. */
117 cacheRead?: number
118 inputTokens?: number
119}
120
121/** The context window by category, as /context counts it locally. */
122export type ContextView = {
123 at: number
124 total: number
125 max: number
126 threshold?: number
127 rows: { name: string; tokens: number; kind: string }[]
128 mcp: { server: string; tokens: number; tools: number }[]
129 memoryFiles: number
130 memoryTokens: number
131 skills?: { count: number; tokens: number }
132}
133
134/** One day's figures, summed over every session the mod ran in on this machine. */
135export type Day = { day: string; cost: number; turns: number; tools: number; sessions: number; colds: number; agents: number }
136
137export type Ledger = { today: Day; week: Day[] }
138
139/** One headline from g-line's Pulse feed, cleaned. */
140export type PulseItem = {
141 title: string
142 url: string
143 source: string
144 at?: string
145 /** The whole post or summary, for the reader; the title when the feed has none. */
146 text?: string
147 likes?: number
148 /** The author's display name on X (`Claude`), beside the handle in `source`. */
149 author?: string
150 /** The story a post on X is about, or what it quotes. */
151 story?: string
152 /** Who found a publisher's item (`Techmeme`), when `source` is the publisher. */
153 via?: string
154 /** Hacker News points, and its comments page. */
155 points?: number
156 discussion?: string
157}
158
159/** The feed as last fetched. */
160export type PulseFeed = { fetchedAt: number; generatedAt?: string; items: PulseItem[] }
161
162declare module 'claude-code' {
163 interface PluginState {
164 'g-line': {
165 snap: Snap
166 tick: number
167 seen: string[]
168 hidden: boolean
169 receipts: Record<string, Receipt>
170 turn: TurnLive | null
171 agents: AgentRow[]
172 totals: Totals
173 forecast: Forecast | null
174 context: ContextView | null
175 ledger: Ledger | null
176 view: 'details' | 'card' | 'settings' | 'news'
177 pulse: PulseFeed | null
178 /** Which headline Pulse's row under the prompt shows: it moves on every few seconds. */
179 turnover: number
180 /** The reader's selected item. */
181 newsAt: number
182 }
183 }
184}
185