SLOPSHOPPER

Limits Status Line

Status line for Claude Code: the 5-hour and weekly plan usage limits with recent pace, usage projected to the reset, reset countdown and a red warning with the…

newbandprocesstimer
★ 2v?MITupdated 2026-10-23reylukakozlowski/limits-statusline
A shopper browsing a rack in a slop shop
README

Limits Status Line

Limits Status Line: a Claude Code status line for the 5-hour and weekly usage limits, with the context, 7-day and 5-hour lines of a sample session

A Claude Code status line plugin for your claude.ai plan's 5-hour and weekly usage limits. It answers, at a glance, "how close am I to my limits, and will I hit them before they reset?": for the 5-hour and the weekly (7-day) limit it shows the usage now and where your current pace takes it by the reset, the time and clock time of the reset, and the pace itself. When that pace would pass 100% before the window resets, the limit turns red and says when it runs out. Next to the limits it shows the session's cost, runtime, prompt-cache hit ratio and how long the cache stays warm, effort and lines changed, the project, git branch and model, and how full the context window is. The banner above shows the five lines of a sample session.

It works in both places Claude Code runs on your computer, on macOS, Linux and Windows alike:

  • Terminal — the classic status line at the bottom of the screen; the context history is a braille line.
  • Claude desktop app (Code tab) — a band above the prompt, drawn by the plugin's mod in the app's monospaced face, with the context history as a real vector line.

It is a Claude Code plugin only: claude.ai chat and Cowork have no status line, so it adds nothing there.

Requirements

FeatureNeeds
Operating systemOne that Claude Code supports: macOS 13+, Windows 10 1809+ or Linux. The automated tests run on macOS, Linux (Ubuntu) and Windows (Tests workflow)
Terminal status lineClaude Code with status line support and Node.js 20+ on PATH (on macOS, for example, from nodejs.org, Homebrew or nvm)
Branch in the headergit 2.15 or later on PATH; on macOS it comes with the Xcode Command Line Tools (xcode-select --install). Without it the terminal line says no git
Desktop bandClaude Code 2.1.290 or later (the desktop app updates its own copy); the monospaced face from 2.1.299. No Node.js needed
Weekly (7-day) and 5-hour limitsA claude.ai Pro or Max subscription; the limits appear after the session's first response
Prompt-cache ratio in the terminalExact with Claude Code 2.1.251+, estimated from the last request on older versions

Install

Install it from one source only, Anthropic's directory or GitHub, so that one copy draws the line.

From Anthropic's directory

Once the plugin is listed in Anthropic's directory, add Limits Status Line in claude.ai under Customize → Plugins, or browse the directory with /plugin directory in Claude Code 2.1.287 or later. Claude Code loads it at the next session start as limits-statusline@synced.

From GitHub

From a shell, on any Claude Code version with plugins:

claude plugin marketplace add reylukakozlowski/limits-statusline
claude plugin install limits-statusline@sealoop

Newer versions can do both steps in one line: /plugin install limits-statusline --marketplace reylukakozlowski/limits-statusline inside a session (Claude Code 2.1.275+), or claude plugin install limits-statusline --marketplace reylukakozlowski/limits-statusline from a shell (2.1.292+). In the desktop app: click + next to the prompt box, then Plugins, Add marketplace with reylukakozlowski/limits-statusline, then add limits-statusline.

Turn on the terminal status line

A plugin cannot switch the status line on by itself, so run this once in Claude Code:

/limits-statusline:setup

It writes statusLine into your settings.json (in CLAUDE_CONFIG_DIR when you set one, else ~/.claude). Your previous status line, if you had one, is saved and comes back with /limits-statusline:setup --uninstall. Add --dry-run to see what would change without writing anything. The command runs only when you type it, and Claude Code pre-approves only the plugin's own setup script for it. The desktop band needs no setup.

Uninstall

Run /limits-statusline:setup --uninstall before removing the plugin: it puts your previous status line back (or removes the entry). Then remove the plugin where you added it: in claude.ai under Customize → Plugins for the directory, or with claude plugin uninstall limits-statusline@sealoop for GitHub. Removing or disabling the plugin first leaves settings.json pointing at a runtime that no longer exists, so the bottom line goes blank; your saved status line is still in ~/.claude/limits-statusline/previous-statusline.json and the command above restores it as long as the plugin is installed.

Use cases

  1. Turn the status line on, after a look at what changes. Type /limits-statusline:setup --dry-run: it prints the statusLine command it would write and writes nothing. Then type /limits-statusline:setup: it writes the entry, saves the status line you had, and the five lines appear at the bottom of the terminal after your next message.
  2. See whether the 5-hour limit runs out before it resets. Work as usual and read the 5h line:
   5h  ■■■■■■■■■■■□□□□□ 70% → 117%  ↻ 2h00m 16:20      ↗ 23.3%/h  ⚠ 100% in 1h17m

Over the last half hour you used 23.3% an hour. At that pace the two hours left until the reset at 16:20 add about 47%, so the window would end near 117%: the line is red, and the limit runs out in 1h17m, well before the reset. That is the moment to switch to a smaller model or a lower effort, or to plan the rest of the work around the reset. Slow down and the pace, the projection and the colour follow within half an hour. Below 80% projected the line is green, from 80% to 100% yellow.

  1. Keep the context window in view during a long session. The ctx bar shows how full the context window is, with the tokens used and the window size (543k/1.0M), and the line next to it shows how it moved. When the bar turns red (above 80%), /compact or /clear before Claude Code compacts on its own.
  2. Put your old status line back. Type /limits-statusline:setup --uninstall: it restores the status line you had before setup, or removes the entry if you had none.
  3. Choose where the band appears. Open /config (or the plugin's settings in the desktop app) and set band to all to draw the band in the terminal too, or to off to keep only the status line at the bottom.

What the numbers mean

ItemSourceMeaning
costcost.total_cost_usdSession cost in US dollars with two decimals, estimated by Claude Code at list price; resets on /clear
5h40mcost.total_duration_msHow long the session has been running (desktop: since the session first started)
cache 99%prompt_cache.hit_ratioShare of input tokens served from the prompt cache
52m / coldprompt_cache.expires_atHow long the prompt cache stays warm without a new prompt (yellow in its last 5 minutes), or that it went cold and the next prompt rebuilds it
xhigheffort.levelReasoning effort of the session (desktop: shown after the first request)
+156 -23cost.total_lines_added, total_lines_removedLines of code added and removed in the session (terminal only)
ctx 54% 543k/1.0Mcontext_windowContext window fill; the line shows how it moved during the session
7d / 5hrate_limitsThe weekly (7-day) and 5-hour plan limits: usage now → projected at the reset, ↻ time and clock time of the reset, ↗ pace, ⚠ when the limit runs out before the reset

Pace is how fast the usage grew recently, shown per hour: over the last 30 minutes for the 5-hour limit and over the last 24 hours for the 7-day limit, so the 7-day pace includes your nights and days off. The projection (→) extends that pace to the reset: it answers "where do I end up if I keep going like this?". The plan's limits are shared by every session and by claude.ai, so the pace counts all of them: each session adds the figures it receives to a small shared history, and the line always shows the newest figure any session saw. Without readings from before it, a new window has no pace in its first 15 minutes (→ —, ↗ —); when you work on across a reset, the pace carries on from the previous window. Without any history yet the pace is the average since the window opened. A large drop within a window is taken as a limit reset given on claude.ai, and usage counts again from there.

Colours: green below 80% projected, yellow from 80% to 100%, red above 100% or when the limit is used up; then ⚠ 100% in 1h17m says when it runs out at the current pace. The reset's clock time is in your computer's time zone, with the weekday when it is more than a day away. On a narrow screen the clock time goes first, then the ⚠ part. The context bar is green below 50%, orange to 80% and red above. The context line is scaled from its lowest to its highest point, so it shows the shape of the trend; the percentage next to the bar is the absolute figure.

In the desktop app the cache countdown is estimated: Claude Code keeps the cache warm for an hour after the last response on a plan within its limits and five minutes otherwise, unless promptCacheTtl or a prompt-cache variable says else. Lines changed are shown in the terminal only, because the desktop app does not give plugins that count.

Settings

OptionValuesDefault
banddesktop (band only in the desktop app), all (also in the terminal), offdesktop

Change it in /config or in the plugin's settings in the desktop app. Set NO_COLOR=1 to print the terminal status line without colours.

Privacy: what the plugin runs, reads and writes

Everything stays on your computer. The plugin makes no network requests, sends no telemetry, uses no credentials, and stores no prompts or responses. The only file contents it keeps are the backup copy of your settings.json that setup makes. Folder paths and branch names, which it reads and shows, can contain your name; PRIVACY.md has the full statement.

Its data folder is ~/.claude/plugins/data/limits-statusline-<source>/: -synced when you added it from the directory, -sealoop when you added it from GitHub.

  • Status line (terminal): reads the JSON Claude Code passes on stdin, runs git --no-optional-locks status --porcelain=v2 --branch --untracked-files=no in the working folder (--no-optional-locks, so it never takes git's index lock), and keeps a small context history per session, the fill percentages only, in the data folder's history/ (pruned after 7 days).
  • Plan limit history (status line and desktop band alike): the 5-hour and 7-day percentages with their time and reset time, and each session's last figures, in ~/.claude/limits-statusline/limit-history.json, kept for 1 hour (5-hour) and 48 hours (7-day). A figure a session reports for the first time may be an old one, so the status line stamps it with the time the session's transcript was last written (the file's time only, never its contents).
  • Setup (/limits-statusline:setup): copies the status line runtime (the plugin's own src/core and src/terminal) into the data folder, because the plugin's install folder changes with every update and the command in settings.json must not. It writes statusLine to settings.json (atomically, through a symlink if it is one) as node running that copy, and keeps your previous status line and a copy of the previous settings.json in ~/.claude/limits-statusline/. It refuses to touch a settings file that is not valid JSON.
  • SessionStart hook: after setup has run, refreshes the runtime copy after a plugin update and rebuilds it if it went missing; before setup it does nothing. On a computer without Node.js it cannot run and ends without an error, so sessions start as usual.
  • Desktop band (mod): draws the same lines above the prompt and redraws once a minute so the reset countdowns stay current. Every call it makes to Claude Code is in src/desktop/band.mjs.
  • The one program it runs, and why: git --no-optional-locks status --porcelain=v2 --branch --untracked-files=no, in the session's folder (--no-optional-locks, so it never takes git's index lock) with a 1.5-second time limit, written out whole in the code, options included. It runs when the session starts and each time Claude Code reports new session figures, to show the branch and, with *, whether tracked files have uncommitted changes. git status only reads the repository on your computer and contacts no remote. Nothing the band reads is passed to it: the command and its options are fixed. Where the command cannot run (no git, or Claude Code runs no commands there), the band reads the branch from the session's own .git/HEAD file instead, looking for .git from the session's folder upward, as git does. In a git worktree .git is a file that names the worktree's git folder, and the band reads HEAD there.
  • What it reads: the session's figures from Claude Code (context fill, plan limits, cost, runtime, model, working folder, session id and version); from each model request (a turn.step hook) only the effort level, handing the request and the response on unchanged without looking at their text; from each finished turn only its token counts, for the cache ratio; the plan limit history file; where git cannot run, whether Claude Code has the session in a git repository and the session's .git/HEAD (in a worktree, its .git file and the HEAD file in the folder it names); the settings, of which it uses only promptCacheTtl; and the variables FORCE_PROMPT_CACHING_5M, CLAUDE_CODE_PROMPT_CACHE_TTL and ENABLE_PROMPT_CACHING_1H, to know how long the prompt cache stays warm, and CLAUDE_CONFIG_DIR, USERPROFILE and HOME, only to find the configuration folder.
  • What it sends, and where: nothing, and nowhere. It makes no network requests, and the git status above is the only program it starts. What it reads is drawn in the band and kept as described here.
  • The one file it writes is the plan limit history above, ~/.claude/limits-statusline/limit-history.json (in CLAUDE_CONFIG_DIR when set): JSON with the plugin's own figures, which the status line and the band read as data. It is not a build, start-up, settings or instructions file, and no tool runs it or takes it as settings or instructions. The code names the file in the write itself; only its folder comes from CLAUDE_CONFIG_DIR or your home folder, as Claude Code's own configuration folder does.
  • What it keeps: the effort, cache counts, time of the last response, context history and git state, in the state Claude Code holds for the mod during the session.

With CLAUDE_CONFIG_DIR set, these folders live there instead of ~/.claude.

Troubleshooting

  • Nothing shows in a new session — the plugin is not installed: claude --plugin-dir and a session's hot-reloaded mods folder load it for that session only. claude plugin list should show limits-statusline@synced or limits-statusline@sealoop; if it does not, follow Install.
  • The terminal shows nothing — run /limits-statusline:setup and check that node --version prints 20 or higher. Claude Code runs the command through sh on macOS and Linux, and through Git Bash or PowerShell on Windows; its paths use forward slashes, which all of them read.
  • macOS asks to install the command line developer tools — until they are installed, macOS's own git is only a stand-in that asks for them each time it runs, and the plugin runs git status to show the branch. Install them from that dialog or with xcode-select --install; the desktop app's isolated sessions need git as well.
  • The bottom line went blank after the plugin was removed or moved to another source — run /limits-statusline:setup again with the plugin installed: it rebuilds the runtime copy and points settings.json at it, or use --uninstall to put your previous status line back.
  • 7d — and 5h — — the limits arrive with the first response of a session and only for Pro and Max subscribers.
  • The 7-day projection is very high on the first day after installing — the 7-day pace looks back 24 hours; until the shared history covers a day, the pace is the average since the weekly reset, which early in the week counts only working hours. It settles once a day of history is there.
  • Odd pace after switching accounts — the shared history does not tell accounts apart; two accounts used in turn within a week mix their figures until the readings age out (48 hours).
  • No effort or cache shown — older Claude Code versions do not send them; update Claude Code.

Support

  • Questions, bugs and ideas: GitHub issues.
  • Security problems: report them privately as SECURITY.md describes, not in a public issue.
  • Contact: Luka Kozlowski (SeaLoop), lukakozlowski@sealoop.com.

Development

Tests, running a working copy in every session, and releases are described in CONTRIBUTING.md.

License

MIT © 2026 Luka Kozlowski (SeaLoop) · lukakozlowski@sealoop.com

Source 26 files
hooks/register.mjs 7 lines
1// Hooks module (mod) entry: the band above the prompt in the Claude desktop app.
2import { registerBand } from '../src/desktop/band.mjs';
3
4export function register(on, options) {
5  registerBand(on, options);
6}
7
src/desktop/band.mjs 199 lines
1// The mod: listens to the session, keeps a tracker in $.state, and draws the band above the
2// prompt from the same core layout as the terminal status line.
3//
4// Every call on `$` is written in this file, as a plain `$.noun.method(...)`: Anthropic's
5// directory lists and checks a mod's calls from the source, and reads `$` passed only to a
6// function declared at the top of the file that declares the hooks. The other desktop modules
7// get the answers as plain values.
8
9import { atom, read, update } from 'claude-code';
10import { parseGitStatus } from '../core/git-status.mjs';
11import { buildLines } from '../core/layout.mjs';
12import { EMPTY_LIMIT_HISTORY, parseLimitHistory, serializeLimitHistory } from '../core/limit-history.mjs';
13import { limitHistoryPath } from '../core/paths.mjs';
14import { renderBand } from './band-view.mjs';
15import { cacheTtlFromEngine } from './cache-ttl.mjs';
16import { NOT_A_REPO, dotGitPaths, gitDirFromFile, gitFromHead } from './git.mjs';
17import { configFolderFor, withMeasuredLimits } from './limit-history.mjs';
18import { shouldDraw, supportsTypography } from './policy.mjs';
19import { snapshotFromSession } from './snapshot.mjs';
20import { afterClear, initialTracker, withContext, withEffort, withGit, withTurnUsage } from './tracker.mjs';
21
22const tracker = atom({ plugin: 'limits-statusline', key: 'tracker' }, initialTracker());
23
24// Reset countdowns tick in minutes; redraw once a minute even while the session is idle.
25const REDRAW_EVERY_MS = 60_000;
26
27/** @param {import('claude-code').On} on @param {import('claude-code').PluginOptions} options */
28export function registerBand(on, options) {
29  const mode = options?.band;
30
31  on('session.start', async ($, e, next) => {
32    await refreshGit($);
33    $.clock.every(REDRAW_EVERY_MS, () => $.ui.invalidate('ui.render'));
34    return next(e);
35  });
36
37  on('turn.step', async function* ($, e, next) {
38    await update($, tracker, (state) => withEffort(state ?? initialTracker(), e));
39    return yield* next(e);
40  });
41
42  on('turn.complete', async ($, e, next) => {
43    const nowMs = await $.clock.now();
44    await update($, tracker, (state) => withTurnUsage(state ?? initialTracker(), e, nowMs));
45    return next(e);
46  });
47
48  // A measurement names what moved since the last one, so a plan figure recorded here was
49  // observed just now: the shared history gets it with the right time.
50  on('session.measure', async ($, e, next) => {
51    await update($, tracker, (state) => withContext(state ?? initialTracker(), e.context?.percent));
52    if (e.changed?.includes('rateLimits')) await recordMeasuredLimits($, e.rateLimits, await $.clock.now());
53    await refreshGit($);
54    return next(e);
55  });
56
57  // /clear raises session.end with reason 'clear' and no session.start after it.
58  on('session.end', async ($, e, next) => {
59    if (e.reason === 'clear') await update($, tracker, (state) => afterClear(state ?? initialTracker()));
60    return next(e);
61  });
62
63  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
64    if (!shouldDraw(mode, e.surface) || e.props.hasSurvey) return next(e);
65    const [usage, modelRaw, cwd, state, nowMs, typography, limitReadings] = await Promise.all([
66      $.session.usage(), $.session.model(), $.session.cwd(), read($, tracker), $.clock.now(), hasTypography($),
67      loadLimitReadings($),
68    ]);
69    const cacheTtlMs = await readCacheTtl($, usage?.rateLimits, nowMs);
70    const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
71    const snapshot = snapshotFromSession({ usage, modelRaw, cwd, tracker: state, nowMs, timeZone, limitReadings, cacheTtlMs });
72    const lines = buildLines(snapshot, { columns: e.props.bodyColumns });
73    return renderBand(lines, $.ui.resolve(e), h, { typography });
74  });
75}
76
77async function refreshGit($) {
78  const git = await probeGit($);
79  await update($, tracker, (state) => withGit(state ?? initialTracker(), git));
80}
81
82/** @returns {Promise<import('../core/git-status.mjs').GitState | null>} null when nothing could be asked */
83async function probeGit($) {
84  try {
85    // The whole call as fixed text, options too, so that the directory reads the whole command:
86    // the terminal's GIT_STATUS_ARGS, run in the session's folder (the default), for at most
87    // 1.5 s. --no-optional-locks: never take the index lock from the user's own git.
88    const status = await $.process.run(['git', '--no-optional-locks', 'status', '--porcelain=v2', '--branch', '--untracked-files=no'],
89      { timeoutMs: 1500 });
90    return parseGitStatus(status);
91  } catch {
92    return readGitHead($);
93  }
94}
95
96// The engine's repository says whether the session is in one, but its root is the main working
97// tree's for a worktree; the session's own `.git` is looked for from its folder up, as git does.
98async function readGitHead($) {
99  let repo;
100  let cwd;
101  try {
102    [repo, cwd] = await Promise.all([$.session.repo(), $.session.cwd()]);
103  } catch {
104    return null;
105  }
106  if (!repo?.root) return { ...NOT_A_REPO };
107  try {
108    for (const dotGit of dotGitPaths(cwd)) {
109      if (await $.fs.exists(dotGit)) return gitFromHead(await readHead($, dotGit));
110    }
111  } catch {
112    // A HEAD that cannot be read, or a folder the file system refuses: the branch stays unknown.
113  }
114  return gitFromHead(null);
115}
116
117/** A `.git` folder holds HEAD; a `.git` file, a worktree's, names the folder that does. */
118async function readHead($, dotGit) {
119  try {
120    return await $.fs.read(`${dotGit}/HEAD`);
121  } catch {
122    const gitDir = gitDirFromFile(await $.fs.read(dotGit), dotGit);
123    return gitDir === null ? null : $.fs.read(`${gitDir}/HEAD`);
124  }
125}
126
127async function hasTypography($) {
128  try {
129    return supportsTypography((await $.session.version()).version);
130  } catch {
131    return false;
132  }
133}
134
135/** @returns {Promise<number | null>} null when the engine cannot answer */
136async function readCacheTtl($, rateLimits, nowMs) {
137  try {
138    // Literal names: the engine lists the variables a module reads from its calls.
139    const [force5m, chosenTtl, enable1h, settings] = await Promise.all([
140      $.env.get('FORCE_PROMPT_CACHING_5M'),
141      $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL'),
142      $.env.get('ENABLE_PROMPT_CACHING_1H'),
143      $.settings.read(),
144    ]);
145    const env = { FORCE_PROMPT_CACHING_5M: force5m, CLAUDE_CODE_PROMPT_CACHE_TTL: chosenTtl, ENABLE_PROMPT_CACHING_1H: enable1h };
146    return cacheTtlFromEngine({ env, settings, rateLimits, nowMs });
147  } catch {
148    return null;
149  }
150}
151
152// The shared limit history: every call settles, so a file that cannot be used leaves the band with
153// less to show, never without one.
154
155/** @returns {Promise<string | null>} the configuration folder, null when none is known */
156async function configFolder($) {
157  try {
158    // Literal names: the engine lists the variables a module reads from its calls.
159    const [configDirVariable, userProfile, home] = await Promise.all([
160      $.env.get('CLAUDE_CONFIG_DIR'), $.env.get('USERPROFILE'), $.env.get('HOME'),
161    ]);
162    return configFolderFor(configDirVariable, userProfile, home);
163  } catch {
164    return null;
165  }
166}
167
168async function readLimitHistory($, configDir) {
169  const file = limitHistoryPath(configDir);
170  try {
171    return (await $.fs.exists(file)) ? parseLimitHistory(await $.fs.read(file)) : EMPTY_LIMIT_HISTORY;
172  } catch {
173    return EMPTY_LIMIT_HISTORY;
174  }
175}
176
177/** @returns {Promise<import('../core/limit-history.mjs').LimitReadings>} the shared readings to draw from */
178async function loadLimitReadings($) {
179  const configDir = await configFolder($);
180  return (configDir === null ? EMPTY_LIMIT_HISTORY : await readLimitHistory($, configDir)).readings;
181}
182
183/** Adds this session's plan figures, measured just now, to the shared history. */
184async function recordMeasuredLimits($, rateLimits, nowMs) {
185  const configDir = await configFolder($);
186  if (configDir === null) return;
187  try {
188    const history = await readLimitHistory($, configDir);
189    const sessionId = await $.session.id();
190    const next = withMeasuredLimits(history, { sessionId, rateLimits, nowMs });
191    // The file's name as fixed text, so that the directory reads which file the mod writes; only
192    // the folder comes from the environment, as Claude Code's own configuration folder does.
193    if (next !== history) await $.fs.write(`${configDir}/limits-statusline/limit-history.json`, serializeLimitHistory(next));
194  } catch {
195    // The terminal status line or another session may hold the file for a moment; the next
196    // measurement records again.
197  }
198}
199
src/core/git-status.mjs 33 lines
1// Parses `git status --porcelain=v2 --branch` so both adapters share one git question and one answer.
2
3// --no-optional-locks (git 2.15+, the same as GIT_OPTIONAL_LOCKS=0): never take the index lock away
4// from the user's own git commands.
5export const GIT_STATUS_ARGS = Object.freeze(['--no-optional-locks', 'status', '--porcelain=v2', '--branch', '--untracked-files=no']);
6
7const NOT_A_REPO = Object.freeze({ isRepo: false, branch: null, isDirty: false });
8const SHORT_SHA_LENGTH = 7;
9
10/**
11 * @typedef {{ isRepo: boolean, branch: string | null, isDirty: boolean }} GitState
12 * @param {{ exitCode: number, stdout: string } | null} result of running git with GIT_STATUS_ARGS
13 * @returns {GitState}
14 */
15export function parseGitStatus(result) {
16  if (!result || result.exitCode !== 0 || typeof result.stdout !== 'string') return { ...NOT_A_REPO };
17  let head = null;
18  let oid = null;
19  let isDirty = false;
20  for (const line of result.stdout.split(/\r?\n/)) {
21    if (line.startsWith('# branch.head ')) head = line.slice('# branch.head '.length).trim();
22    else if (line.startsWith('# branch.oid ')) oid = line.slice('# branch.oid '.length).trim();
23    else if (line !== '' && !line.startsWith('#')) isDirty = true;
24  }
25  return { isRepo: true, branch: branchName(head, oid), isDirty };
26}
27
28function branchName(head, oid) {
29  if (head && head !== '(detached)') return head;
30  if (oid && oid !== '(initial)') return oid.slice(0, SHORT_SHA_LENGTH);
31  return null;
32}
33
src/core/layout.mjs 201 lines
1// Snapshot → five lines of tone-tagged segments. The single place that decides what the
2// status line says; renderers only decide how a tone looks.
3
4import {
5  DASH, formatClock, formatCountdown, formatDuration, formatPercent, formatRate, formatTokens, formatUsd,
6} from './format.mjs';
7import { BAR_CELLS, brailleLine, normalizeSeries, progressBar } from './glyphs.mjs';
8import { LIMIT_WINDOWS } from './limit-windows.mjs';
9import { analyzeLimit } from './metrics.mjs';
10import { cacheTimeLeftMs } from './prompt-cache.mjs';
11import { TONE, cacheTone, contextTone, limitTone } from './thresholds.mjs';
12
13/**
14 * The contract both adapters fill. Unknown values are null, never undefined.
15 * @typedef {import('./metrics.mjs').LimitReading} LimitReading
16 * @typedef {{
17 *   nowMs: number,
18 *   timeZone: string | null,
19 *   project: string | null,
20 *   git: import('./git-status.mjs').GitState | null,
21 *   modelName: string | null,
22 *   costUsd: number | null,
23 *   durationMs: number | null,
24 *   cacheHitRatio: number | null,
25 *   promptCache: import('./prompt-cache.mjs').PromptCache | null,
26 *   effort: string | null,
27 *   linesChanged: { added: number, removed: number } | null,
28 *   context: { usedTokens: number | null, windowTokens: number | null, percent: number | null },
29 *   contextHistory: number[],
30 *   limits: { sevenDay: LimitReading | null, fiveHour: LimitReading | null },
31 *   limitReadings: import('./limit-history.mjs').LimitReadings,
32 * }} Snapshot
33 * A segment that draws a line chart also carries its points (`series`, 0–1, oldest first), so a
34 * surface that can draw vector graphics draws the line instead of the braille `text`.
35 * @typedef {{ text: string, tone: string, series?: number[] }} Segment
36 * @typedef {Segment[]} Line
37 */
38
39const LINE_CHARS = 15; // the mockup's line spans 15 characters
40const WIDE = Object.freeze({ barWidth: BAR_CELLS, lineChars: LINE_CHARS });
41const NARROW = Object.freeze({ barWidth: 10, lineChars: 0 });
42const FULL_LINE_COLUMNS = 52; // "ctx " + 16 cells + " 54%" + "  543k/1.0M" + "  " + 15-char line
43const LABEL_WIDTH = 4; // "ctx ", "7d  ", "5h  "; the session line is indented to match
44const PERCENT_WIDTH = 3;
45const PROJECTION_WIDTH = 4; // "117%"
46const GAP = '  ';
47const POINTS_PER_CHAR = 2;
48
49const LIMIT_LINES = Object.freeze([
50  Object.freeze({ label: '7d', kind: 'sevenDay' }),
51  Object.freeze({ label: '5h', kind: 'fiveHour' }),
52]);
53
54// What the limit lines give up, in order, until they fit the width: first the reset's clock
55// time, then the time to 100%. Both lines drop the same parts, so their columns stay aligned.
56const LIMIT_DETAILS = Object.freeze([
57  Object.freeze({ showClock: true, showLimitIn: true }),
58  Object.freeze({ showClock: false, showLimitIn: true }),
59  Object.freeze({ showClock: false, showLimitIn: false }),
60]);
61
62const segment = (text, tone = TONE.plain) => ({ text, tone });
63const separator = (text) => segment(text, TONE.muted);
64const indent = () => segment(' '.repeat(LABEL_WIDTH));
65
66/** @param {Line} line */
67export const lineText = (line) => line.map((part) => part.text).join('');
68
69/**
70 * @param {Snapshot} snapshot
71 * @param {{ columns?: number }} options terminal or band width in cells
72 * @returns {Line[]}
73 */
74export function buildLines(snapshot, { columns } = {}) {
75  const size = Number.isFinite(columns) && columns < FULL_LINE_COLUMNS ? NARROW : WIDE;
76  return [
77    headerLine(snapshot),
78    sessionLine(snapshot),
79    contextLine(snapshot, size),
80    ...limitLines(snapshot, size, columns),
81  ];
82}
83
84function headerLine({ project, git, modelName }) {
85  return [
86    segment(project ?? DASH, TONE.accent),
87    separator(' | '),
88    gitSegment(git),
89    separator(' | '),
90    segment(`✦ ${modelName ?? DASH}`, TONE.model),
91  ];
92}
93
94function gitSegment(git) {
95  if (!git?.isRepo) return segment('no git', TONE.muted);
96  return segment(`git ${git.branch ?? DASH}${git.isDirty ? '*' : ''}`);
97}
98
99function contextLine({ context, contextHistory }, size) {
100  const tone = contextTone(context?.percent);
101  const series = normalizeSeries(contextHistory, size.lineChars * POINTS_PER_CHAR);
102  const chart = brailleLine(series, size.lineChars);
103  return [
104    segment('ctx'.padEnd(LABEL_WIDTH), TONE.muted),
105    ...barSegments(context?.percent, tone, size.barWidth),
106    segment(GAP),
107    segment(`${formatTokens(context?.usedTokens)}/${formatTokens(context?.windowTokens)}`, TONE.muted),
108    ...(chart ? [segment(GAP), { text: chart, tone: TONE.spark, series }] : []),
109  ];
110}
111
112function sessionLine({ costUsd, durationMs, cacheHitRatio, promptCache, effort, linesChanged, nowMs }) {
113  const items = [
114    Number.isFinite(costUsd) && [segment(formatUsd(costUsd), TONE.cost)],
115    Number.isFinite(durationMs) && [segment(formatDuration(durationMs), TONE.muted)],
116    Number.isFinite(cacheHitRatio) && cacheSegments(cacheHitRatio, promptCache, nowMs),
117    effort && [segment(effort, TONE.ok)],
118    linesChangedSegments(linesChanged),
119  ].filter(Boolean);
120  if (items.length === 0) return [indent(), segment(DASH, TONE.muted)];
121  return [indent(), ...items.flatMap((item, index) => (index === 0 ? item : [separator(' · '), ...item]))];
122}
123
124// `cache 99%`, then how long the cache stays warm (`52m`) or that it went cold.
125function cacheSegments(ratio, promptCache, nowMs) {
126  const ratioSegment = segment(`cache ${formatPercent(ratio * 100)}`, TONE.muted);
127  const leftMs = cacheTimeLeftMs(promptCache, nowMs);
128  if (leftMs === null) return [ratioSegment];
129  return [ratioSegment, segment(leftMs > 0 ? ` ${formatDuration(leftMs)}` : ' cold', cacheTone(leftMs))];
130}
131
132function linesChangedSegments(linesChanged) {
133  const { added = 0, removed = 0 } = linesChanged ?? {};
134  if (!(added + removed > 0)) return null;
135  return [segment(`+${added}`, TONE.added), segment(` -${removed}`, TONE.removed)];
136}
137
138function limitLines(snapshot, size, columns) {
139  const clock = { nowMs: snapshot.nowMs, timeZone: snapshot.timeZone ?? null };
140  const limits = LIMIT_LINES.map(({ label, kind }) => {
141    const reading = snapshot.limits?.[kind] ?? null;
142    const analysis = analyzeLimit(reading, LIMIT_WINDOWS[kind], snapshot.nowMs, snapshot.limitReadings?.[kind] ?? []);
143    const clockText = formatClock(reading?.resetsAtMs, clock);
144    return { label, analysis, countdown: formatCountdown(analysis?.remainingMs), clockText: clockText === DASH ? null : clockText };
145  });
146  const draw = (details) => {
147    const resets = resetColumn(limits.filter(({ analysis }) => analysis), details.showClock);
148    return limits.map((limit) => limitLine(limit, { size, details, reset: resets.get(limit) }));
149  };
150  const fits = (lines) => !Number.isFinite(columns) || lines.every((line) => lineText(line).length <= columns);
151  for (const details of LIMIT_DETAILS) {
152    const lines = draw(details);
153    if (fits(lines)) return lines;
154  }
155  return draw(LIMIT_DETAILS.at(-1));
156}
157
158// The reset column of each limit line: `3h50m`, or with the clock time `3h50m 18:10`; the
159// countdowns and the clock times are each padded to the longest, so the columns after them line up.
160function resetColumn(limits, showClock) {
161  const widest = (texts) => Math.max(0, ...texts.map((text) => text.length));
162  const countdownWidth = widest(limits.map(({ countdown }) => countdown));
163  const clockWidth = showClock ? widest(limits.map(({ clockText }) => clockText ?? '')) : 0;
164  return new Map(limits.map((limit) => {
165    const countdown = limit.countdown.padEnd(countdownWidth);
166    return [limit, clockWidth > 0 ? `${countdown} ${(limit.clockText ?? '').padEnd(clockWidth)}` : countdown];
167  }));
168}
169
170function limitLine({ label, analysis }, { size, details, reset }) {
171  const labelSegment = segment(label.padEnd(LABEL_WIDTH), TONE.muted);
172  if (!analysis) return [labelSegment, segment(DASH, TONE.muted)];
173  const tone = limitTone(analysis);
174  const hasProjection = Number.isFinite(analysis.projectedPercent);
175  // The rate stays quiet while the pace is fine and takes the warning colour when it is not.
176  const rateTone = Number.isFinite(analysis.ratePerHour) && tone !== TONE.ok ? tone : TONE.muted;
177  return [
178    labelSegment,
179    ...barSegments(analysis.percentUsed, tone, size.barWidth),
180    separator(' → '),
181    segment(formatPercent(analysis.projectedPercent).padStart(PROJECTION_WIDTH), hasProjection ? tone : TONE.muted),
182    segment(GAP),
183    segment(`↻ ${reset}`, TONE.muted),
184    segment(GAP),
185    segment(`↗ ${formatRate(analysis.ratePerHour)}`, rateTone),
186    ...(details.showLimitIn && analysis.limitInMs !== null
187      ? [segment(GAP), segment(`⚠ 100% in ${formatCountdown(analysis.limitInMs)}`, TONE.alert)]
188      : []),
189  ];
190}
191
192function barSegments(percent, tone, width) {
193  const { filled, empty } = progressBar(percent, width);
194  return [
195    segment(filled, tone),
196    segment(empty, TONE.muted),
197    segment(' '),
198    segment(formatPercent(percent).padStart(PERCENT_WIDTH), tone),
199  ];
200}
201
src/core/limit-history.mjs 142 lines
1// The shared record of plan-limit readings behind the recent pace: every session and both
2// surfaces add to one file, so a new session knows the pace at once and the 7-day pace can look
3// back a whole day. Pure: the adapters read and write the file.
4//
5// A session adds a reading only when its own figure changed. A change comes with a new response,
6// so it is stamped now. A figure the session reports for the first time may be old (a session
7// left open before the history existed, or forgotten since), so it is stamped with the
8// session's last response: stamping it "now" would put an old figure after newer ones.
9
10import { LIMIT_KINDS, LIMIT_WINDOWS, retentionMs } from './limit-windows.mjs';
11import { MS_PER_MINUTE } from './time.mjs';
12
13export const LIMIT_HISTORY_VERSION = 1;
14
15/** How long readings of each kind are kept. */
16export const HISTORY_RETENTION_MS = Object.freeze(Object.fromEntries(
17  LIMIT_KINDS.map((kind) => [kind, retentionMs(LIMIT_WINDOWS[kind])]),
18));
19
20/** Sessions whose last figures are remembered; the least recently seen fall off first. */
21export const MAX_SESSIONS = 64;
22
23/** Reset times this close together name the same window (the API may round them). */
24export const SAME_WINDOW_TOLERANCE_MS = 10 * MS_PER_MINUTE;
25
26/**
27 * @typedef {[atMs: number, percentUsed: number, resetsAtMs: number]} LimitPoint
28 * @typedef {{ fiveHour: LimitPoint[], sevenDay: LimitPoint[] }} LimitReadings
29 * @typedef {[percentUsed: number, resetsAtMs: number]} SeenFigure
30 * @typedef {{ atMs: number, fiveHour: SeenFigure | null, sevenDay: SeenFigure | null }} SessionSeen
31 * @typedef {{ readings: LimitReadings, sessions: Record<string, SessionSeen> }} LimitHistory
32 * @typedef {{ fiveHour: import('./metrics.mjs').LimitReading | null,
33 *             sevenDay: import('./metrics.mjs').LimitReading | null }} Limits
34 */
35
36/** @type {LimitHistory} */
37export const EMPTY_LIMIT_HISTORY = Object.freeze({
38  readings: Object.freeze({ fiveHour: Object.freeze([]), sevenDay: Object.freeze([]) }),
39  sessions: Object.freeze({}),
40});
41
42/** @param {number} a @param {number} b reset times */
43export const isSameWindow = (a, b) => Math.abs(a - b) <= SAME_WINDOW_TOLERANCE_MS;
44
45const isPercent = (value) => Number.isFinite(value) && value >= 0;
46const isUsableReading = (reading) => isPercent(reading?.percentUsed) && Number.isFinite(reading?.resetsAtMs);
47
48/**
49 * Adds the session's readings that differ from what it reported last, prunes what is too old.
50 * @param {LimitHistory} history
51 * @param {{ sessionId: unknown, limits: Limits | null, nowMs: number, lastResponseAtMs?: number | null }} update
52 *   `lastResponseAtMs`: when the session's last response arrived, if known; it stamps figures the
53 *   session reports for the first time
54 * @returns {LimitHistory} `history` itself when nothing was added
55 */
56export function recordLimits(history, { sessionId, limits, nowMs, lastResponseAtMs = null }) {
57  if (typeof sessionId !== 'string' || sessionId === '' || !limits) return history;
58  const seen = history.sessions[sessionId];
59  const changed = LIMIT_KINDS.filter((kind) => isUsableReading(limits[kind]) && !isSameFigure(seen?.[kind], limits[kind]));
60  if (changed.length === 0) return history;
61
62  const firstSeenAtMs = Number.isFinite(lastResponseAtMs) ? Math.min(lastResponseAtMs, nowMs) : nowMs;
63  const readings = { ...history.readings };
64  const figures = { fiveHour: seen?.fiveHour ?? null, sevenDay: seen?.sevenDay ?? null };
65  for (const kind of changed) {
66    const { percentUsed, resetsAtMs } = limits[kind];
67    const atMs = figures[kind] ? nowMs : firstSeenAtMs;
68    readings[kind] = prune(withPoint(readings[kind], [atMs, percentUsed, resetsAtMs], kind), kind, nowMs);
69    figures[kind] = [percentUsed, resetsAtMs];
70  }
71  const sessions = keepRecentSessions({ ...history.sessions, [sessionId]: { atMs: nowMs, ...figures } }, nowMs);
72  return { readings, sessions };
73}
74
75function isSameFigure(figure, reading) {
76  return Array.isArray(figure) && figure[0] === reading.percentUsed && isSameWindow(figure[1], reading.resetsAtMs);
77}
78
79// Fixed buckets rather than "close to the last point", which would slide along a busy session
80// and keep a single point forever. A late point (stamped before the newest) is simply added.
81function withPoint(points, point, kind) {
82  const last = points.at(-1);
83  const bucket = (atMs) => Math.floor(atMs / LIMIT_WINDOWS[kind].bucketMs);
84  const isSameBucket = last !== undefined && point[0] >= last[0] && bucket(point[0]) === bucket(last[0])
85    && isSameWindow(last[2], point[2]);
86  return isSameBucket ? [...points.slice(0, -1), point] : [...points, point];
87}
88
89// Drops readings older than the retention but the newest of them: after a pause longer than the
90// retention the pace still needs a reading from before its look-back to read the start off.
91function prune(points, kind, nowMs) {
92  const cutoffMs = nowMs - HISTORY_RETENTION_MS[kind];
93  const baseline = points
94    .filter(([atMs]) => atMs < cutoffMs)
95    .reduce((newest, point) => (!newest || point[0] > newest[0] ? point : newest), null);
96  return points.filter((point) => point[0] >= cutoffMs || point === baseline);
97}
98
99function keepRecentSessions(sessions, nowMs) {
100  const maxAgeMs = Math.max(...Object.values(HISTORY_RETENTION_MS));
101  const recent = Object.entries(sessions)
102    .filter(([, session]) => nowMs - session.atMs <= maxAgeMs)
103    .sort(([, a], [, b]) => b.atMs - a.atMs)
104    .slice(0, MAX_SESSIONS);
105  return Object.fromEntries(recent);
106}
107
108/** @param {LimitHistory} history @returns {string} */
109export function serializeLimitHistory(history) {
110  return JSON.stringify({ version: LIMIT_HISTORY_VERSION, ...history });
111}
112
113/**
114 * Reads the file's text back; anything unreadable, of another version or malformed is dropped,
115 * entry by entry, so one bad line never costs the rest.
116 * @param {unknown} text
117 * @returns {LimitHistory}
118 */
119export function parseLimitHistory(text) {
120  let data;
121  try {
122    data = JSON.parse(typeof text === 'string' ? text : '');
123  } catch {
124    return EMPTY_LIMIT_HISTORY;
125  }
126  if (data === null || typeof data !== 'object' || data.version !== LIMIT_HISTORY_VERSION) return EMPTY_LIMIT_HISTORY;
127  const readings = Object.fromEntries(LIMIT_KINDS.map((kind) => [kind, validPoints(data.readings?.[kind])]));
128  const sessions = Object.fromEntries(Object.entries(data.sessions ?? {})
129    .filter(([, session]) => isSession(session))
130    .map(([id, { atMs, fiveHour = null, sevenDay = null }]) => [id, { atMs, fiveHour, sevenDay }]));
131  return { readings, sessions };
132}
133
134const isPoint = (point) => Array.isArray(point) && point.length === 3
135  && Number.isFinite(point[0]) && isPercent(point[1]) && Number.isFinite(point[2]);
136const validPoints = (points) => (Array.isArray(points) ? points.filter(isPoint) : []);
137
138const isFigure = (figure) => figure === null
139  || (Array.isArray(figure) && figure.length === 2 && isPercent(figure[0]) && Number.isFinite(figure[1]));
140const isSession = (session) => session !== null && typeof session === 'object'
141  && Number.isFinite(session.atMs) && LIMIT_KINDS.every((kind) => isFigure(session[kind] ?? null));
142
src/core/paths.mjs 32 lines
1// Path helpers that must work on Windows and POSIX paths alike, whatever platform runs them.
2
3import { textOrNull } from './values.mjs';
4
5/** Last path segment, or null. @param {unknown} path */
6export function folderName(path) {
7  if (typeof path !== 'string') return null;
8  return path.split(/[\\/]+/).filter(Boolean).at(-1) ?? null;
9}
10
11const withoutTrailingSeparator = (path) => path.replace(/[\\/]+$/, '');
12
13/**
14 * The Claude Code configuration folder as Claude Code picks it: CLAUDE_CONFIG_DIR, else
15 * `.claude` in the home folder. Null when neither is known.
16 * @param {unknown} configDirVariable CLAUDE_CONFIG_DIR @param {unknown} home the home folder
17 * @returns {string | null}
18 */
19export function claudeConfigDir(configDirVariable, home) {
20  const configured = textOrNull(configDirVariable);
21  if (configured) return configured;
22  const homeFolder = textOrNull(home);
23  return homeFolder ? `${withoutTrailingSeparator(homeFolder)}/.claude` : null;
24}
25
26/**
27 * The limit history both surfaces share, next to what setup keeps: it outlives the plugin's
28 * data folder, which differs between install sources and which the desktop band cannot name.
29 * @param {string} configDir
30 */
31export const limitHistoryPath = (configDir) => `${withoutTrailingSeparator(configDir)}/limits-statusline/limit-history.json`;
32
src/desktop/band-view.mjs 63 lines
1// Lines → a Box/Text tree for the band above the prompt. Tones become theme colour keys, so
2// the band follows the person's light or dark theme; a line chart becomes an SVG where the
3// surface draws one (the desktop app), as in the approved mockup.
4
5import { sparklineSvg } from './sparkline-svg.mjs';
6
7export const TONE_COLOR = Object.freeze({
8  muted: 'subtle',
9  accent: 'suggestion',
10  model: 'merged',
11  ok: 'success',
12  warn: 'warning',
13  alert: 'error',
14  cost: 'claude',
15  spark: 'suggestion',
16  added: 'success',
17  removed: 'error',
18});
19
20const BOLD_TONES = new Set(['accent', 'alert']);
21
22// About 15 monospaced characters by one row, like the mockup's line; SVG cannot use theme
23// keys, so the stroke is the terminal sparkline's teal.
24const CHART = Object.freeze({ width: 120, height: 16, color: '#5fafaf', alt: 'Context window fill over the session' });
25
26/**
27 * @param {import('../core/layout.mjs').Line[]} lines
28 * @param {{ Box: unknown, Text: unknown, Svg?: unknown }} elements the surface's table from $.ui.resolve(e)
29 * @param {(tag: unknown, props: object, ...children: unknown[]) => unknown} createElement the engine's `h`
30 * @param {{ typography?: boolean }} [options] true where Text takes `mono` and `tabular` (2.1.299+)
31 */
32export function renderBand(lines, elements, createElement, { typography = false } = {}) {
33  const rowProps = typography ? { wrap: 'truncate-end', mono: true, tabular: true } : { wrap: 'truncate-end' };
34  const textRow = (parts) => createElement(elements.Text, rowProps, ...parts
35    .filter((part) => part.text)
36    .map((part) => createElement(elements.Text, styleFor(part.tone), part.text)));
37
38  const rows = lines.map((line) => {
39    const chartIndex = elements.Svg ? line.findIndex((part) => part.series?.length) : -1;
40    if (chartIndex === -1) return textRow(line);
41    return createElement(
42      elements.Box,
43      { flexDirection: 'row' },
44      textRow(line.slice(0, chartIndex)),
45      createElement(elements.Svg, {
46        source: sparklineSvg(line[chartIndex].series, CHART),
47        alt: CHART.alt,
48        width: CHART.width,
49        height: CHART.height,
50      }),
51    );
52  });
53  return createElement(elements.Box, { flexDirection: 'column' }, ...rows);
54}
55
56function styleFor(tone) {
57  const style = {};
58  if (Object.hasOwn(TONE_COLOR, tone)) style.color = TONE_COLOR[tone];
59  // `bold` rather than `weight`: a tree with `weight` does not draw before Claude Code 2.1.299.
60  if (BOLD_TONES.has(tone)) style.bold = true;
61  return style;
62}
63
src/desktop/cache-ttl.mjs 28 lines
1// The main conversation's prompt-cache lifetime as the band can learn it: the variables and the
2// setting Claude Code reads, and whether the session runs on a plan within its limits. The band
3// reads them through `$` (band.mjs) and passes the answers here.
4
5import { FULL_PERCENT } from '../core/metrics.mjs';
6import { mainCacheTtlMs } from '../core/prompt-cache.mjs';
7import { recordOf } from '../core/values.mjs';
8import { limitsFromRateLimits } from './snapshot.mjs';
9
10/**
11 * @param {{
12 *   env: { FORCE_PROMPT_CACHING_5M?: unknown, CLAUDE_CODE_PROMPT_CACHE_TTL?: unknown, ENABLE_PROMPT_CACHING_1H?: unknown },
13 *   settings: unknown,
14 *   rateLimits: unknown,
15 *   nowMs: number,
16 * }} answers the variables, `$.settings.read()`, and `$.session.usage()`'s windows
17 * @returns {number | null}
18 */
19export function cacheTtlFromEngine({ env, settings, rateLimits, nowMs }) {
20  const planReadings = Object.values(limitsFromRateLimits(rateLimits, nowMs)).filter(Boolean);
21  return mainCacheTtlMs({
22    env,
23    settingTtl: recordOf(settings).promptCacheTtl,
24    isSubscription: planReadings.length > 0,
25    isOverPlan: planReadings.some((reading) => reading.percentUsed >= FULL_PERCENT),
26  });
27}
28
src/desktop/git.mjs 54 lines
1// Git state for the band. The band runs `git status` through `$.process`, which is documented as
2// CLI only; where it is unavailable the branch is read from the session's own HEAD through `$.fs`
3// and the dirty marker is then unknown. The calls on `$` live in band.mjs, where the directory
4// reads them; this file keeps what is decided from their answers.
5
6const SHORT_SHA_LENGTH = 7;
7export const NOT_A_REPO = Object.freeze({ isRepo: false, branch: null, isDirty: false });
8
9/**
10 * The state of a repository known only by its HEAD file.
11 * @param {unknown} head the file's text, or null when it could not be read
12 * @returns {import('../core/git-status.mjs').GitState}
13 */
14export function gitFromHead(head) {
15  return { isRepo: true, branch: head == null ? null : branchFromHead(head), isDirty: false };
16}
17
18function branchFromHead(head) {
19  const text = String(head).trim();
20  if (text.startsWith('ref: refs/heads/')) return text.slice('ref: refs/heads/'.length);
21  return /^[0-9a-f]{7,}$/i.test(text) ? text.slice(0, SHORT_SHA_LENGTH) : null;
22}
23
24/**
25 * The `.git` paths git looks for, from the folder up to its root, nearest first. The folder's own
26 * separators are kept; `/.git` is added to each, which Windows takes as well.
27 * @param {string} folder an absolute folder
28 * @returns {string[]}
29 */
30export function dotGitPaths(folder) {
31  const paths = [];
32  for (let at = String(folder).replace(/[\\/]+$/, ''); ; at = at.slice(0, lastSeparator(at))) {
33    paths.push(`${at}/.git`);
34    if (lastSeparator(at) < 0) return paths;
35  }
36}
37
38/**
39 * The git folder a `.git` file names. A worktree's (and a submodule's) `.git` is a file,
40 * `gitdir: <path>`, the path relative to the folder that holds the file unless absolute.
41 * @param {unknown} text the `.git` file's text, or null when it could not be read
42 * @param {string} dotGit the `.git` file's path
43 * @returns {string | null} null when the text names no folder
44 */
45export function gitDirFromFile(text, dotGit) {
46  const match = /^gitdir:[ \t]*(\S.*?)\s*$/.exec(String(text ?? '').split('\n')[0]);
47  if (!match) return null;
48  const gitDir = match[1];
49  return isAbsolute(gitDir) ? gitDir : `${dotGit.slice(0, lastSeparator(dotGit) + 1)}${gitDir}`;
50}
51
52const lastSeparator = (path) => Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
53const isAbsolute = (path) => /^(?:[\\/]|[A-Za-z]:[\\/])/.test(path);
54
src/desktop/limit-history.mjs 32 lines
1// The shared limit history (core/limit-history.mjs) for the band: the same file the terminal
2// status line keeps, in the folder where Claude Code keeps its configuration. The band reads and
3// writes it through `$` (band.mjs); this file keeps what is decided from the answers.
4
5import { recordLimits } from '../core/limit-history.mjs';
6import { claudeConfigDir } from '../core/paths.mjs';
7import { limitsFromRateLimits } from './snapshot.mjs';
8
9/**
10 * The configuration folder as Claude Code finds it, with no separator at its end, so that
11 * `${folder}/limits-statusline/limit-history.json` is the terminal's limitHistoryPath(folder).
12 * @param {unknown} configDirVariable `CLAUDE_CONFIG_DIR`
13 * @param {unknown} userProfile `USERPROFILE`
14 * @param {unknown} home `HOME`
15 * @returns {string | null} null when no configuration folder is known
16 */
17export function configFolderFor(configDirVariable, userProfile, home) {
18  // USERPROFILE first: on Windows Node's home folder is USERPROFILE even where HOME is set.
19  const configDir = claudeConfigDir(configDirVariable, userProfile || home);
20  return configDir === null ? null : configDir.replace(/[\\/]+$/, '');
21}
22
23/**
24 * The history with this session's plan figures, measured just now; the same history when they
25 * change nothing.
26 * @param {import('../core/limit-history.mjs').LimitHistory} history
27 * @param {{ sessionId: unknown, rateLimits: unknown, nowMs: number }} measurement `session.measure`'s windows
28 */
29export function withMeasuredLimits(history, { sessionId, rateLimits, nowMs }) {
30  return recordLimits(history, { sessionId, limits: limitsFromRateLimits(rateLimits, nowMs), nowMs });
31}
32
src/desktop/policy.mjs 29 lines
1// Where the band draws (userConfig `band`). The terminal has the classic status line at the
2// bottom, so by default the band only draws in the desktop app.
3
4export const BAND_SURFACES = Object.freeze({
5  desktop: Object.freeze(['desktop']),
6  all: Object.freeze(['desktop', 'terminal']),
7  off: Object.freeze([]),
8});
9
10export const DEFAULT_BAND_MODE = 'desktop';
11
12/** @param {unknown} mode userConfig value @param {string} surface `e.surface` */
13export function shouldDraw(mode, surface) {
14  const key = typeof mode === 'string' && Object.hasOwn(BAND_SURFACES, mode) ? mode : DEFAULT_BAND_MODE;
15  return BAND_SURFACES[key].includes(surface);
16}
17
18// Text `mono` and `tabular` arrived in 2.1.299; an older release refuses the whole tree.
19const TYPOGRAPHY_SINCE = Object.freeze([2, 1, 299]);
20
21/** @param {unknown} version `$.session.version().version`, e.g. "2.1.299" */
22export function supportsTypography(version) {
23  const match = /^(\d+)\.(\d+)\.(\d+)/.exec(typeof version === 'string' ? version : '');
24  if (!match) return false;
25  const parts = match.slice(1).map(Number);
26  const firstDifference = parts.findIndex((part, index) => part !== TYPOGRAPHY_SINCE[index]);
27  return firstDifference === -1 || parts[firstDifference] > TYPOGRAPHY_SINCE[firstDifference];
28}
29
src/desktop/snapshot.mjs 77 lines
1// Mods API answers ($.session.usage(), model(), cwd()) plus the tracker → core Snapshot.
2
3import { EMPTY_LIMIT_HISTORY } from '../core/limit-history.mjs';
4import { cacheHitRatio } from '../core/metrics.mjs';
5import { prettyModelName } from '../core/model-name.mjs';
6import { folderName } from '../core/paths.mjs';
7import { promptCacheAfter } from '../core/prompt-cache.mjs';
8import { numberOrNull, recordOf } from '../core/values.mjs';
9import { initialTracker } from './tracker.mjs';
10
11/**
12 * @param {{
13 *   usage: Record<string, unknown> | null, modelRaw: unknown, cwd: unknown,
14 *   tracker: import('./tracker.mjs').Tracker | null, nowMs: number, timeZone?: string | null,
15 *   limitReadings?: import('../core/limit-history.mjs').LimitReadings, cacheTtlMs?: number | null,
16 * }} sources `cacheTtlMs`: the main conversation's cache lifetime, null when unknown
17 * @returns {import('../core/layout.mjs').Snapshot}
18 */
19export function snapshotFromSession({
20  usage, modelRaw, cwd, tracker, nowMs, timeZone = null, limitReadings, cacheTtlMs = null,
21}) {
22  const figures = recordOf(usage);
23  const state = tracker ?? initialTracker();
24  const context = recordOf(figures.context);
25  const startedAt = numberOrNull(figures.startedAt);
26  return {
27    nowMs,
28    timeZone,
29    project: folderName(cwd),
30    git: state.git ?? null,
31    modelName: prettyModelName(modelRaw),
32    costUsd: numberOrNull(recordOf(figures.cost).usd),
33    durationMs: startedAt !== null && nowMs >= startedAt ? nowMs - startedAt : null,
34    cacheHitRatio: cacheHitRatio({
35      inputTokens: state.cache.input,
36      cacheCreationTokens: state.cache.creation,
37      cacheReadTokens: state.cache.read,
38    }),
39    promptCache: Number.isFinite(cacheTtlMs) ? promptCacheAfter(state.lastTurn ?? null, cacheTtlMs) : null,
40    effort: state.effort ?? null,
41    linesChanged: null, // the mods API has no count of changed lines; the terminal gets Claude Code's own
42    context: {
43      usedTokens: numberOrNull(context.tokens),
44      windowTokens: numberOrNull(context.window),
45      percent: numberOrNull(context.percent),
46    },
47    contextHistory: [...state.history],
48    limits: limitsFromRateLimits(figures.rateLimits, nowMs),
49    limitReadings: limitReadings ?? EMPTY_LIMIT_HISTORY.readings,
50  };
51}
52
53/**
54 * The 7-day and 5-hour readings among the windows the last response reported.
55 * @param {unknown} windows `rateLimits` of `$.session.usage()` or of `session.measure`
56 * @param {number} nowMs
57 * @returns {import('../core/limit-history.mjs').Limits}
58 */
59export function limitsFromRateLimits(windows, nowMs) {
60  const list = Array.isArray(windows) ? windows : [];
61  return {
62    sevenDay: limitReading(list, 'seven_day', nowMs),
63    fiveHour: limitReading(list, 'five_hour', nowMs),
64  };
65}
66
67// A window whose reset time has passed is stale; the terminal's stdin drops it the same way.
68function limitReading(windows, kind, nowMs) {
69  const window = recordOf(windows.find((candidate) => candidate?.kind === kind));
70  const percentUsed = numberOrNull(window.percentUsed);
71  if (percentUsed === null) return null;
72  const parsed = typeof window.resetsAt === 'string' ? Date.parse(window.resetsAt) : NaN;
73  const resetsAtMs = Number.isFinite(parsed) ? parsed : null;
74  if (resetsAtMs !== null && resetsAtMs <= nowMs) return null;
75  return { percentUsed, resetsAtMs };
76}
77