Responsive statusline mod for Claude Code (function hooks, no command hooks); figures computed by the core-guards statusline engine

A plugin ecosystem that turns Claude Code into a supervised, multi-agent development environment. Expert agents write code, hooks enforce quality in real-time, skills inject framework-specific knowledge, and intelligent cartography auto-maps plugins and projects — so Claude never guesses, never duplicates, and always follows your architecture.


Scripted demo — a faithful reconstruction of the real hook → agent → sniper cycle. Regenerate with
vhs docs/demo/hook-sniper.tape.
Without plugins: Claude Code writes code based on its training data. It can hallucinate APIs, duplicate existing code, ignore your project structure, and produce inconsistent quality.
With Fusengine plugins:
# Add marketplace
/plugin marketplace add fusengine/agents
# Install all plugins
/plugin install fuse-ai-pilot fuse-commit-pro fuse-laravel fuse-nextjs fuse-react fuse-astro fuse-swift-apple-expert fuse-solid fuse-tailwindcss fuse-design fuse-prompt-engineer fuse-shadcn-ui fuse-security fuse-changelog fuse-lessons fuse-tanstack-start fuse-typescript fuse-php fuse-rust fuse-go fuse-motion fuse-statusline # fuse-statusline: responsive status line mod, replaces the classic one (the installer can also set it up)
# Setup (hooks + API keys + MCP servers)
~/.claude/plugins/marketplaces/fusengine-plugins/setup.sh # macOS / Linux
~\.claude\plugins\marketplaces\fusengine-plugins\setup.ps1 # Windows
Statusline (optional): the fuse-statusline mod is the recommended option — the installer offers it (default yes) and it replaces the classic status line. The classic statusline stays available and is configured with:
bun --cwd ~/.claude/plugins/marketplaces/fusengine-plugins/plugins/core-guards/statusline run config
User prompt → Hook detects project type → Expert agent activated
→ Hook loads SOLID references → Agent reads docs via MCP
→ Hook blocks if DRY violation → Agent writes code
→ Hook checks file size → Agent runs Sniper validation
→ Commit gate blocks on lint/type errors → Commit with version bump
Every step is intercepted. Claude cannot skip research, cannot duplicate code, cannot exceed file size limits, and cannot commit with lint or type errors.
Each plugin provides an expert agent that auto-activates when it detects the framework in your project. The agent has access to official documentation via MCP servers and follows SOLID principles enforced by hooks.
| Plugin | Detects | What the agent does |
|---|---|---|
| fuse-nextjs | next.config.* | App Router, RSC, Prisma 7, Better Auth, proxy.ts patterns |
| fuse-laravel | composer.json + artisan | Eloquent, Livewire, Blade, queues, Sanctum, Stripe Connect |
| fuse-react | package.json + React | React 19 hooks, TanStack Router/Form, Zustand stores |
| fuse-astro | astro.config.* | Islands, Content Layer, Actions, SEO, Starlight, i18n |
| fuse-swift-apple-expert | Package.swift | SwiftUI, concurrency, all Apple platforms (iOS → visionOS) |
| fuse-tailwindcss | tailwind.config.* | v4.1 CSS-first config, @theme, @utility, OKLCH colors |
| fuse-design | Any UI task | Gemini Design MCP + shadcn/ui + WCAG 2.2 accessibility |
| fuse-shadcn-ui | components.json | Radix/Base UI detection, registry, theming, migration |
| fuse-tanstack-start | @tanstack/react-start | Server functions, selective SSR, middleware, server routes, Nitro deploy |
| fuse-typescript | tsconfig.json (no framework) | Pure TS 6.0, Node 24 LTS / Bun 1.3, strict typing, ESM packaging |
| fuse-php | composer.json (no artisan) | PHP 8.5, PER-CS 3.0, PHPStan, PSR, Slim / Symfony components |
| fuse-rust | Cargo.toml | Rust 1.96+, 2024 edition, ownership, tokio / axum, clippy-clean |
| fuse-go | go.mod | Go 1.26+, concurrency, backend services, golangci-lint v2 |
| Plugin | What it enforces |
|---|---|
| fuse-ai-pilot | APEX workflow orchestration, 7-phase sniper validation, DRY detection via jscpd, 4-level cache (60-75% token savings) |
| fuse-security | OWASP Top 10 scanning, CVE research via NVD/OSV.dev, dependency audit, secrets detection, auth patterns audit |
| fuse-solid | Files < 100 lines, interface separation, JSDoc mandatory — auto-detected for TypeScript, PHP, Swift, Go, Java, Ruby, Rust |
| Plugin | What it automates |
|---|---|
| fuse-commit-pro | Conventional commits with security check, auto version bump, CHANGELOG, git tag — with a pre-commit secret-pattern scan |
| fuse-prompt-engineer | Prompt creation with CoT/Few-Shot/Meta-Prompting, A/B testing, 50+ template library, agent design |
| fuse-cartographer | Intelligent cartography: auto-generates navigable maps of plugins and projects at SessionStart with merge-preserving enrichment — agents navigate via linked index trees, /map --enrich completes descriptions from source frontmatter |
| fuse-changelog | Monitors Claude Code updates, detects breaking changes in plugins, gathers community feedback via Exa |
| fuse-lessons | Per-project "never reproduce" memory — writes compact timestamped lessons to MEMORY/LESSON.md after code edits and force-reads them at session/subagent start so mistakes don't recur across sessions |
| fuse-motion | Product motion films and demos — brief lock, beat map, deterministic HTML scenes rendered to mp4/GIF; owner gates on stills and draft, independent critic review |
| fuse-statusline | Responsive statusline mod above the prompt — pill cards on the desktop app, classic segments on the terminal; cache hit/expiry timer, 5h/7d usage, effort — replaces the classic status line |
| Plugin | What it guards |
|---|---|
| core-guards | Blocks git push --force, rm -rf, npm install without lock, enforces SOLID file limits, tracks session state, DRY duplication blocking — also bundles token-optimization hooks (MCP verbosity caps, MCP + WebFetch disk cache, SessionStart cleanup) |
| claude-rules | Injects APEX/SOLID/DRY rules into every prompt so Claude never forgets the methodology |
| Feature | How it prevents mistakes |
|---|---|
| APEX Workflow | Forces Analyze before coding — no more "let me just write this real quick" |
| Agent Teams | Parallel agents with exclusive file ownership — no merge conflicts |
| DRY Detection | Blocks Write/Edit if function/class name already exists — forces import or shared extraction |
| SOLID Hooks | Denies file save if >100 lines or missing SOLID reference read |
| Sniper Validation | 7-phase post-edit check: explore → research → grep → lint → fix → zero errors |
| 4-Level Cache | Caches exploration, docs, lessons, tests — 60-75% token savings |
| Token-efficient by default | Caps MCP verbosity (Context7/Exa numResults≤3, tokens≤2000), disk cache for MCP results + WebFetch with TTL (48h / 24h) and ripgrep lookup, auto-cleanup at SessionStart — typically saves ~150-200 KB per research-heavy session |
| 23 MCP Servers | Context7, Exa, Astro docs, Gemini Design, shadcn, fuse-browser, and more |
| Smart Commits | Security scan before commit, auto version bump, CHANGELOG, shields.io badge sync |
| Section | Content |
|---|---|
| Getting Started | Installation, configuration, first steps |
| Workflow | APEX methodology, agents, skills, commands |
| Plugins | Per-plugin documentation and skills |
| Reference | Architecture, hooks, MCP servers, cache system |
MIT
hooks/register.tsx 170 lines1import type { EngineInterface, Register } from "claude-code";
2import { atom, read, update } from "claude-code";
3import { cacheRemaining, cacheTtlMs } from "../cache-expiry";
4import { isEngaged } from "../cards-extra";
5import { buildInput, engineArgv } from "../engine";
6import { cacheFigures, toFacts } from "../facts";
7import type { SvgElement } from "../interfaces/statusline-elements.interface";
8import { flow, layout } from "../layout";
9import { terminalLayout } from "../layout-terminal";
10import { parseData } from "../parse-data";
11import { StatuslineView } from "../view";
12import { TERM_INDENT, TerminalView } from "../view-terminal";
13
14const data = atom({ plugin: "fuse-statusline", key: "data" } as const, null);
15const cache = atom({ plugin: "fuse-statusline", key: "cache" } as const, null);
16const effort = atom({ plugin: "fuse-statusline", key: "effort" } as const, null);
17const turnEnd = atom({ plugin: "fuse-statusline", key: "turnEnd" } as const, null);
18const tick = atom({ plugin: "fuse-statusline", key: "tick" } as const, null);
19
20/** How often the cache countdown redraws (it shows minutes, and seconds only under ten). */
21const TICK_MS = 5_000;
22/** At most one refresh per this long from the steps of a running turn. */
23const STEP_REFRESH_MS = 10_000;
24/** The tick stops this long after a turn: past the longest TTL (1h) the countdown reads `expiré`. */
25const TICK_UNTIL_MS = 61 * 60_000;
26
27/** Refresh period for the engine's figures (clock, git, daily spend) between turns. */
28const REFRESH_MS = 30_000;
29/** Upper bound for one engine run. */
30const ENGINE_TIMEOUT_MS = 10_000;
31/** Width assumed when the surface reports none. */
32const FALLBACK_COLUMNS = 80;
33/** Rows the cards may take, whatever the room above the prompt. */
34const MAX_ROWS = 3;
35
36/**
37 * The surface's `Svg`, never the terminal's: its table can carry one at runtime that
38 * draws nothing, which left the bars blank and the cards in hollow outlines.
39 */
40const svgOf = (surface: string, ui: object): SvgElement | undefined =>
41 surface !== "terminal" && "Svg" in ui ? (ui.Svg as SvgElement) : undefined;
42
43/**
44 * Reads the session, runs the engine's emitter on it and stores the engine's figures.
45 * Failures are swallowed: a broken statusline must never break a hook.
46 *
47 * @param $ - The engine interface of the calling hook
48 */
49async function refresh($: EngineInterface): Promise<void> {
50 try {
51 const [home, emitter, id, model, cwd, root, version, usage, now, level] = await Promise.all([
52 $.env.get("HOME"),
53 $.env.get("FUSE_STATUSLINE_EMITTER"),
54 $.session.id(),
55 $.session.model(),
56 $.session.cwd(),
57 $.session.root(),
58 $.session.version(),
59 $.session.usage(),
60 $.clock.now(),
61 read($, effort),
62 ]);
63 const input = buildInput({
64 id,
65 model,
66 cwd,
67 root,
68 version: version.version,
69 usage,
70 now,
71 effort: level,
72 });
73 const result = await $.process.run(engineArgv(home ?? "", emitter), {
74 cwd,
75 stdin: JSON.stringify(input),
76 timeoutMs: ENGINE_TIMEOUT_MS,
77 });
78 const parsed = result.exitCode === 0 ? parseData(result.stdout) : null;
79 if (parsed) await update($, data, () => parsed);
80 } catch {
81 // Keep the last good figures.
82 }
83}
84
85/**
86 * Draws the statusline above the prompt as cards laid out for the band's width:
87 * the engine computes its own figures, the session supplies the live ones.
88 *
89 * @param on - Registers a hook
90 */
91export const register: Register = (on) => {
92 /** When a main step last refreshed the engine's figures (the transcript clock follows each request). */
93 let lastStepRefresh = 0;
94
95 on("session.start", async ($, e, next) => {
96 $.clock.every(REFRESH_MS, () => refresh($));
97 $.clock.every(TICK_MS, async () => {
98 const endedAt = await read($, turnEnd);
99 const t = await $.clock.now();
100 if (endedAt === null || t - endedAt > TICK_UNTIL_MS) return;
101 await update($, tick, () => t);
102 });
103 void refresh($);
104 return next(e);
105 });
106
107 on("turn.step", async function* ($, e, next) {
108 const level = e.effort === undefined ? null : String(e.effort);
109 if (e.agentId === undefined && level !== null && level !== (await read($, effort))) {
110 await update($, effort, () => level);
111 }
112 const t = await $.clock.now();
113 if (e.agentId === undefined && t - lastStepRefresh >= STEP_REFRESH_MS) {
114 lastStepRefresh = t;
115 void refresh($);
116 }
117 return yield* next(e);
118 });
119
120 on("turn.complete", async ($, e, next) => {
121 const figures = e.agentId === undefined ? cacheFigures(e.usage) : null;
122 if (figures) {
123 const endedAt = await $.clock.now();
124 await update($, cache, () => figures);
125 await update($, turnEnd, () => endedAt);
126 }
127 void refresh($);
128 return next(e);
129 });
130
131 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
132 const engine = await read($, data);
133 if (e.props.hasSurvey || !engine) return next(e);
134 const [model, usage, now, cacheNow, effortNow, endedAt, force5m, ttl, enable1h] =
135 await Promise.all([
136 $.session.model(),
137 $.session.usage(),
138 $.clock.now(),
139 read($, cache),
140 read($, effort),
141 read($, turnEnd),
142 $.env.get("FORCE_PROMPT_CACHING_5M"),
143 $.env.get("CLAUDE_CODE_PROMPT_CACHE_TTL"),
144 $.env.get("ENABLE_PROMPT_CACHING_1H"),
145 read($, tick),
146 ]);
147 const subscription = usage.rateLimits.length > 0 || engine.limits !== null;
148 const lifetime = cacheTtlMs({ force5m, ttl, enable1h }, subscription, isEngaged(engine.extra));
149 const remaining = cacheRemaining(engine.cacheClock, endedAt, now, lifetime);
150 const withExpiry = cacheNow && remaining ? { ...cacheNow, ...remaining } : cacheNow;
151 const cacheLeft = !cacheNow && remaining && !remaining.estimated ? remaining : undefined;
152 const facts = {
153 ...toFacts(engine, { model, usage, effort: effortNow, cache: withExpiry }),
154 cacheLeft,
155 };
156 const columns = e.props.bodyColumns || e.viewport?.columns || FALLBACK_COLUMNS;
157 const ui = $.ui.resolve(e);
158 const Svg = svgOf(e.surface, ui);
159 if (e.surface === "terminal" && facts.engine.terminal) {
160 const drawn = terminalLayout(facts, columns - TERM_INDENT, Math.min(MAX_ROWS, e.props.maxRows));
161 return drawn.rows.length > 0 ? TerminalView(ui, drawn) : next(e);
162 }
163 const rows = Svg
164 ? flow(facts, now)
165 : layout(facts, now, columns, Math.min(MAX_ROWS, e.props.maxRows));
166 if (rows.length === 0) return next(e);
167 return StatuslineView(ui, rows, Svg);
168 });
169};
170cache-expiry.ts 123 lines1import type { TtlEnv } from "./interfaces/cache.interface";
2import { THEME } from "./theme";
3import type { CacheClock, CacheLeft, Span } from "./types";
4
5const MIN = 60_000;
6/** Time left from which the countdown reads amber, then red. */
7const AMBER_MS = 2 * MIN;
8const RED_MS = 30_000;
9
10/** Prompt-cache TTLs Claude Code requests. */
11export const TTL_MS = { "5m": 5 * MIN, "1h": 60 * MIN } as const;
12
13/**
14 * The main conversation's cache TTL, in Claude Code's own order of precedence
15 * (code.claude.com/docs/en/prompt-caching): `FORCE_PROMPT_CACHING_5M`, then
16 * `CLAUDE_CODE_PROMPT_CACHE_TTL`, then (unreadable by a mod) the `promptCacheTtl` setting,
17 * then `ENABLE_PROMPT_CACHING_1H`, then the default: one hour on a subscription within its
18 * plan usage, five minutes on an API key or once usage credits are being spent.
19 *
20 * @param env - The variables above
21 * @param subscription - Whether the account has rate-limit windows (a subscription)
22 * @param creditsSpent - Whether usage credits are being spent (the plan's usage is over)
23 * @returns The TTL in milliseconds
24 */
25export function cacheTtlMs(env: TtlEnv, subscription: boolean, creditsSpent: boolean): number {
26 if (env.force5m === "1") return TTL_MS["5m"];
27 if (env.ttl === "5m" || env.ttl === "1h") return TTL_MS[env.ttl];
28 if (env.enable1h === "1") return TTL_MS["1h"];
29 return subscription && !creditsSpent ? TTL_MS["1h"] : TTL_MS["5m"];
30}
31
32/**
33 * Time left until the cache lapses.
34 *
35 * @param turnEnd - `$.clock.now()` when the last main turn ended
36 * @param now - `$.clock.now()`
37 * @param ttl - From `cacheTtlMs`
38 * @returns Milliseconds, 0 once lapsed
39 */
40export const cacheLeftMs = (turnEnd: number, now: number, ttl: number): number =>
41 Math.max(0, turnEnd + ttl - now);
42
43/**
44 * The countdown as text: `4m12s` under ten minutes, `58m` above, `45s` under one, `expiré` at 0.
45 *
46 * A `~` leads an estimate (no transcript clock), never a real reading.
47 *
48 * @param leftMs - From `cacheLeftMs`
49 * @param estimated - Whether the time is an estimate
50 * @returns The text
51 */
52export function expiryText(leftMs: number, estimated = false): string {
53 const tilde = estimated ? "~" : "";
54 if (leftMs <= 0) return `${tilde}expiré`;
55 const s = Math.ceil(leftMs / 1000);
56 const m = Math.floor(s / 60);
57 if (m >= 10) return `${tilde}${m}m`;
58 return m > 0 ? `${tilde}${m}m${(s % 60).toString().padStart(2, "0")}s` : `${tilde}${s}s`;
59}
60
61/**
62 * When the cache lapses: from the transcript's clock (the last main request plus the TTL it
63 * wrote with) when both are known, else estimated from the last main turn's end and the TTL
64 * `cacheTtlMs` infers.
65 *
66 * @param clock - The transcript's clock, if the emitter read it
67 * @param turnEnd - `$.clock.now()` when the last main turn ended
68 * @param now - `$.clock.now()`
69 * @param estimateTtl - The inferred TTL in milliseconds
70 * @returns The time left and whether it is an estimate; `null` with neither source
71 */
72export function cacheRemaining(
73 clock: CacheClock | null,
74 turnEnd: number | null,
75 now: number,
76 estimateTtl: number,
77): { leftMs: number; estimated: boolean } | null {
78 if (clock?.ttl) {
79 return { leftMs: cacheLeftMs(clock.lastRequestAt, now, TTL_MS[clock.ttl]), estimated: false };
80 }
81 return turnEnd === null
82 ? null
83 : { leftMs: cacheLeftMs(turnEnd, now, estimateTtl), estimated: true };
84}
85
86/**
87 * The countdown as spans: ` · ⏱ 4m12s` with room, ` 4m12s` when the cache text is compact.
88 *
89 * @param leftMs - From `cacheLeftMs`; absent before a main turn has ended
90 * @param level - The cache text's abbreviation level
91 * @param estimated - Whether the time is an estimate (drawn with a `~`)
92 * @returns The spans, or `[]` without a countdown
93 */
94export function expirySpans(leftMs: number | undefined, level: number, estimated = false): Span[] {
95 if (leftMs === undefined) return [];
96 const color = expiryColor(leftMs);
97 const text = expiryText(leftMs, estimated);
98 return level >= 2
99 ? [{ text: ` ${text}`, color }]
100 : [{ text: " · ", color: THEME.muted }, { text: `⏱ ${text}`, color }];
101}
102
103/**
104 * The cache segment when only the transcript's clock is known (after a resume or reload,
105 * before a turn has reported ratio and tokens): the word and the countdown, no figure
106 * invented.
107 *
108 * @param left - The countdown
109 * @param level - The cache text's abbreviation level
110 * @param word - The label span, in the caller's style
111 * @returns `Hit · ⏱ 59m`, or `⚡ 59m` when the room runs out
112 */
113export function clockOnlySpans(left: CacheLeft, level: number, word: Span): Span[] {
114 const lead: Span = level >= 2 ? { text: "⚡", color: THEME.muted } : word;
115 return [lead, ...expirySpans(left.leftMs, level, left.estimated)];
116}
117
118/** Muted while there is time, amber under two minutes, red under thirty seconds and once lapsed. */
119export function expiryColor(leftMs: number): string {
120 if (leftMs <= RED_MS) return THEME.bad;
121 return leftMs <= AMBER_MS ? THEME.warn : THEME.muted;
122}
123cards-extra.ts 51 lines1import { bar, makeCard, strong, text } from "./card";
2import type { Card, ExtraCredits, Facts, Item } from "./interfaces/card.interface";
3import { THEME } from "./theme";
4
5/** Cells of the credits bar at each level; 0 drops it. */
6const BAR_AT = [6, 4, 0, 0, 0] as const;
7/** Utilization from which the credits read red instead of amber. */
8export const EXTRA_RED = 80;
9
10/**
11 * Whether usage-based credits are being consumed: the account's `extra_usage.is_enabled`
12 * is true AND `used_credits` is above zero. Enabled-but-unused, or used-but-disabled
13 * (e.g. `disabled_reason: out_of_credits`), is not engaged.
14 *
15 * @param extra - The account's extra-usage credits
16 * @returns True when overage is on and spending
17 */
18export const isEngaged = (extra: ExtraCredits | null | undefined): extra is ExtraCredits =>
19 !!extra && extra.enabled && extra.usedUsd > 0;
20
21/** `$75.50/$100.00`, whole dollars for the limit once the room runs out. */
22function spendText(e: ExtraCredits, level: number): string {
23 const limit = level >= 1 ? `$${e.limitUsd.toFixed(0)}` : `$${e.limitUsd.toFixed(2)}`;
24 return `$${e.usedUsd.toFixed(2)}${level >= 1 ? "/" : " / "}${limit}`;
25}
26
27/**
28 * The credit-usage card: spend over the monthly limit, percentage and bar, amber then red
29 * as utilization climbs. Shown only while credits are engaged (`isEngaged`).
30 *
31 * @param f - Everything the cards draw from
32 * @param level - Abbreviation level: the bar goes, then the label, then the spend
33 * @returns The card, or `null` when switched off, not engaged or without data
34 */
35export function extraCard(f: Facts, level: number): Card | null {
36 const { extra: flag } = f.engine.flags;
37 const extra = f.engine.extra;
38 if (!flag.enabled || !isEngaged(extra)) return null;
39 const color = extra.percent >= EXTRA_RED ? THEME.bad : THEME.warn;
40 const pct = Math.round(extra.percent);
41 const items: Item[] = [];
42 if (level <= 2) items.push(text(strong("Credits", level <= 1 ? THEME.text : color)));
43 if (flag.spending && level <= 3) items.push(text({ text: spendText(extra, level), color }));
44 if (flag.percent) items.push(text(strong(`${pct}%`, color)));
45 const width = BAR_AT[level] ?? 0;
46 if (flag.bar && width > 0) {
47 items.push(bar(width, `credits ${pct}%`, { ratio: extra.percent / 100, fill: color }));
48 }
49 return makeCard("extra", 80, level, items);
50}
51engine.ts 57 lines1import type { HookInput } from "../statusline/src/interfaces/hook-input.interface";
2import { modelName } from "./labels";
3import type { SessionFacts } from "./interfaces/session-facts.interface";
4
5/**
6 * The structured emitter: it runs the statusline engine's own services and prints
7 * their figures as JSON, under the marketplace path every marketplace hook uses.
8 */
9const EMITTER_PATH =
10 "/.claude/plugins/marketplaces/fusengine-plugins/plugins/core-guards/statusline-mod/emit-data.ts";
11
12/**
13 * Builds the argv that runs the engine's structured emitter.
14 *
15 * @param home - The user's home directory
16 * @param override - `FUSE_STATUSLINE_EMITTER`: an absolute path to run instead (development)
17 * @returns The argv for `$.process.run`
18 */
19export function engineArgv(home: string, override?: string | null): string[] {
20 return ["bun", override || home + EMITTER_PATH];
21}
22
23/**
24 * Builds the statusLine stdin payload from the session, as Claude Code sends it.
25 *
26 * @param facts - The session's figures, read by the mod
27 * @returns The HookInput the statusline engine reads
28 */
29export function buildInput(facts: SessionFacts): HookInput {
30 const { usage } = facts;
31 const tokens = usage.context.tokens ?? 0;
32 return {
33 session_id: facts.id,
34 transcript_path: "",
35 cwd: facts.cwd,
36 model: { id: facts.model, display_name: modelName(facts.model) || facts.model },
37 workspace: { current_dir: facts.cwd, project_dir: facts.root },
38 version: facts.version,
39 output_style: { name: "default" },
40 effort: facts.effort ? { level: facts.effort } : undefined,
41 cost: usage.cost && {
42 total_cost_usd: usage.cost.usd,
43 total_duration_ms: facts.now - usage.startedAt,
44 total_api_duration_ms: 0,
45 total_lines_added: 0,
46 total_lines_removed: 0,
47 },
48 context_window: {
49 total_input_tokens: tokens,
50 total_output_tokens: 0,
51 context_window_size: usage.context.window,
52 used_percentage: usage.context.percent ?? null,
53 current_usage: { input_tokens: tokens },
54 },
55 };
56}
57facts.ts 53 lines1import type { ModelUsage } from "claude-code";
2import type { CacheFigures, EngineFacts, Facts, RateWindow } from "./interfaces/card.interface";
3import type { SessionReading } from "./interfaces/session-facts.interface";
4
5/**
6 * What a turn did with the prompt cache: read / (input + read + written) and the two counts.
7 *
8 * @param usage - The turn's token counts (`turn.complete`'s `usage`)
9 * @returns The figures, or `null` when the turn counted no input
10 */
11export function cacheFigures(usage: ModelUsage | undefined): CacheFigures | null {
12 if (!usage) return null;
13 const read = usage.cache_read_input_tokens;
14 const written = usage.cache_creation_input_tokens;
15 const total = usage.input_tokens + read + written;
16 return total > 0 ? { ratio: read / total, read, written } : null;
17}
18
19/** One rate-limit window by its kind, if the session reports it. */
20function windowOf(
21 limits: SessionReading["usage"]["rateLimits"],
22 kind: string,
23): RateWindow | undefined {
24 const found = limits.find((l) => l.kind === kind);
25 return found && { percent: found.percentUsed, resetsAt: found.resetsAt };
26}
27
28/**
29 * Joins the engine's figures and the live session's into what the cards draw from.
30 * The session's rate-limit windows win; the engine's OAuth cache fills in until the session
31 * has them. A figure neither has stays absent: its card piece is hidden, never zeroed.
32 *
33 * @param engine - What the engine's emitter printed
34 * @param s - What the mod read from the session
35 * @returns The facts
36 */
37export function toFacts(engine: EngineFacts, s: SessionReading): Facts {
38 const { context, rateLimits, cost } = s.usage;
39 const tokens = context.tokens ?? 0;
40 return {
41 engine,
42 model: s.model,
43 effort: s.effort ?? undefined,
44 cache: s.cache ?? undefined,
45 contextTokens: tokens,
46 contextWindow: context.window,
47 contextPercent: context.percent ?? (context.window > 0 ? (tokens / context.window) * 100 : 0),
48 fiveHour: windowOf(rateLimits, "five_hour") ?? engine.limits?.fiveHour,
49 sevenDay: windowOf(rateLimits, "seven_day") ?? engine.limits?.sevenDay,
50 costUsd: cost?.usd,
51 };
52}
53interfaces/statusline-elements.interface.ts 8 lines1import type { Elements, RenderSurface } from "claude-code";
2
3/** The element constructors the statusline view draws with, whichever surface renders it. */
4export type StatuslineElements = Pick<Elements[RenderSurface], "Box" | "Text">;
5
6/** The desktop's vector leaf; the terminal's table has none. */
7export type SvgElement = Elements["desktop"]["Svg"];
8layout.ts 70 lines1import { cardsAt, MAX_LEVEL } from "./cards";
2import type { Card, Facts, Row } from "./interfaces/card.interface";
3
4/** Cells between two cards of a row. */
5export const CARD_GAP = 1;
6
7/** Cards flowed into rows, a card never split; `null` when one card alone is wider than `width`. */
8function wrap(cards: Card[], width: number): Row[] | null {
9 const rows: Row[] = [];
10 let row: Card[] = [];
11 let used = 0;
12 for (const card of cards) {
13 if (card.width > width) return null;
14 const next = used + (row.length > 0 ? CARD_GAP : 0) + card.width;
15 if (row.length > 0 && next > width) {
16 rows.push(row);
17 row = [];
18 used = 0;
19 }
20 used += (row.length > 0 ? CARD_GAP : 0) + card.width;
21 row.push(card);
22 }
23 if (row.length > 0) rows.push(row);
24 return rows;
25}
26
27/**
28 * Every card, whole, as one row for a surface that flows them itself (the desktop's
29 * `flexWrap`): its cells are the code font's, not the proportional text's, so a width in
30 * cells over-counts what the text takes and pre-wrapping would leave half the band empty.
31 *
32 * @param f - Everything the cards draw from
33 * @param now - `$.clock.now()`
34 * @returns One row of every card, or none when there is nothing to show
35 */
36export function flow(f: Facts, now: number): Row[] {
37 const cards = cardsAt(f, 0, now, true);
38 return cards.length > 0 ? [cards] : [];
39}
40
41/**
42 * Lays the cards out for a width. Cards flow as whole units onto as many rows as
43 * `maxRows` allows; short of room every card abbreviates, level by level (path to its
44 * last directory, shorter bars, resets and counts dropped); only when the most
45 * abbreviated cards still do not fit does the least important card go, and the
46 * abbreviation starts over.
47 *
48 * @param f - Everything the cards draw from
49 * @param now - `$.clock.now()`
50 * @param width - Cells available; the band's `bodyColumns`
51 * @param maxRows - Rows the layout may take
52 * @returns The rows to draw; empty when there is nothing to show
53 */
54export function layout(f: Facts, now: number, width: number, maxRows: number): Row[] {
55 const all = cardsAt(f, 0, now);
56 const pool = new Set(all.map((c) => c.id));
57 const rank = new Map(all.map((c) => [c.id, c.importance]));
58 while (pool.size > 0) {
59 for (let level = 0; level <= MAX_LEVEL; level++) {
60 const cards = cardsAt(f, level, now).filter((c) => pool.has(c.id));
61 const rows = wrap(cards, width);
62 if (rows && rows.length > 0 && rows.length <= maxRows) return rows;
63 }
64 const lowest = [...pool].sort((a, b) => (rank.get(a) ?? 0) - (rank.get(b) ?? 0))[0];
65 if (lowest === undefined) break;
66 pool.delete(lowest);
67 }
68 return [];
69}
70layout-terminal.ts 64 lines1import type { Facts } from "./interfaces/card.interface";
2import type { TermLayout, TermRow, TermSegment } from "./interfaces/terminal.interface";
3import { decode } from "./sgr";
4import { termSegments, widthOf } from "./segments-terminal";
5
6/** The cache segment's last abbreviation level. */
7const MAX_LEVEL = 2;
8
9/** One line's segments flowed into rows, a segment never split; `null` when one is too wide. */
10function wrap(segments: TermSegment[], width: number, gap: number): TermRow[] | null {
11 const rows: TermRow[] = [];
12 let row: TermSegment[] = [];
13 let used = 0;
14 for (const s of segments) {
15 if (s.width > width) return null;
16 const next = used + (row.length > 0 ? gap : 0) + s.width;
17 if (row.length > 0 && next > width) {
18 rows.push(row);
19 row = [];
20 used = 0;
21 }
22 used += (row.length > 0 ? gap : 0) + s.width;
23 row.push(s);
24 }
25 if (row.length > 0) rows.push(row);
26 return rows;
27}
28
29/** Both lines wrapped on their own, line 1 first; `null` when a segment does not fit. */
30function rowsOf(segments: TermSegment[], width: number, gap: number): TermRow[] | null {
31 const lines = [1, 2].map((n) => wrap(segments.filter((s) => s.line === n), width, gap));
32 return lines.some((l) => l === null) ? null : lines.flatMap((l) => l ?? []);
33}
34
35/**
36 * Lays the old statusline's segments out for a width: they flow as whole units, line 1 then
37 * line 2 as the engine splits them, onto at most `maxRows` rows. Short of room the cache
38 * segment abbreviates; only then does the least important segment (the highest priority
39 * number) go, and the abbreviation starts over.
40 *
41 * @param f - Everything the cards draw from
42 * @param width - Cells available; the band's `bodyColumns`
43 * @param maxRows - Rows the layout may take
44 * @returns The rows and the separator; no rows when nothing fits or the engine gave no segments
45 */
46export function terminalLayout(f: Facts, width: number, maxRows: number): TermLayout {
47 const separator = decode(f.engine.terminal?.separator ?? "");
48 const gap = widthOf(separator);
49 const dropped = new Set<string>();
50 for (;;) {
51 for (let level = 0; level <= MAX_LEVEL; level++) {
52 const all = termSegments(f, level).filter((s) => !dropped.has(s.id));
53 const rows = rowsOf(all, width, gap);
54 if (all.length === 0) return { rows: [], separator };
55 if (rows && rows.length > 0 && rows.length <= maxRows) return { rows, separator };
56 }
57 const last = termSegments(f, MAX_LEVEL)
58 .filter((s) => !dropped.has(s.id))
59 .at(-1);
60 if (!last) return { rows: [], separator };
61 dropped.add(last.id);
62 }
63}
64parse-data.ts 81 lines1import { isObject } from "./is-object";
2import type { CacheClock, EngineFacts, EngineLimits, RateWindow, TerminalLook } from "./types";
3
4
5/** A well-formed window, else `undefined`. */
6function cleanWindow(w: unknown): RateWindow | undefined {
7 if (!isObject(w) || typeof w.percent !== "number" || !Number.isFinite(w.percent)) return undefined;
8 return typeof w.resetsAt === "string"
9 ? { percent: w.percent, resetsAt: w.resetsAt }
10 : { percent: w.percent };
11}
12
13/** The usage windows, keeping only well-formed ones; an older emitter has none. */
14function cleanLimits(v: unknown): EngineLimits | null {
15 if (!isObject(v)) return null;
16 const fiveHour = cleanWindow(v.fiveHour);
17 const sevenDay = cleanWindow(v.sevenDay);
18 return fiveHour || sevenDay ? { fiveHour, sevenDay } : null;
19}
20
21/** The transcript's cache clock when well-formed, else `null`. */
22function cleanClock(v: unknown): CacheClock | null {
23 if (!isObject(v) || typeof v.lastRequestAt !== "number" || !Number.isFinite(v.lastRequestAt)) {
24 return null;
25 }
26 return { lastRequestAt: v.lastRequestAt, ttl: v.ttl === "5m" || v.ttl === "1h" ? v.ttl : null };
27}
28
29/** The engine's segments, keeping only well-formed ones; an older emitter has none. */
30function cleanTerminal(v: unknown): TerminalLook | null {
31 if (!isObject(v) || typeof v.separator !== "string" || !Array.isArray(v.segments)) return null;
32 const segments = v.segments.filter(
33 (s): s is TerminalLook["segments"][number] =>
34 isObject(s) &&
35 typeof s.name === "string" &&
36 typeof s.priority === "number" &&
37 (s.line === 1 || s.line === 2) &&
38 typeof s.ansi === "string",
39 );
40 return segments.length > 0 ? { segments, separator: v.separator } : null;
41}
42
43/**
44 * Parses the emitter's stdout. The emitter is the engine's own script, but its
45 * output crosses a process boundary, so the shape is checked before it is stored.
46 *
47 * @param stdout - What `emit-data.ts` printed
48 * @returns The engine facts, or `null` when the output is not them
49 */
50export function parseData(stdout: string): EngineFacts | null {
51 try {
52 const v: unknown = JSON.parse(stdout);
53 if (!isObject(v)) return null;
54 const { flags, time, path } = v;
55 const ok =
56 isObject(flags) &&
57 isObject(flags.time) &&
58 isObject(flags.directory) &&
59 isObject(flags.model) &&
60 isObject(flags.context) &&
61 isObject(flags.limits) &&
62 isObject(flags.cost) &&
63 isObject(flags.extra) &&
64 (v.extra === null || isObject(v.extra)) &&
65 isObject(time) &&
66 typeof time.date === "string" &&
67 typeof time.clock === "string" &&
68 isObject(path) &&
69 typeof path.styled === "string" &&
70 typeof path.base === "string" &&
71 (v.git === null || isObject(v.git)) &&
72 (v.dailyUsd === null || typeof v.dailyUsd === "number");
73 if (!ok) return null;
74 return { ...(v as EngineFacts), limits: cleanLimits(v.limits), terminal: cleanTerminal(v.terminal),
75 cacheClock: cleanClock(v.cacheClock),
76 };
77 } catch {
78 return null;
79 }
80}
81view.tsx 88 lines1import { barRuns } from "./bar-cells";
2import { svgSize, svgSource } from "./bar-svg";
3import type { BarItem, Item, Row, Span } from "./interfaces/card.interface";
4import type { StatuslineElements, SvgElement } from "./interfaces/statusline-elements.interface";
5import { CARD_GAP } from "./layout";
6import { THEME } from "./theme";
7
8/** Cells between two cards where the surface flows them (the desktop): wider than the terminal's. */
9const FLOW_GAP = 1;
10
11/** Inner padding and item gap of a card on the desktop, whose cells are wider than the text. */
12const FLOW_PAD = 1;
13
14/** One Text per span, in the color the card gave it. */
15const spanText = (ui: StatuslineElements, spans: Span[], key: string) => {
16 const { Text } = ui;
17 return (
18 <Text key={key}>
19 {spans.map((s, i) => (
20 <Text key={`${key}-${i}`} color={s.color} bold={s.bold} dimColor={s.dim}>
21 {s.text}
22 </Text>
23 ))}
24 </Text>
25 );
26};
27
28/** A bar: real rounded stripes where the surface has `Svg`, else cells of colored glyphs. */
29const barElement = (ui: StatuslineElements, item: BarItem, key: string, Svg?: SvgElement) => {
30 const { Text } = ui;
31 if (Svg) return <Svg key={key} source={svgSource(item)} alt={item.label} {...svgSize(item)} />;
32 return (
33 <Text key={key}>
34 {barRuns(item).map((r, i) => (
35 <Text key={`${key}-${i}`} color={r.color} backgroundColor={r.bg}>
36 {r.text}
37 </Text>
38 ))}
39 </Text>
40 );
41};
42
43const itemElement = (ui: StatuslineElements, item: Item, key: string, Svg?: SvgElement) =>
44 item.kind === "bar" ? barElement(ui, item, key, Svg) : spanText(ui, item.spans, key);
45
46/**
47 * Draws the laid-out cards: one row Box per row, one background-colored padded Box per
48 * card, never shrunk, so a card is cut by nothing but the layout that sized it. Where the
49 * surface flows the cards (desktop), each card gets a `round` border in its own background
50 * color, so it reads as a rounded chip; the terminal keeps borderless cells.
51 *
52 * @param ui - The surface's element table, from `$.ui.resolve(e)`
53 * @param rows - The rows from `layout`
54 * @param Svg - The surface's `Svg`, when it has one (desktop): bars become rounded stripes
55 * @returns The band's tree
56 */
57export function StatuslineView(ui: StatuslineElements, rows: Row[], Svg?: SvgElement) {
58 const { Box } = ui;
59 return (
60 <Box flexDirection="column">
61 {rows.map((row, r) => (
62 <Box
63 key={`r${r}`}
64 flexDirection="row"
65 flexWrap={Svg ? "wrap" : "nowrap"}
66 columnGap={Svg ? FLOW_GAP : CARD_GAP}
67 rowGap={Svg ? FLOW_GAP : 0}
68 >
69 {row.map((card) => (
70 <Box
71 key={`${r}-${card.id}`}
72 flexShrink={0}
73 alignItems="center"
74 backgroundColor={THEME.card}
75 borderStyle={Svg ? "round" : undefined}
76 borderColor={Svg ? THEME.card : undefined}
77 paddingX={Svg ? FLOW_PAD : card.pad}
78 columnGap={Svg ? FLOW_PAD : card.gap}
79 >
80 {card.items.map((item, i) => itemElement(ui, item, `${card.id}-${i}`, Svg))}
81 </Box>
82 ))}
83 </Box>
84 ))}
85 </Box>
86 );
87}
88view-terminal.tsx 36 lines1import type { TermLayout } from "./interfaces/terminal.interface";
2import type { StatuslineElements } from "./interfaces/statusline-elements.interface";
3import type { Span } from "./types";
4
5/** Left inset of the band, in cells: the same inset Claude Code gives its own status line. */
6export const TERM_INDENT = 2;
7
8/**
9 * Draws the terminal's statusline as the old one: plain colored text, one `Text` per row,
10 * segments joined by the engine's separator, inset by `TERM_INDENT`. No background, no border.
11 *
12 * @param ui - The surface's element table, from `$.ui.resolve(e)`
13 * @param layout - The rows from `terminalLayout`
14 * @returns The band's tree
15 */
16export function TerminalView(ui: StatuslineElements, layout: TermLayout) {
17 const { Box, Text } = ui;
18 const run = (s: Span, key: string) => (
19 <Text key={key} color={s.color} bold={s.bold} dimColor={s.dim}>
20 {s.text}
21 </Text>
22 );
23 return (
24 <Box flexDirection="column" paddingLeft={TERM_INDENT}>
25 {layout.rows.map((row, r) => (
26 <Text key={`r${r}`}>
27 {row.flatMap((seg, i) => [
28 ...(i > 0 ? layout.separator.map((s, k) => run(s, `r${r}-s${i}-${k}`)) : []),
29 ...seg.spans.map((s, k) => run(s, `r${r}-${seg.id}-${k}`)),
30 ])}
31 </Text>
32 ))}
33 </Box>
34 );
35}
36interfaces/cache.interface.ts 10 lines1/** The environment variables that choose Claude Code's main-conversation cache TTL. */
2export interface TtlEnv {
3 /** `FORCE_PROMPT_CACHING_5M` */
4 force5m?: string | null;
5 /** `CLAUDE_CODE_PROMPT_CACHE_TTL` (`5m` or `1h`) */
6 ttl?: string | null;
7 /** `ENABLE_PROMPT_CACHING_1H` */
8 enable1h?: string | null;
9}
10