Two status lines under the prompt: GitHub user, project, branch, open issues and PRs, Anthropic account and Claude Code version; then the model, session cost…

Two status lines just under the prompt, below Claude Code's own hint line:
octo:my-project (main) 7 issues · 2 PRs me@example.com (max) v2.1.296
Opus 5.5 session $1.24 ctx ━━━━━━━━━━ 31% 5h ━━━━━━━━━━ 21% ↻ 15:00 week ━━━━━━━━━━ 44% ↻ Tue
Each meter is ten cells: the used share in colour, the rest dimmed.
gh api user, cached 2 hours), project folder, git branch (from repo/ when the project uses the {project}/repo/ layout), open issues and PRs in that GitHub repo (cached 5 minutes per repo, hidden off GitHub), the Anthropic account and plan from claude auth status (cached 10 minutes), and the Claude Code version. gh and claude calls give up after 5 seconds, so a dead network cannot hold up session start.It refreshes after every turn. Each part moves to the next line as a whole when the terminal is narrow: two lines when wide, three or four at 80 to 120 columns. Plan limits show for plans that report them (Pro and Max), and context shows ? until the session's first response. Per-model and monthly extra-usage figures are in no supported interface; /usage shows them (anthropics/claude-code#92080).
This plugin is a mod: TypeScript that runs inside Claude Code. It is separate from simpleapps, so it can be turned off on its own in /plugin.
Requires Claude Code 2.1.287 or later.
/plugin install simpleapps-statusline@augur-skills
/reload-plugins
Then delete statusLine from ~/.claude/settings.json, so the replaced statusline-full / statusline-basic script does not draw a second status line.
In a folder you have not trusted yet, no mod loads until you answer the trust prompt.
/plan-usage prints the same lines plus the raw reading, so it works without seeing the terminal:
claude -p "/plan-usage"
From the root of this repo:
claude --plugin-dir plugins/simpleapps-statusline # reloads on save; generates API types (gitignored)
claude plugin validate --strict plugins/simpleapps-statusline
claude plugin test plugins/simpleapps-statusline
pnpm exec tsc -p plugins/simpleapps-statusline/tsconfig.json --noEmit # after --plugin-dir has generated the types
All three MUST pass before pushing; CI does not run them (the type check needs the API types only --plugin-dir generates). Tests stub every process, store, and session call, so they need no network. Loader rules: write each $ call in full and pass $ only to top-level functions; give a command.run hook .catch(...); keep $.state in a const atom declared in types/index.d.ts. Full API: the mods docs.
hooks/register.tsx 470 lines1// Two status lines under the prompt: where you are (GitHub user, project, branch, open issues and
2// PRs, Anthropic account, version), then this session's cost and context and every plan rate limit
3// Claude Code reports (5-hour, weekly) for the signed-in account.
4// Source: $.session.usage(), refreshed on session.measure (after each turn, and when a limit moves).
5import { atom, read, update } from "claude-code";
6import type { EngineInterface, Register } from "claude-code";
7
8import type { Limit, Reading, UsageSnapshot } from "../types";
9
10const COMMAND = "plan-usage";
11
12const snapshot = atom(
13 { plugin: "simpleapps-statusline", key: "snapshot" } as const,
14 {} as UsageSnapshot,
15);
16
17const num = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : undefined);
18
19/** The session's cost in dollars, whether reported as a number or inside an object. */
20export function costUsd(cost: unknown): number | undefined {
21 if (num(cost) !== undefined) return num(cost);
22 if (cost === null || typeof cost !== "object") return undefined;
23 const c = cost as Record<string, unknown>;
24 return num(c.totalUsd) ?? num(c.usd) ?? num(c.total_cost_usd) ?? num(c.totalCostUsd);
25}
26
27/** `$.session.usage()` reduced to what the pane shows; unknown shapes become undefined, never a throw. */
28export function toReading(raw: unknown, at: number): Reading {
29 const u = (raw ?? {}) as Record<string, unknown>;
30 const context = (u.context ?? {}) as Record<string, unknown>;
31 const limits = Array.isArray(u.rateLimits) ? (u.rateLimits as Record<string, unknown>[]) : [];
32 return {
33 at,
34 cost: costUsd(u.cost),
35 contextPercent: num(context.percent),
36 limits: limits.map((l): Limit => ({
37 kind: String(l.kind ?? "limit"),
38 percent: num(l.percentUsed),
39 resetsAt: l.resetsAt === undefined || l.resetsAt === null ? undefined : String(l.resetsAt),
40 })),
41 };
42}
43
44const NAMES: Record<string, string> = {
45 five_hour: "5-hour",
46 fiveHour: "5-hour",
47 session: "5-hour",
48 seven_day: "Weekly",
49 sevenDay: "Weekly",
50 weekly: "Weekly",
51 monthly: "Monthly",
52 spend_limit: "Spend limit",
53};
54
55/** A limit's label: known kinds by name, others (per-model, extra usage) from their own kind. */
56export function limitName(kind: string): string {
57 if (NAMES[kind]) return NAMES[kind];
58 const words = kind.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ");
59 return words.charAt(0).toUpperCase() + words.slice(1);
60}
61
62const pad = (n: number) => String(n).padStart(2, "0");
63
64/** When a limit resets: the time today, else the weekday, else the date. */
65export function resetLabel(resetsAt: string | undefined, now: number): string {
66 if (resetsAt === undefined) return "";
67 const asNumber = Number(resetsAt);
68 // Seconds or milliseconds since the epoch, or an ISO date
69 const ms = Number.isNaN(asNumber)
70 ? Date.parse(resetsAt)
71 : asNumber < 1e12
72 ? asNumber * 1000
73 : asNumber;
74 if (Number.isNaN(ms)) return "";
75 const d = new Date(ms);
76 const days = (ms - now) / 86_400_000;
77 if (days < 1 && d.getDate() === new Date(now).getDate())
78 return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
79 if (days < 7) return d.toLocaleDateString("en-US", { weekday: "short" });
80 return `${d.getMonth() + 1}/${d.getDate()}`;
81}
82
83/** Red at 90% and over, yellow at 75%. */
84export function percentColor(percent: number | undefined) {
85 if (percent === undefined) return undefined;
86 if (percent >= 90) return "red";
87 return percent >= 75 ? "yellow" : undefined;
88}
89
90const pct = (p: number | undefined) => (p === undefined ? "?" : `${Math.round(p)}%`);
91
92type Row = [string, string | undefined];
93
94/** The bar's parts (and `/plan-usage`'s lines) as [text, color]. */
95export function rows(r: Reading | undefined, now: number): Row[] {
96 if (r === undefined) return [["No reading yet", undefined]];
97 const cost = r.cost === undefined ? "" : `$${r.cost.toFixed(2)} · `;
98 const session: Row = [`Session ${cost}ctx ${pct(r.contextPercent)}`, undefined];
99 const limits = r.limits.map((l): Row => {
100 const reset = resetLabel(l.resetsAt, now);
101 const text = `${limitName(l.kind)} ${pct(l.percent)}${reset ? ` resets ${reset}` : ""}`;
102 return [text, percentColor(l.percent)];
103 });
104 const none: Row = ["No plan limits reported", undefined];
105 return [session, ...(limits.length ? limits : [none])];
106}
107
108const SHORT: Record<string, string> = { five_hour: "5h", seven_day: "week" };
109
110/** One labelled figure in the bar; `percent` draws a meter. */
111export type Part = { label: string; value: string; percent?: number; note?: string };
112
113/** The bar's figures: session cost, context, then each limit with its reset. */
114export function parts(r: Reading, now: number): Part[] {
115 const cost: Part[] =
116 r.cost === undefined ? [] : [{ label: "session", value: `$${r.cost.toFixed(2)}` }];
117 const context: Part = { label: "ctx", value: pct(r.contextPercent), percent: r.contextPercent };
118 const limits = r.limits.map((l): Part => {
119 const reset = resetLabel(l.resetsAt, now);
120 const label = SHORT[l.kind] ?? limitName(l.kind).toLowerCase();
121 return {
122 label,
123 value: pct(l.percent),
124 percent: l.percent,
125 note: reset ? `↻ ${reset}` : undefined,
126 };
127 });
128 return [...cost, context, ...limits];
129}
130
131/** A model id as people say it: `claude-opus-5-5[1m]` → `Opus 5.5 (1M)`. */
132function prettyModel(id: string): string {
133 const [, base = id, window] = /^(.*?)(?:\[(\w+)\])?$/.exec(id) ?? [];
134 const words = base.replace(/^claude-/, "").split("-");
135 const name = words
136 .filter((w) => !/^\d+$/.test(w))
137 .map((w) => w.charAt(0).toUpperCase() + w.slice(1));
138 const version = words.filter((w) => /^\d+$/.test(w)).join(".");
139 const label = [...name, version].filter(Boolean).join(" ");
140 return window ? `${label} (${window.toUpperCase()})` : label;
141}
142
143/** `$.session.model()` as a short name, whether a string or an object. */
144export function modelLabel(raw: unknown): string | undefined {
145 if (typeof raw === "string") return raw ? prettyModel(raw) : undefined;
146 if (raw === null || typeof raw !== "object") return undefined;
147 const m = raw as Record<string, unknown>;
148 const display = m.displayName ?? m.display_name;
149 if (typeof display === "string" && display) return display;
150 const id = m.id ?? m.model ?? m.name;
151 return typeof id === "string" && id ? prettyModel(id) : undefined;
152}
153
154/** `$.session.version()` as `v2.1.296`, whether a string or an object. */
155export function versionLabel(raw: unknown): string | undefined {
156 const v =
157 raw !== null && typeof raw === "object" ? (raw as Record<string, unknown>).version : raw;
158 if (typeof v !== "string" || !v) return undefined;
159 return v.startsWith("v") ? v : `v${v}`;
160}
161
162const WIDTH = 10;
163
164/** A meter's filled and empty cell counts; 0–100%, a sliver shows for any use. */
165export function meter(percent: number): [number, number] {
166 const filled =
167 percent <= 0 ? 0 : Math.max(1, Math.min(WIDTH, Math.round((percent / 100) * WIDTH)));
168 return [filled, WIDTH - filled];
169}
170
171/** Meter colour: green, yellow at 75%, red at 90%. */
172export const meterColor = (percent: number) => percentColor(percent) ?? "green";
173
174/** `/plan-usage`: the lines, then the raw reading, so new kinds show up as they are. */
175export function checkText(r: Reading, raw: unknown, now: number): string {
176 const lines = [whereLabel(r), r.model, ...rows(r, now).map(([t]) => t)].filter(Boolean);
177 return [...lines, `raw: ${JSON.stringify(raw)}`].join("\n");
178}
179
180/** The first line of a command's output; undefined when it failed or printed nothing. */
181export function firstLine(out: unknown): string | undefined {
182 const o = out as { exitCode?: number; stdout?: string } | undefined;
183 if (o === undefined || o.exitCode !== 0) return undefined;
184 return o.stdout?.split("\n")[0]?.trim() || undefined;
185}
186
187/** `$.session.root()` as a path, whether a string or an object. */
188export function rootPath(raw: unknown): string | undefined {
189 const p = raw !== null && typeof raw === "object" ? (raw as Record<string, unknown>).path : raw;
190 return typeof p === "string" && p ? p.replace(/\/+$/, "") : undefined;
191}
192
193/** `claude auth status --json` as `email (plan)`; undefined when signed out or unreadable. */
194export function accountLabel(out: unknown): string | undefined {
195 const o = out as { exitCode?: number; stdout?: string } | undefined;
196 if (o === undefined || o.exitCode !== 0) return undefined;
197 try {
198 const s = JSON.parse(o.stdout ?? "") as Record<string, unknown>;
199 if (s.loggedIn !== true) return "signed out";
200 const who = typeof s.email === "string" && s.email ? s.email : String(s.authMethod ?? "");
201 return typeof s.subscriptionType === "string" && s.subscriptionType
202 ? `${who} (${s.subscriptionType})`
203 : who || undefined;
204 } catch {
205 return undefined;
206 }
207}
208
209/** A GitHub remote URL (ssh or https) as `owner/name`; undefined for any other host. */
210export function ghRepo(url: string | undefined): string | undefined {
211 const m = /github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?\/?$/.exec(url ?? "");
212 return m ? `${m[1]}/${m[2]}` : undefined;
213}
214
215/** The open issue and PR counts from the GraphQL answer; undefined when it failed. */
216export function countsOf(out: unknown): { issues: number; prs: number } | undefined {
217 const o = out as { exitCode?: number; stdout?: string } | undefined;
218 if (o === undefined || o.exitCode !== 0) return undefined;
219 try {
220 const repo = (JSON.parse(o.stdout ?? "") as { data?: { repository?: Record<string, unknown> } })
221 .data?.repository;
222 const issues = num((repo?.issues as Record<string, unknown> | undefined)?.totalCount);
223 const prs = num((repo?.pullRequests as Record<string, unknown> | undefined)?.totalCount);
224 return issues === undefined || prs === undefined ? undefined : { issues, prs };
225 } catch {
226 return undefined;
227 }
228}
229
230const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
231
232/** `7 issues · 2 PRs`; undefined when the counts are unknown. */
233export function countsLabel(r: Reading): string | undefined {
234 if (r.issues === undefined || r.prs === undefined) return undefined;
235 return `${plural(r.issues, "issue")} · ${plural(r.prs, "PR")}`;
236}
237
238/** `user:project (branch) · N issues · N PRs · account · version`, the first line. */
239export function whereLabel(r: Reading): string {
240 const where = `${r.user ?? "?"}:${r.project ?? "?"}${r.branch ? ` (${r.branch})` : ""}`;
241 return [where, countsLabel(r), r.account, r.version].filter(Boolean).join(" · ");
242}
243
244// GitHub login is cached this long, as the old status line did
245const USER_TTL_MS = 2 * 60 * 60 * 1000;
246const ACCOUNT_TTL_MS = 10 * 60 * 1000;
247const COUNTS_TTL_MS = 5 * 60 * 1000;
248// A repo's cached counts are dropped once unused this long
249const COUNTS_KEEP_MS = 24 * 60 * 60 * 1000;
250// Network calls (gh, claude) give up after this, so a dead network cannot hold up session start
251// for $.process.run's 30 s default
252const NETWORK = { timeoutMs: 5000 } as const;
253
254const COUNTS_QUERY =
255 "query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { issues(states: OPEN) { totalCount } pullRequests(states: OPEN) { totalCount } } }";
256
257/** The GitHub login, cached for two hours; the old login when gh fails. */
258async function ghLogin($: EngineInterface, now: number): Promise<string | undefined> {
259 const cached = (await $.store.get("ghUser").catch(() => undefined)) as
260 { login: string; at: number } | undefined;
261 if (cached && now - cached.at < USER_TTL_MS) return cached.login;
262 const login = firstLine(
263 await $.process.run(["gh", "api", "user", "--jq", ".login"], NETWORK).catch(() => undefined),
264 );
265 if (!login) return cached?.login;
266 await $.store.set("ghUser", { login, at: now }).catch(() => undefined);
267 return login;
268}
269
270/** The Anthropic account Claude Code is signed in as; re-read soon so a /login shows. */
271async function claudeAccount($: EngineInterface, now: number): Promise<string | undefined> {
272 const cached = (await $.store.get("claudeAccount").catch(() => undefined)) as
273 { label: string; at: number } | undefined;
274 if (cached && now - cached.at < ACCOUNT_TTL_MS) return cached.label;
275 const label = accountLabel(
276 await $.process.run(["claude", "auth", "status", "--json"], NETWORK).catch(() => undefined),
277 );
278 if (!label) return cached?.label;
279 await $.store.set("claudeAccount", { label, at: now }).catch(() => undefined);
280 return label;
281}
282
283type Counts = { repo: string; issues: number; prs: number; at: number };
284
285// One store key per repo: $.store is shared by every session, so sessions in different repos
286// must not overwrite each other's counts
287const COUNTS_PREFIX = "ghCounts:";
288
289/** Drop counts for repos unused for a day, and the old single `ghCounts` key. */
290async function pruneCounts($: EngineInterface, now: number) {
291 const keys = (await $.store.keys().catch(() => [])) as string[];
292 for (const key of keys) {
293 if (key !== "ghCounts" && !key.startsWith(COUNTS_PREFIX)) continue;
294 const entry = (await $.store.get(key).catch(() => undefined)) as Counts | undefined;
295 if (key === "ghCounts" || !entry || now - entry.at > COUNTS_KEEP_MS)
296 await $.store.delete(key).catch(() => undefined);
297 }
298}
299
300/** Open issues and PRs in the folder's GitHub repo, cached five minutes per repo; none off GitHub. */
301async function repoCounts($: EngineInterface, gitDir: string | undefined, now: number) {
302 if (gitDir === undefined) return undefined;
303 const repo = ghRepo(
304 firstLine(
305 await $.process
306 .run(["git", "-C", gitDir, "remote", "get-url", "origin"])
307 .catch(() => undefined),
308 ),
309 );
310 if (repo === undefined) return undefined;
311 const key = `${COUNTS_PREFIX}${repo}`;
312 const same = (await $.store.get(key).catch(() => undefined)) as Counts | undefined;
313 if (same && now - same.at < COUNTS_TTL_MS) return same;
314 const [owner, name] = repo.split("/");
315 const counts = countsOf(
316 await $.process
317 .run(
318 [
319 "gh",
320 "api",
321 "graphql",
322 "-f",
323 `query=${COUNTS_QUERY}`,
324 "-f",
325 `owner=${owner}`,
326 "-f",
327 `name=${name}`,
328 ],
329 NETWORK,
330 )
331 .catch(() => undefined),
332 );
333 if (counts === undefined) return same;
334 const fresh: Counts = { repo, ...counts, at: now };
335 await $.store.set(key, fresh).catch(() => undefined);
336 await pruneCounts($, now);
337 return fresh;
338}
339
340/** Everything the lines show, read fresh, plus the raw values for `/plan-usage`. */
341async function measure($: EngineInterface) {
342 const now = await $.clock.now();
343 const [usage, model, version, rootRaw] = await Promise.all([
344 $.session.usage().catch(() => undefined),
345 Promise.resolve($.session.model()).catch(() => undefined),
346 Promise.resolve($.session.version()).catch(() => undefined),
347 Promise.resolve($.session.root()).catch(() => undefined),
348 ]);
349 const root = rootPath(rootRaw);
350 // The project layout keeps the git repo in repo/; fall back to the folder itself
351 const nested = root ? await $.fs.exists(`${root}/repo/.git`).catch(() => false) : false;
352 const gitDir = nested ? `${root}/repo` : root;
353 const branch = gitDir
354 ? firstLine(
355 await $.process
356 .run(["git", "-C", gitDir, "branch", "--show-current"])
357 .catch(() => undefined),
358 )
359 : undefined;
360 const [user, account, counts] = await Promise.all([
361 ghLogin($, now),
362 claudeAccount($, now),
363 repoCounts($, gitDir, now),
364 ]);
365 const reading: Reading = {
366 issues: counts?.issues,
367 prs: counts?.prs,
368 account,
369 ...toReading(usage, now),
370 model: modelLabel(model),
371 version: versionLabel(version),
372 user,
373 project: root?.split("/").pop(),
374 branch,
375 };
376 await update($, snapshot, () => ({ reading }));
377 return { reading, raw: { usage, model, version, root: rootRaw }, now };
378}
379
380export const register: Register = (on) => {
381 on("session.start", async ($, e, next) => {
382 await $.command.register({
383 name: COMMAND,
384 description: "Print this session's usage and the plan limits, with the raw reading.",
385 });
386 await measure($);
387 return next(e);
388 });
389
390 // After each turn: the model (/model) or the branch may have changed
391 on("session.measure", async ($, e, next) => {
392 await measure($);
393 return next(e);
394 });
395
396 on("command.run", { command: COMMAND }, async ($) => {
397 const { reading, raw, now } = await measure($);
398 return { text: checkText(reading, raw, now) };
399 }).catch(async (_$, _e, next) => ({
400 text: `plan-usage failed: ${next.error?.message ?? "unknown"}`,
401 }));
402
403 // Two lines under the prompt, below Claude Code's own hint line. Each part is a whole unit
404 // that wraps to the next line when the terminal is too narrow, never split mid-word: two
405 // lines when wide, three or four when narrow.
406 on("ui.render", { component: "PromptHint" }, async ($, e, next) => {
407 const s = await read($, snapshot);
408 if (s.reading === undefined) return next(e);
409 const { Box, Text } = $.ui.resolve(e);
410 const theirs = await next(e);
411 const now = await $.clock.now();
412 const counts = countsLabel(s.reading);
413 return (
414 <Box flexDirection="column">
415 {theirs}
416 <Box flexDirection="row" flexWrap="wrap" columnGap={3}>
417 {/* The location may shrink and wrap inside itself only when it alone is too wide */}
418 <Text>
419 <Text color="green">{s.reading.user ?? "?"}</Text>
420 <Text dimColor>:</Text>
421 <Text color="blue">{s.reading.project ?? "?"}</Text>
422 {s.reading.branch && <Text dimColor>{` (${s.reading.branch})`}</Text>}
423 </Text>
424 {counts && (
425 <Box flexShrink={0}>
426 <Text bold>{counts}</Text>
427 </Box>
428 )}
429 {s.reading.account && (
430 <Box flexShrink={0}>
431 <Text color="magenta">{s.reading.account}</Text>
432 </Box>
433 )}
434 {s.reading.version && (
435 <Box flexShrink={0}>
436 <Text color="cyan">{s.reading.version}</Text>
437 </Box>
438 )}
439 </Box>
440 <Box flexDirection="row" flexWrap="wrap" columnGap={3}>
441 {s.reading.model && (
442 <Box flexShrink={0}>
443 <Text color="yellow">{s.reading.model}</Text>
444 </Box>
445 )}
446 {parts(s.reading, now).map((p) => {
447 const [filled, empty] = p.percent === undefined ? [0, 0] : meter(p.percent);
448 const color = p.percent === undefined ? undefined : meterColor(p.percent);
449 return (
450 <Box flexDirection="row" flexShrink={0} columnGap={1}>
451 <Text dimColor>{p.label}</Text>
452 {p.percent !== undefined && (
453 <Text>
454 <Text color={color}>{"━".repeat(filled)}</Text>
455 <Text dimColor>{"━".repeat(empty)}</Text>
456 </Text>
457 )}
458 <Text bold color={color}>
459 {p.value}
460 </Text>
461 {p.note && <Text dimColor>{p.note}</Text>}
462 </Box>
463 );
464 })}
465 </Box>
466 </Box>
467 );
468 });
469};
470types/index.d.ts 31 lines1/** One plan rate limit from $.session.usage(); `kind` is Claude Code's own name for it. */
2export type Limit = { kind: string; percent?: number; resetsAt?: string };
3
4/** What the pane shows, taken from one $.session.usage() call. */
5export type Reading = {
6 at: number;
7 cost?: number;
8 contextPercent?: number;
9 limits: Limit[];
10 // Current model (starts the meters line) and Claude Code version (ends the first line)
11 model?: string;
12 version?: string;
13 // GitHub login, project folder and git branch, for the line above the meters
14 user?: string;
15 project?: string;
16 branch?: string;
17 // Open issues and PRs in that GitHub repo
18 issues?: number;
19 prs?: number;
20 // Anthropic account Claude Code is signed in as: `email (plan)`
21 account?: string;
22};
23
24export type UsageSnapshot = { reading?: Reading };
25
26declare module "claude-code" {
27 interface PluginState {
28 "simpleapps-statusline": { snapshot: UsageSnapshot };
29 }
30}
31