SLOPSHOPPER

g-line

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…

newpanespinnerguardcommandtoast
v0.6.1MITupdated 2026-10-16lifeofgurpreet/g-line
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · g-line
│ ┃ g-line ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ l: Layout two lines r: Receipt adaptive p: │ g-line │ │ ┃ Opus 5.5 ⏺ Read(src/auth.ts) │ glanceline is now g-line: use /g-line. │ │ ┃ context ▰▰▱▱▱ 49% (103k left) ⎿ Read 6 lines │ /glanceline works until the next release. │ │ ┃ 5h ▰▰▱▱▱ 31% ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ $0.42 at API prices ⎿ Added 2 lines, removed 1 line │ ┃ 30m session ⏺ Bash(bun test) │ ┃ Tools (9 calls, 1 failed) ⎿ 3 pass, 1 fail │ ┃ 4 Bash (1 failed) │ ┃ 2 Write ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ 1 Read │ ┃ 1 Grep ✻ Worked for 42s · done 4:20 PM │ ┃ 1 Edit │ ┃ Pace projects your average rate since each › /g-line │ ┃ window opened. Totals count only sessions │ ┃ g-line ran in. Layout: │ ┃ model,context,five_hour,seven_day,spend,cach │ ┃ e. │ ┃ [ Share card ] │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ⟨Claude Code's own drawing⟩ Opus 5.5

Draws

Pane · g-line
l: Layout two lines r: Receipt adaptive p: Pulse off s: A Opus 5.5 context ▰▰▱▱▱ 49% (103k left) 5h ▰▰▱▱▱ 31% $0.42 at API prices 30m session Tools (9 calls, 1 failed) 4 Bash (1 failed) 2 Write 1 Read 1 Grep 1 Edit Pace projects your average rate since each window opened. Totals count only sessions g-line ran in. Layout: model,context,five_hour,seven_day,spend,cache. [ Share card ]
Prompt hint
⟨Claude Code's own drawing⟩ Opus 5.5
README

g-line

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

A real Claude Code session with g-line: an ordinary turn closes with "API $0.22 · cache read 49%"; while it runs, the spinner shows its burn and a Pulse headline; over five idle minutes the cache countdown on the second line runs down, a heads-up warns before it goes cold, and it goes cold; the next turn compares two API schemas and closes with "cache cold · rebuilt 42k · API $0.31"

What it does

  • The unusual turn identifies itself. The line that closes each turn (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.
  • The warning stays with the work. While a turn runs, the spinner shows its burn (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.
  • What matters stays visible. Your gauges ride at the end of Claude Code's own hint line (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.
  • A heads-up before the cache goes cold. A native notification five minutes before a one-hour prompt cache expires, with what's at stake: 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.

A schema comparison closes with "Baked for 43s · done 1:22 PM · cache cold · rebuilt 42k · API $0.31"; under the prompt, the hint line ends with "ctx 4% · 5h 1% · wk 80%!" and the coloured line reads "Opus 5.5 high · cache warm ~4m · 5h resets in 2h 47m · wk out ~Thu 19:00, resets Fri 16:00"

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.

Why a mod

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:

  • the receipt on each turn's own closing line, kept beside that turn in the terminal;
  • the burn and failures on the working spinner, while the turn runs;
  • the gauges at the end of the existing hint line, so the footer grows by one row, not two or three;
  • each request's cache reads and writes, every tool call and its result, subagents, and compactions, read from the engine as they happen rather than from the transcript file;
  • a /g-line command that runs without a Claude turn, a pane with buttons, and native notifications through your own notification channel.

How it compares

Read from each project's README on 2026-10-14. "Not documented" means not in its docs, not impossible.

g-lineclaude-hudccstatuslineclaude-powerlineccusage
What it isClaude Code modPlugin that sets up a statusLine commandstatusLine commandstatusLine commandUsage-report CLI with a statusline command
Where it drawsThe hint line, one row under it, the spinner, each turn's closing lineIts status lines (two by default)Its status lines, configurableIts status lines, powerline styleIts status line, and separate reports
Receipt on each turn's own lineYesNot documentedNot documentedNot documentedNot documented
Model and effortYesYes (effort optional)Yes (widget)YesWhen Claude Code supplies it
Prompt-cache countdownYes, an estimateOptionalYes (widget)OptionalNot documented
Limits with paceProjected run-out timeUsage windows, pace coloursUsage and reset widgetsUsage with an even-spend pace markerBlock cost, time and burn rate
HistoryReceipts in scrollback; today and this week in the paneOptional cost ledgerSession and compaction widgetsToday and month budgetsDaily, monthly and session reports
Where activity comes fromEngine eventsStatus data; optional lines read the transcriptStatus data, transcript and usage API, by widgetStatus data and local transcripts, by featureLocal usage records
Pick it toSee which turn changed things, beside the workGet a configurable session overviewGet extensive widget customisationGet a styled layout with budgetsGet 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.

What you see

The receipt

ReceiptWhen
API $0.22 · cache read 49%An ordinary turn: its cost at API prices and the share of its input the cache served
+86k contextThe turn grew the context by at least 8k tokens, or by 2% of the window when that is more
cache cold · rebuilt 42kThe turn's first request came after the cache's lifetime and rewrote it
cache write 84kA request wrote at least 20k tokens to the cache, well beyond what the context grew
3 failed Bash callsTwo or more tool calls failed in the turn (named when they were all one tool)
compacted 182k → 31kThe context was compacted during the turn
API $1.42 · 4x recentAt 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.

The two lines under the prompt

Line 1, at the end of Claude Code's hint line, drawn dim by Claude Code:

SegmentExampleWhat it means
contextctx 13%How full the context window is, from the last response
5h, week5h 1%, wk 79%!Your usage windows. ! means on pace to run out before the reset
spendspend 83%Behind a Claude gateway that sets a spend limit

Line 2, g-line's own row, in colour:

SegmentExampleWhat it means
modelOpus 5.5 highThe model and the effort of the last request (fast when fast mode is on)
cachecache ~59m, cache coldHow 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
timingwk out ~Thu 19:00, resets Fri 16:00When a window on pace to run out gets there (rounded: it's a projection), else when it resets
agents, compactionagents 2 running, compacts in ~3 turnsWhile 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.

Line 1 and line 2 at 100 columns At 80 columns

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.

The spinner

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.

The spinner mid-turn: Sprouting… · 2 tools · +1.2k ctx · 5h 1%

Pulse (off unless you turn it on)

/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.

Pulse on a long turn: Churning… · 1 tool · 5h 9% │ 𝕏 @claudeai · 6d: Claude Dashboards and Claude Motion are in b…

The pane, settings and share card

/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.

The /g-line pane

Configure

/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
}
KeyValuesDefault
lines2, 1, or receipts (receipts, spinner and heads-ups only)2
receiptadaptive, short (cost, cache, tools), full, off, or a list of cost, cache, context, tools, agents, outputadaptive
pulsetrue fetches the headline feed (see Pulse)false
gittrue puts your branch on line 2; g-line then runs git status in your projectfalse
notifytrue, 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
toaststrue, falsetrue
spinnertrue, false: the burn on the spinnertrue
tool_streakFailures in a row of the same call before the toast; 0 turns it off3
today_budgetDollars at API prices; today's total across sessions joins line 2 and a toast says when you pass itnone
coachtrue 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 itselffalse
claude_readstrue gives Claude a status tool to read these figures when you ask it to (see below)false
footer_labelstrue, false: agents and compaction in the footer (one-line layout)true
short_hintstrue draws (shift+tab to cycle) as ⇧⇥true
export_statetrue writes the figures as JSON under ~/.cache/g-line/state/ for tmux and other barsfalse
reset_threshold, long_turn_seconds, cache_ttl, icons, segments, preset, account_commandSee 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.

Let Claude read the figures (opt-in, experimental)

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.

Privacy and what it runs

  • Reads: the figures Claude Code hands every mod (usage, turns, tool calls and their results' success, subagents, compactions). It doesn't read the transcript file.
  • Keeps: per session per day, numbers only (cost at API prices, turns, tool calls, cold rebuilds, agents) in Claude Code's plugin store, deleted after 35 days; your choices made with /g-line; when you last opened the news reader; and, with Pulse on, the last feed.
  • Runs: 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.
  • Sends: nothing, unless Pulse is on (one GET of a static file) or you enable claude_reads (the snapshot goes into your conversation).
  • Writes: a one-line timestamp at ~/.cache/glanceline/live/<session id> so an older status line command can step aside, and with export_state, a JSON file for other bars.

Upgrading from glanceline 0.3 or 0.4

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.

Develop

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.

License

MIT

Source 9 files
hooks/register.tsx 1559 lines
1// 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 lines
1// 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}
435
hooks/extras.ts 441 lines
1// 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}
441
hooks/inline.ts 101 lines
1// 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}
101
hooks/deck.ts 157 lines
1// 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}
157
hooks/glance.ts 92 lines
1// 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}
92
hooks/pulse.ts 151 lines
1// 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}
151
hooks/settings.ts 319 lines
1// 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}
319
types/index.d.ts 185 lines
1/** 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