SLOPSHOPPER

fuse-statusline

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

newbandprocesstimer
★ 31v1.0.15MITupdated 2026-10-12fusengine/agents/plugins/core-guards/statusline-mod
A shopper browsing a rack in a slop shop
README

Fusengine Claude Code Plugins

version plugins agents skills License: MIT platform Windows

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.

Statusline

Demo: the hook → agent → sniper cycle

Scripted demo — a faithful reconstruction of the real hook → agent → sniper cycle. Regenerate with vhs docs/demo/hook-sniper.tape.

What It Does

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:

  • Expert agents detect your project type (Next.js, Laravel, React, Astro, Swift...) and load framework-specific documentation via MCP servers before writing a single line
  • 60+ hook checks intercept Write/Edit/Bash and agent/session lifecycle events to enforce file-size limits (<100 lines), block code duplication (DRY), require SOLID reference reads, gate documentation lookup, and block destructive shell commands
  • 196 skills inject copy-paste-ready templates, architecture patterns, and best practices directly into agent context — no hallucination needed
  • APEX workflow structures every task through Analyze → Plan → Execute (TDD) → Review → Validate — preventing the "just write code and hope" approach
  • Sniper agent — a 7-phase post-edit quality check (explore → research → grep usages → lint → fix → zero errors) the APEX workflow invokes after every modification

Quick Install

# 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

How It Works

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.

Plugins

Development — Framework Expert Agents

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.

PluginDetectsWhat the agent does
fuse-nextjsnext.config.*App Router, RSC, Prisma 7, Better Auth, proxy.ts patterns
fuse-laravelcomposer.json + artisanEloquent, Livewire, Blade, queues, Sanctum, Stripe Connect
fuse-reactpackage.json + ReactReact 19 hooks, TanStack Router/Form, Zustand stores
fuse-astroastro.config.*Islands, Content Layer, Actions, SEO, Starlight, i18n
fuse-swift-apple-expertPackage.swiftSwiftUI, concurrency, all Apple platforms (iOS → visionOS)
fuse-tailwindcsstailwind.config.*v4.1 CSS-first config, @theme, @utility, OKLCH colors
fuse-designAny UI taskGemini Design MCP + shadcn/ui + WCAG 2.2 accessibility
fuse-shadcn-uicomponents.jsonRadix/Base UI detection, registry, theming, migration
fuse-tanstack-start@tanstack/react-startServer functions, selective SSR, middleware, server routes, Nitro deploy
fuse-typescripttsconfig.json (no framework)Pure TS 6.0, Node 24 LTS / Bun 1.3, strict typing, ESM packaging
fuse-phpcomposer.json (no artisan)PHP 8.5, PER-CS 3.0, PHPStan, PSR, Slim / Symfony components
fuse-rustCargo.tomlRust 1.96+, 2024 edition, ownership, tokio / axum, clippy-clean
fuse-gogo.modGo 1.26+, concurrency, backend services, golangci-lint v2

Quality & Security — Automated Enforcement

PluginWhat it enforces
fuse-ai-pilotAPEX workflow orchestration, 7-phase sniper validation, DRY detection via jscpd, 4-level cache (60-75% token savings)
fuse-securityOWASP Top 10 scanning, CVE research via NVD/OSV.dev, dependency audit, secrets detection, auth patterns audit
fuse-solidFiles < 100 lines, interface separation, JSDoc mandatory — auto-detected for TypeScript, PHP, Swift, Go, Java, Ruby, Rust

Productivity — Automation

PluginWhat it automates
fuse-commit-proConventional commits with security check, auto version bump, CHANGELOG, git tag — with a pre-commit secret-pattern scan
fuse-prompt-engineerPrompt creation with CoT/Few-Shot/Meta-Prompting, A/B testing, 50+ template library, agent design
fuse-cartographerIntelligent 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-changelogMonitors Claude Code updates, detects breaking changes in plugins, gathers community feedback via Exa
fuse-lessonsPer-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-motionProduct 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-statuslineResponsive 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

Core (auto-installed, always active)

PluginWhat it guards
core-guardsBlocks 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-rulesInjects APEX/SOLID/DRY rules into every prompt so Claude never forgets the methodology

Key Features

FeatureHow it prevents mistakes
APEX WorkflowForces Analyze before coding — no more "let me just write this real quick"
Agent TeamsParallel agents with exclusive file ownership — no merge conflicts
DRY DetectionBlocks Write/Edit if function/class name already exists — forces import or shared extraction
SOLID HooksDenies file save if >100 lines or missing SOLID reference read
Sniper Validation7-phase post-edit check: explore → research → grep → lint → fix → zero errors
4-Level CacheCaches exploration, docs, lessons, tests — 60-75% token savings
Token-efficient by defaultCaps 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 ServersContext7, Exa, Astro docs, Gemini Design, shadcn, fuse-browser, and more
Smart CommitsSecurity scan before commit, auto version bump, CHANGELOG, shields.io badge sync

Documentation

SectionContent
Getting StartedInstallation, configuration, first steps
WorkflowAPEX methodology, agents, skills, commands
PluginsPer-plugin documentation and skills
ReferenceArchitecture, hooks, MCP servers, cache system

License

MIT

Source 32 files
hooks/register.tsx 170 lines
1import 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};
170
cache-expiry.ts 123 lines
1import 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}
123
cards-extra.ts 51 lines
1import { 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}
51
engine.ts 57 lines
1import 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}
57
facts.ts 53 lines
1import 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}
53
interfaces/statusline-elements.interface.ts 8 lines
1import 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"];
8
layout.ts 70 lines
1import { 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}
70
layout-terminal.ts 64 lines
1import 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}
64
parse-data.ts 81 lines
1import { 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}
81
view.tsx 88 lines
1import { 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}
88
view-terminal.tsx 36 lines
1import 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}
36
interfaces/cache.interface.ts 10 lines
1/** 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