Native Claude Code status-line mod with configurable widgets for cache TTL, model, cost, context, API rate limits, git, and Headroom stats

A configurable native Claude Code Mod that owns the status line below the prompt and displays real-time session metrics — model, cost, context window, cache TTL, API usage, git state, Headroom stats, and more. The existing 62-widget renderer and interactive TUI remain intact; the Mod replaces the old settings-file statusLine bootstrap.
The default layout renders three lines below your Claude Code prompt:
| Line | Content |
|---|---|
| 1 — Session | Working directory · git branch · model · cost · context bar · cache TTL |
| 2 — Usage | 5-hour and 7-day rate-limit bars · overage spend (hidden when unavailable) |
| 3 — Headroom | Tokens saved · compression % · cost saved · cache hit rate (hidden unless proxy is active) |
The context bar changes color as you approach the limit:
The cache TTL indicator turns from green → yellow → red as expiry approaches:
Inside a Claude Code session:
/plugin marketplace add JerrettDavis/ClaudeStatusLineWidgets
/plugin install cache-ttl-statusline@claude-statusline-widgets
Or from the CLI:
claude plugin marketplace add JerrettDavis/ClaudeStatusLineWidgets
claude plugin install cache-ttl-statusline@claude-statusline-widgets
Restart Claude Code, or run /reload-plugins in an already-open session. The default hook mode registers the classic status line; see below for the native Mod mode.
git clone https://github.com/JerrettDavis/ClaudeStatusLineWidgets.git
cd ClaudeStatusLineWidgets
npm install
npm run build
claude --plugin-dir .
To inspect the Mod before loading it:
claude plugin validate .
The Mod is declared by hooks/hooks.json and hooks/statusline.tsx. It renders in-process from the native session APIs (no subprocess) and draws the result as a coloured band via ui.render; ui.status is plain-text only.
One plugin, one renderer, two install modes. Switch with ccfooter-config mode <hook|mod> (or node scripts/mode.js set <mode>), then restart Claude Code or run /reload-plugins:
| Mode | How it is drawn | Trade-offs |
|---|---|---|
hook (default) | Classic statusLine command written to settings.json at SessionStart | Full ANSI colour and every row, below the prompt |
mod | Native Claude Code Mod, no settings.json entry | Coloured rows drawn above the prompt (ui.status is plain text only); needs Claude Code 2.1.287+ |
Inside Claude Code, /statusline-mode shows the current mode and /statusline-mode hook|mod switches it. Switching to mod takes effect immediately; switching to hook needs a restart to load the classic line.
The mode is stored in ~/.config/claude-statusline-widgets/mode.json; CCFOOTER_MODE overrides it. Switching only ever adds or removes this plugin's own statusLine entry, never a custom one.
ccfooter-config CLI globallyRequires Node.js 22.12 or newer and npm. npm creates the platform-specific ccfooter-config launchers, including CMD and PowerShell shims on Windows.
# From a local clone
npm install -g .
# Pre-built runtime directly from GitHub (all platforms)
npm install -g https://github.com/JerrettDavis/ClaudeStatusLineWidgets/archive/refs/heads/main.tar.gz
Use the archive URL rather than npm install -g github:.... npm's Git dependency preparation can inherit the global install settings, link the package to a temporary checkout, and then delete that checkout. The archive installs the bundled runtime without Git preparation or build dependencies. To build a GitHub revision instead, run npm pack github:JerrettDavis/ClaudeStatusLineWidgets without -g, then run npm install -g ./<filename> using the tarball filename printed by npm pack.
Ensure npm's global executable directory is on your PATH (npm prefix -g on Windows, or $(npm prefix -g)/bin on macOS/Linux). To verify installation without opening the TUI, run ccfooter-config mode get.
Upgrading from an older install: before installing, run npm uninstall -g claude-statusline-widgets, then remove only the legacy ccfooter-config launchers left behind by this package. On Windows these are ccfooter-config.cmd and ccfooter-config.ps1 in the directory printed by npm prefix -g; on macOS/Linux it is ccfooter-config in that directory's bin subdirectory. The old installer created unmanaged files, so npm cannot overwrite them automatically (EEXIST). Do not remove an unrelated CLI.
On Windows, also remove the unused %LOCALAPPDATA%\claude-statusline-widgets\global-runtime directory after verifying the new CLI works. Do not disable TLS certificate verification; configure npm's trusted CA certificates if your network requires them.
Launch the TUI configurator with:
ccfooter-config
The TUI lets you:
| Context | Key | Action |
|---|---|---|
| Global | Ctrl+S | Save settings |
| Global | Ctrl+C | Quit |
| Line Editor | a | Add a widget |
| Line Editor | v | Cycle the selected widget's display variant |
| Line Editor | d / Delete | Remove selected widget |
| Line Editor | m | Toggle move mode (reorder with arrow keys) |
| Line Editor | x | Delete entire line |
| Line Editor | Esc | Go back |
| Line Selector | a | Add a new line |
| Widget Picker | Arrow keys | Navigate widgets |
| Widget Picker | Enter | Select widget to add |
| Widget Picker | Esc | Cancel |
Settings are saved to ~/.config/claude-statusline-widgets/settings.json. You can also edit this file directly. Example:
{
"version": 2,
"minimalistMode": false,
"lines": [
[
{ "id": "1", "type": "model" },
{ "id": "2", "type": "separator" },
{ "id": "3", "type": "cost" },
{ "id": "4", "type": "separator" },
{ "id": "5", "type": "context-bar" },
{ "id": "6", "type": "separator" },
{ "id": "7", "type": "cache-ttl" }
],
[
{ "id": "8", "type": "usage-5h" },
{ "id": "9", "type": "separator" },
{ "id": "10", "type": "usage-7d" }
]
]
}
Delete the settings file to reset to defaults.
For the full configuration reference (all options, environment variables, color names) see docs/configuration.md.
Claude StatusLine Widgets now ships with 62 built-in widgets across seven categories, plus variants for context, cache, usage, and skills widgets.
| Category | Included widgets |
|---|---|
| Session | path, branch, model, cost, session-id, version, output-style, session-clock, session-elapsed, account-email, thinking-effort, vim-mode, skills |
| Context | context-bar, context-percent, context-length, cache-ttl, cache-tokens |
| Usage | usage-5h, usage-7d, usage-overage, usage-reset-5h, usage-reset-7d |
| Tokens | tokens-input, tokens-output, tokens-total, input-speed, output-speed, total-speed |
| Git | git-status, git-changes, git-staged, git-unstaged, git-untracked, git-ahead-behind, git-conflicts, git-sha, git-root, git-insertions, git-deletions, git-origin-owner, git-origin-repo, git-origin-owner-repo, git-upstream-owner, git-upstream-repo, git-upstream-owner-repo, git-is-fork, git-worktree-mode, git-worktree-name, git-worktree-branch, git-worktree-original-branch |
| Headroom | headroom-tokens, headroom-compression, headroom-cost, headroom-cache-hit |
| Environment | terminal-width, memory-usage |
| Layout | separator, custom-text, custom-symbol, link, custom-command |
Shared widget variants include:
context-bar: bar, percent, remainingcontext-percent: percent, bar, remainingcache-ttl: time, countdown, badgeusage-5h / usage-7d: bar, percent, countdownusage-overage: bar, percentskills: count, listFor per-widget documentation, examples, and configuration options see docs/widgets.md.
The usage line shows your real-time Anthropic rate-limit utilisation. Data is fetched in a background process every 60 seconds using your OAuth credentials from ~/.claude/.credentials.json — no extra configuration needed if you are logged in to Claude Code.
Set ANTHROPIC_BASE_URL to your Headroom proxy base URL (for example http://127.0.0.1:8787) to activate the Headroom widgets. The statusline first probes <base>/health; when healthy, it queries <base>/stats and displays token savings, compression ratio, cost savings, and cache hit rate.
The plugin loads a Claude Code Mod from hooks/statusline.tsx (in mod mode; in hook mode the classic statusLine command runs dist/index.js instead):
session.start, session.measure, and turn.complete.$.fs/$.process/$.http, then builds the configured widget tree with native Box/Text elements.AbovePrompt band; it stays inert unless the install mode is mod. In hook mode no Mod rendering happens and settings.json holds the classic statusLine command.Running dist/index.js interactively still launches the React/Ink TUI configurator, and its piped-input mode remains available as a legacy/standalone renderer.
.claude-plugin/
marketplace.json — Marketplace catalog
plugin.json — Plugin manifest
hooks/
hooks.json — Mod module declaration
statusline.tsx — Native Claude Code Mod (in-process renderer, mode gate, /statusline-mode)
src/
index.ts — Entry point: TTY detection (TUI vs render mode)
renderer.ts — Settings-driven multi-line renderer
cache.ts — JSONL transcript parsing, TTL computation
segments.ts — Low-level formatters for each segment type
colors.ts — ANSI color/style helpers
usage.ts — Background API usage fetcher with file-based caching
headroom.ts — Headroom compression proxy stats integration
widgets/
types.ts — Widget interface, WidgetItem config, RenderContext
registry.ts — Widget manifest and factory registry
*.ts — One file per widget implementation
config/
schema.ts — Settings type, defaults, validation
loader.ts — Load/save settings, config path, migrations
tui/
index.tsx — TUI entry point (runTUI)
app.tsx — Main app with screen router and preview
components/ — MainMenu, LineSelector, ItemsEditor,
WidgetPicker, ColorMenu
npm install
npm run build # Compile TypeScript
npm run dev # Watch mode
# Test render mode with mock data
echo '{"model":{"display_name":"Opus"},"cost":{"total_cost_usd":0.12},"context_window":{"used_percentage":45},"git_branch":"main","cwd":"/home/user/project"}' | node dist/index.js
# Launch TUI configurator
ccfooter-config
# Or without global install:
node dist/index.js
# Regenerate docs screenshots
npm run build && node scripts/capture-screenshots.js
Screenshots in docs/images/ are auto-regenerated by the screenshots workflow whenever src/ changes on main.
MIT
hooks/statusline.tsx 790 lines1/**
2 * Claude Code native mod: cache TTL statusline bridge.
3 *
4 * Renders the configured statusline as a React band AbovePrompt using native
5 * `Box` / `Text` / `Link` elements from `$.ui.resolve(e)`. Data sources
6 * are engine nouns (`$.session.*`, `$.fs.read`, `$.http.fetch`,
7 * `$.process.run`); no Node APIs are touched.
8 *
9 * Engine constraints honored:
10 * 1. No dynamic `import()` (validator rejects).
11 * 2. No Node-only imports transitively (`fs`, `child_process`, etc.).
12 * Every helper that uses `$.fs` etc. lives in this file.
13 * 3. `$` (EngineInterface) does not cross imports — every helper that
14 * accepts `$` must live in this file.
15 *
16 * Replaces the legacy bridge that spawned the CLI as a subprocess. The
17 * CLI itself is kept for the TUI configurator and background-fetch modes.
18 */
19import type {
20 Register,
21 EngineInterface,
22 RenderInput,
23 RenderElement,
24} from "claude-code";
25import { createDefaultSettings, validateSettings } from "../src/config/schema.js";
26import type { Settings, StatusLinePayload } from "../src/widgets/types.js";
27import type {
28 CacheTTLResult,
29 CacheSessionStats,
30} from "../src/cache-core.js";
31import type { HeadroomStats } from "../src/headroom-core.js";
32import type { UsageData } from "../src/usage-core.js";
33import type {
34 GitInfo,
35 RemoteInfo,
36 RuntimeData,
37 SessionInfo,
38 TokenInfo,
39 UsageWindowInfo,
40} from "../src/runtime-core.js";
41import { renderStatusLineElement } from "../src/widgets/renderer-mod.js";
42
43// ────────────────────────────────────────────────────────────────────────────
44// Local helpers — every function that takes `$` lives here.
45// ────────────────────────────────────────────────────────────────────────────
46
47type $ = EngineInterface;
48
49const TRANSCRIPT_REF = { plugin: "cache-ttl-statusline", key: "transcript_path" } as const;
50
51function pathBasename(p: string): string {
52 const norm = p.replace(/\\/g, "/");
53 const idx = norm.lastIndexOf("/");
54 return idx === -1 ? norm : norm.slice(idx + 1);
55}
56
57async function readText($: $, path: string): Promise<string | null> {
58 try {
59 const raw = await $.fs.read(path, { as: "text" });
60 return typeof raw === "string" ? raw : new TextDecoder().decode(raw);
61 } catch {
62 return null;
63 }
64}
65
66async function writeTextSafe($: $, path: string, text: string): Promise<boolean> {
67 try {
68 await $.fs.write(path, text);
69 return true;
70 } catch {
71 return false;
72 }
73}
74
75async function getHome($: $): Promise<string | null> {
76 return (
77 (await $.env.get("CLAUDE_CONFIG_DIR")) ??
78 (await $.env.get("HOME")) ??
79 (await $.env.get("USERPROFILE")) ??
80 null
81 );
82}
83
84async function getTranscriptPath($: $): Promise<string | undefined> {
85 try {
86 const r = await $.state.get<string>(TRANSCRIPT_REF);
87 return r.value;
88 } catch {
89 return undefined;
90 }
91}
92
93async function loadSettingsMod($: $): Promise<Settings> {
94 const home = await getHome($);
95 if (!home) return createDefaultSettings();
96 const path = `${home}/.config/claude-statusline-widgets/settings.json`;
97 const text = await readText($, path);
98 if (text === null) return createDefaultSettings();
99 try {
100 return validateSettings(JSON.parse(text));
101 } catch {
102 return createDefaultSettings();
103 }
104}
105
106// --- Cache TTL: parse the JSONL directly using --fs. ---
107async function getCacheTTLMod(
108 $: $,
109 transcriptPath: string | undefined,
110 currentCacheRead: number,
111): Promise<CacheTTLResult> {
112 const noData: CacheTTLResult = {
113 remainingSeconds: -1,
114 tier: "none",
115 lastWriteTime: null,
116 expiresAt: null,
117 cacheReadActive: currentCacheRead > 0,
118 };
119 if (!transcriptPath) return noData;
120
121 // Stat first to fail fast on huge transcripts ($.fs.read has a 4 MiB cap).
122 try {
123 const stat = await $.fs.stat(transcriptPath);
124 if (stat.size > 4 * 1024 * 1024) return noData;
125 } catch {
126 return noData;
127 }
128
129 const text = await readText($, transcriptPath);
130 if (text === null) return noData;
131 const lines = text.split("\n").filter((l) => l.trim().length > 0);
132 // Walk backwards.
133 for (let i = lines.length - 1; i >= 0; i--) {
134 let entry: {
135 timestamp?: string;
136 message?: {
137 usage?: {
138 cache_creation_input_tokens?: number;
139 cache_creation?: {
140 ephemeral_5m_input_tokens?: number;
141 ephemeral_1h_input_tokens?: number;
142 };
143 };
144 };
145 };
146 try {
147 entry = JSON.parse(lines[i]);
148 } catch {
149 continue;
150 }
151 const usage = entry.message?.usage;
152 if (!usage?.cache_creation_input_tokens || usage.cache_creation_input_tokens <= 0) continue;
153 const timestamp = entry.timestamp;
154 if (!timestamp) continue;
155 const creation = usage.cache_creation;
156 const is1h = (creation?.ephemeral_1h_input_tokens ?? 0) > 0;
157 const ttlSeconds = is1h ? 3600 : 300;
158 const tier: "1h" | "5m" = is1h ? "1h" : "5m";
159 const writeTime = new Date(timestamp).getTime();
160 const expiresAt = writeTime + ttlSeconds * 1000;
161 const now = await $.clock.now();
162 const remaining = Math.max(0, (expiresAt - now) / 1000);
163 return {
164 remainingSeconds: Math.round(remaining),
165 tier,
166 lastWriteTime: timestamp,
167 expiresAt,
168 cacheReadActive: currentCacheRead > 0,
169 };
170 }
171 return noData;
172}
173
174async function getCacheSessionStatsMod(
175 $: $,
176 transcriptPath: string | undefined,
177): Promise<CacheSessionStats> {
178 const empty: CacheSessionStats = {
179 totalReads: 0,
180 totalWrites: 0,
181 breakCount: 0,
182 lastBreakTime: null,
183 lastBreakTokens: 0,
184 avgBreakTokens: 0,
185 };
186 if (!transcriptPath) return empty;
187 try {
188 const stat = await $.fs.stat(transcriptPath);
189 if (stat.size > 4 * 1024 * 1024) return empty;
190 } catch {
191 return empty;
192 }
193 const text = await readText($, transcriptPath);
194 if (text === null) return empty;
195 const lines = text.split("\n").filter((l) => l.trim().length > 0);
196
197 let totalReads = 0;
198 let totalWrites = 0;
199 const breakList: { tokens: number; time: string }[] = [];
200 let lastExpiresAt: number | null = null;
201
202 for (const line of lines) {
203 let entry: {
204 timestamp?: string;
205 message?: {
206 usage?: {
207 cache_creation_input_tokens?: number;
208 cache_read_input_tokens?: number;
209 cache_creation?: {
210 ephemeral_5m_input_tokens?: number;
211 ephemeral_1h_input_tokens?: number;
212 };
213 };
214 };
215 };
216 try {
217 entry = JSON.parse(line);
218 } catch {
219 continue;
220 }
221 const usage = entry.message?.usage;
222 if (!usage) continue;
223 if (usage.cache_read_input_tokens) totalReads += usage.cache_read_input_tokens;
224 const written = usage.cache_creation_input_tokens ?? 0;
225 if (written > 0 && entry.timestamp) {
226 totalWrites += written;
227 const writeMs = new Date(entry.timestamp).getTime();
228 const isBreak = lastExpiresAt === null || writeMs > lastExpiresAt;
229 if (isBreak) breakList.push({ tokens: written, time: entry.timestamp });
230 const creation = usage.cache_creation;
231 const ttlSeconds =
232 (creation?.ephemeral_1h_input_tokens ?? 0) > 0 ? 3600 : 300;
233 lastExpiresAt = writeMs + ttlSeconds * 1000;
234 }
235 }
236
237 if (breakList.length === 0) return { ...empty, totalReads, totalWrites };
238 const lastBreak = breakList[breakList.length - 1];
239 const avgBreakTokens = Math.round(
240 breakList.reduce((s, b) => s + b.tokens, 0) / breakList.length,
241 );
242 return {
243 totalReads,
244 totalWrites,
245 breakCount: breakList.length,
246 lastBreakTime: lastBreak.time,
247 lastBreakTokens: lastBreak.tokens,
248 avgBreakTokens,
249 };
250}
251
252// --- Usage cache + fetch. ---
253async function readUsageCacheMod($: $): Promise<{ fetchedAt: number; data: UsageData; rateLimitedUntil?: number } | null> {
254 const home = await getHome($);
255 if (!home) return null;
256 const text = await readText($, `${home}/.claude/.cache/usage.json`);
257 if (text === null) return null;
258 try { return JSON.parse(text); } catch { return null; }
259}
260
261async function fetchUsageMod($: $): Promise<void> {
262 const home = await getHome($);
263 if (!home) return;
264 let token: string | null = (await $.env.get("CLAUDE_CODE_OAUTH_TOKEN")) ?? null;
265 if (!token) {
266 const text = await readText($, `${home}/.claude/.credentials.json`);
267 if (text !== null) {
268 try {
269 const j = JSON.parse(text) as { claudeAiOauth?: { accessToken?: string } };
270 token = j?.claudeAiOauth?.accessToken ?? null;
271 } catch { /* ignore */ }
272 }
273 }
274 if (!token) return;
275 try {
276 const res = await $.http.fetch("https://api.anthropic.com/api/oauth/usage", {
277 method: "GET",
278 headers: {
279 Authorization: `Bearer ${token}`,
280 "anthropic-beta": "oauth-2025-04-20",
281 "Content-Type": "application/json",
282 },
283 timeoutMs: 5000,
284 });
285 if (!res.ok) return;
286 const data = await res.json();
287 await writeTextSafe(
288 $,
289 `${home}/.claude/.cache/usage.json`,
290 JSON.stringify({ fetchedAt: Date.now(), data }),
291 );
292 } catch { /* ignore */ }
293}
294
295async function getUsageDataMod($: $): Promise<UsageData | null> {
296 const cache = await readUsageCacheMod($);
297 if (!cache) return null;
298 const stale =
299 Date.now() - cache.fetchedAt > 60_000 &&
300 (!cache.rateLimitedUntil || Date.now() >= cache.rateLimitedUntil);
301 if (stale) {
302 try { await fetchUsageMod($); } catch { /* ignore */ }
303 const after = await readUsageCacheMod($);
304 return after?.data ?? cache.data ?? null;
305 }
306 return cache.data ?? null;
307}
308
309// --- Headroom cache + fetch. ---
310async function readHeadroomCacheMod($: $): Promise<{ fetchedAt: number; isActive: boolean; data: HeadroomStats | null } | null> {
311 const home = await getHome($);
312 if (!home) return null;
313 const text = await readText($, `${home}/.claude/.cache/headroom.json`);
314 if (text === null) return null;
315 try { return JSON.parse(text); } catch { return null; }
316}
317
318async function fetchHeadroomMod($: $): Promise<void> {
319 const home = await getHome($);
320 if (!home) return;
321 const baseUrl = (await $.env.get("ANTHROPIC_BASE_URL")) ?? "http://127.0.0.1:8787";
322 try {
323 const healthRes = await $.http.fetch(`${baseUrl}/health`, { timeoutMs: 2000 });
324 if (!healthRes.ok) return;
325 const health = await healthRes.json() as { status?: string };
326 if (health.status !== "healthy") return;
327 const statsRes = await $.http.fetch(`${baseUrl}/stats`, { timeoutMs: 2000 });
328 if (!statsRes.ok) return;
329 const raw = await statsRes.json() as Record<string, unknown>;
330 const tokens = raw.tokens as Record<string, unknown> | undefined;
331 const cost = raw.cost as Record<string, unknown> | undefined;
332 const requests = raw.requests as Record<string, unknown> | undefined;
333 const prefixCache = raw.prefix_cache as Record<string, Record<string, unknown>> | undefined;
334 const stats: HeadroomStats = {
335 compressionPct: safeNum(tokens?.savings_percent),
336 tokensSaved: safeNum(tokens?.saved) + safeNum(tokens?.cli_tokens_avoided),
337 cliTokensSaved: safeNum(tokens?.cli_tokens_avoided),
338 costSavedUsd: safeNum(cost?.savings_usd),
339 requests: safeNum(requests?.total),
340 cacheHitRate: safeNum(prefixCache?.totals?.hit_rate) / 100,
341 };
342 await writeTextSafe(
343 $,
344 `${home}/.claude/.cache/headroom.json`,
345 JSON.stringify({ fetchedAt: Date.now(), isActive: true, data: stats }),
346 );
347 } catch { /* ignore */ }
348}
349
350function safeNum(v: unknown, fallback = 0): number {
351 const n = Number(v);
352 return Number.isFinite(n) ? n : fallback;
353}
354
355async function getHeadroomStatsMod($: $): Promise<HeadroomStats | null> {
356 const cache = await readHeadroomCacheMod($);
357 if (!cache) {
358 try { await fetchHeadroomMod($); } catch { /* ignore */ }
359 return (await readHeadroomCacheMod($))?.data ?? null;
360 }
361 if (Date.now() - cache.fetchedAt > 30_000) {
362 try { await fetchHeadroomMod($); } catch { /* ignore */ }
363 return (await readHeadroomCacheMod($))?.data ?? cache.data ?? null;
364 }
365 return cache.data ?? null;
366}
367
368// --- Git info via $.process.run. ---
369async function runGit($: $, args: string[], cwd: string): Promise<string | null> {
370 try {
371 const res = await $.process.run(["git", ...args], { cwd, timeoutMs: 4000 });
372 if (res.exitCode !== 0) return null;
373 return res.stdout.trim();
374 } catch {
375 return null;
376 }
377}
378
379function parseRemote(rawUrl: string | null): RemoteInfo | null {
380 if (!rawUrl) return null;
381 const m = rawUrl.match(/[:/]([^/:]+)\/([^/]+?)(?:\.git)?$/);
382 if (!m) return { rawUrl, owner: null, repo: null };
383 return { rawUrl, owner: m[1] || null, repo: m[2] || null };
384}
385
386async function parseGitInfoMod($: $, cwd: string | null): Promise<GitInfo> {
387 const empty: GitInfo = {
388 available: false,
389 cwd,
390 branch: null,
391 rootPath: null,
392 rootName: null,
393 sha: null,
394 staged: 0,
395 unstaged: 0,
396 untracked: 0,
397 conflicts: 0,
398 changes: 0,
399 insertions: 0,
400 deletions: 0,
401 ahead: 0,
402 behind: 0,
403 origin: null,
404 upstream: null,
405 isFork: false,
406 worktreeMode: null,
407 worktreeName: null,
408 worktreeBranch: null,
409 worktreeOriginalBranch: null,
410 };
411 if (!cwd) return empty;
412
413 const status = await runGit($, ["status", "--porcelain=v2", "--branch"], cwd);
414 if (!status) return empty;
415
416 const rootPath = await runGit($, ["rev-parse", "--show-toplevel"], cwd);
417 const origin = parseRemote(await runGit($, ["config", "--get", "remote.origin.url"], cwd));
418 const upstream = parseRemote(await runGit($, ["config", "--get", "remote.upstream.url"], cwd));
419
420 let branch: string | null = null;
421 let ahead = 0;
422 let behind = 0;
423 let staged = 0;
424 let unstaged = 0;
425 let untracked = 0;
426 let conflicts = 0;
427 let changedPaths = 0;
428
429 for (const line of status.split(/\r?\n/)) {
430 if (line.startsWith("# branch.head ")) {
431 branch = line.slice("# branch.head ".length).trim();
432 if (branch === "(detached)") branch = null;
433 continue;
434 }
435 if (line.startsWith("# branch.ab ")) {
436 const m = line.match(/\+(\d+)\s+-(\d+)/);
437 ahead = Number(m?.[1] ?? 0);
438 behind = Number(m?.[2] ?? 0);
439 continue;
440 }
441 if (line.startsWith("? ")) { untracked += 1; continue; }
442 if (line.startsWith("u ")) { conflicts += 1; continue; }
443 if (line.startsWith("1 ") || line.startsWith("2 ")) {
444 changedPaths += 1;
445 const xy = line.split(" ")[1] ?? "..";
446 const i = xy[0] ?? ".";
447 const w = xy[1] ?? ".";
448 if (i !== ".") staged += 1;
449 if (w !== ".") unstaged += 1;
450 }
451 }
452
453 const diffShortstat = await runGit($, ["diff", "--shortstat", "HEAD"], cwd);
454 const cachedShortstat = await runGit($, ["diff", "--cached", "--shortstat"], cwd);
455 const insertions = Number(
456 (diffShortstat ?? cachedShortstat ?? "")?.match(/(\d+)\s+insertions?\(\+\)/)?.[1] ?? 0,
457 );
458 const deletions = Number(
459 (diffShortstat ?? cachedShortstat ?? "")?.match(/(\d+)\s+deletions?\(-\)/)?.[1] ?? 0,
460 );
461
462 return {
463 available: true,
464 cwd,
465 branch,
466 rootPath,
467 rootName: rootPath ? pathBasename(rootPath) : null,
468 sha: await runGit($, ["rev-parse", "--short", "HEAD"], cwd),
469 staged,
470 unstaged,
471 untracked,
472 conflicts,
473 changes: changedPaths + untracked + conflicts,
474 insertions,
475 deletions,
476 ahead,
477 behind,
478 origin,
479 upstream,
480 isFork: Boolean(
481 origin?.owner && origin.repo && upstream?.owner && upstream.repo &&
482 (origin.owner !== upstream.owner || origin.repo !== upstream.repo),
483 ),
484 worktreeMode: rootPath ? "primary" : null,
485 worktreeName: rootPath ? pathBasename(rootPath) : null,
486 worktreeBranch: branch,
487 worktreeOriginalBranch: branch,
488 };
489}
490
491// --- Account email via $.fs.read. ---
492async function readAccountEmailMod($: $): Promise<string | null> {
493 const home = await getHome($);
494 if (!home) return null;
495 const candidates = [`${home}/.claude.json`, `${home}/.claude/.credentials.json`];
496 for (const path of candidates) {
497 const text = await readText($, path);
498 if (text === null) continue;
499 try {
500 const json: unknown = JSON.parse(text);
501 const e = findEmail(json);
502 if (e) return e;
503 } catch { continue; }
504 }
505 return null;
506}
507
508function findEmail(value: unknown): string | null {
509 if (typeof value === "string") {
510 return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? value : null;
511 }
512 if (Array.isArray(value)) {
513 for (const item of value) {
514 const m = findEmail(item);
515 if (m) return m;
516 }
517 return null;
518 }
519 if (value && typeof value === "object") {
520 for (const entry of Object.values(value)) {
521 const m = findEmail(entry);
522 if (m) return m;
523 }
524 }
525 return null;
526}
527
528// --- Compose everything. ---
529async function buildRuntimeDataMod($: $): Promise<RuntimeData> {
530 const cwd = await $.session.cwd().catch(() => "");
531 const version = (await $.session.version().catch(() => ({ version: null }))).version ?? null;
532
533 const session: SessionInfo = {
534 sessionId: (await $.session.id().catch(() => null)) ?? null,
535 version,
536 outputStyle: null,
537 vimMode: null,
538 thinkingEffort: null,
539 skills: [],
540 accountEmail: await readAccountEmailMod($),
541 startedAt: null,
542 elapsedSeconds: null,
543 };
544
545 const tokens: TokenInfo = {
546 input: null,
547 output: null,
548 cached: null,
549 total: null,
550 inputSpeed: null,
551 outputSpeed: null,
552 totalSpeed: null,
553 };
554
555 const usage: UsageWindowInfo = {
556 fiveHourResetSeconds: null,
557 sevenDayResetSeconds: null,
558 };
559
560 return {
561 git: await parseGitInfoMod($, cwd || null),
562 session,
563 system: { terminalWidth: null, memoryUsedBytes: 0, memoryTotalBytes: 0 },
564 tokens,
565 usage,
566 };
567}
568
569async function buildPayloadMod(
570 $: $,
571 transcriptPath: string | undefined,
572 usage: { context: { tokens: number; window: number; percent: number | null }; cost: unknown } | null,
573): Promise<StatusLinePayload> {
574 // Mirror the legacy register.ts buildPayload: pull every field the widget
575 // registry reads (cwd, model, cost, context_window, transcript_path,
576 // git_branch) from $.session.* so mod and CLI see identical data.
577 const [cwd, sessionId, model, versionInfo] = await Promise.all([
578 $.session.cwd().catch(() => ""),
579 $.session.id().catch(() => null),
580 $.session.model().catch(() => ""),
581 $.session.version().catch(() => ({ version: null })),
582 ]);
583
584 const context = usage?.context;
585 const costRaw = usage?.cost;
586 let cost: { total_cost_usd?: number } | undefined;
587 if (typeof costRaw === "number") {
588 cost = { total_cost_usd: costRaw };
589 } else if (costRaw && typeof costRaw === "object") {
590 const r = costRaw as Record<string, unknown>;
591 const total =
592 typeof r.total_cost_usd === "number"
593 ? r.total_cost_usd
594 : typeof r.totalCostUsd === "number"
595 ? r.totalCostUsd
596 : typeof r.usd === "number"
597 ? r.usd
598 : undefined;
599 if (typeof total === "number") cost = { total_cost_usd: total };
600 }
601
602 const payload: StatusLinePayload = {
603 cwd: typeof cwd === "string" && cwd.length > 0 ? cwd : undefined,
604 session_id: typeof sessionId === "string" ? sessionId : undefined,
605 version: versionInfo.version ?? undefined,
606 model: typeof model === "string" && model.length > 0
607 ? { id: model, display_name: model }
608 : undefined,
609 context_window: {
610 used_percentage: context?.percent ?? null,
611 context_window_size: context?.window ?? undefined,
612 },
613 transcript_path: transcriptPath,
614 };
615 if (cost) payload.cost = cost;
616 return payload;
617}
618
619async function buildContextMod(
620 $: $,
621 transcriptPath: string | undefined,
622 currentCacheRead: number,
623): Promise<{
624 payload: StatusLinePayload;
625 cacheTTL: CacheTTLResult;
626 cacheStats: CacheSessionStats;
627 usageData: UsageData | null;
628 headroomStats: HeadroomStats | null;
629 runtime: RuntimeData;
630}> {
631 // Fetch usage once; pass it into both the payload builder (which needs
632 // context + cost) and the cache TTL helper (which needs current cache_read).
633 const usage = await $.session.usage().catch(() => null);
634
635 const [cacheTTL, cacheStats, usageData, headroomStats, runtime, payload] = await Promise.all([
636 getCacheTTLMod($, transcriptPath, currentCacheRead),
637 getCacheSessionStatsMod($, transcriptPath),
638 getUsageDataMod($),
639 getHeadroomStatsMod($),
640 buildRuntimeDataMod($),
641 buildPayloadMod($, transcriptPath, usage),
642 ]);
643 return { payload, cacheTTL, cacheStats, usageData, headroomStats, runtime };
644}
645
646// ────────────────────────────────────────────────────────────────────────────
647// Hooks
648// ────────────────────────────────────────────────────────────────────────────
649
650// Install mode (scripts/mode.js): "hook" means the classic statusLine command
651// draws the line and this Mod stays inert; "mod" means the Mod draws it.
652let active = false;
653
654function runMode($: $, args: string[]) {
655 return $.process.run(["node", `${$.plugin.root}/scripts/mode.js`, ...args], { timeoutMs: 5_000 });
656}
657
658function applyMode($: $, mode: string): void {
659 active = mode === "mod";
660 $.ui.invalidate("ui.render");
661}
662
663export const register: Register = (on) => {
664 on("session.start", async ($, e, next) => {
665 const r = await next(e);
666 // A failed registration must not stop the mode check below.
667 await $.command
668 .register({
669 name: "statusline-mode",
670 description: "Show or switch the status line install mode (hook | mod)",
671 argumentHint: "[hook|mod]",
672 })
673 .catch(() => undefined);
674 const mode = await runMode($, ["get"])
675 .then((res) => (res.exitCode === 0 ? res.stdout.trim() : "hook"))
676 .catch(() => "hook");
677 applyMode($, mode);
678 // Cache TTL countdown needs periodic redraw; hot-reload cancels the
679 // timer; re-arm on every session.start. Inactive renders are a no-op.
680 $.clock.every(15_000, () => $.ui.invalidate("ui.render"));
681 return r;
682 });
683
684 on("command.run", { command: "statusline-mode" }, async ($, e) => {
685 const arg = e.args.trim().toLowerCase();
686 if (arg === "") {
687 const res = await runMode($, ["get"]);
688 return { text: `Status line mode: ${res.stdout.trim() || "hook"}. Use /statusline-mode hook|mod to switch.` };
689 }
690 if (arg !== "hook" && arg !== "mod") {
691 return { text: `Unknown mode "${arg}". Use /statusline-mode hook or /statusline-mode mod.` };
692 }
693 const res = await runMode($, ["set", arg]);
694 if (res.exitCode !== 0) {
695 return { text: `Could not switch mode: ${res.stderr.trim() || `exit ${res.exitCode}`}` };
696 }
697 applyMode($, arg);
698 return {
699 text:
700 arg === "mod"
701 ? "Status line mode: mod. The coloured band is active now; the classic statusLine entry is removed."
702 : "Status line mode: hook. The Mod band is off; restart Claude Code to load the classic statusLine below the prompt.",
703 };
704 });
705
706 // classic.SessionStart carries transcript_path (the native session API
707 // intentionally does not expose it). Capture into $.state so cache reads
708 // survive hot reloads (classic.SessionStart fires once per session).
709 on("classic.SessionStart", async ($, e, next) => {
710 const result = await next(e);
711 if (typeof e.transcript_path === "string") {
712 try { await $.state.set(TRANSCRIPT_REF, e.transcript_path); } catch { /* ignore */ }
713 }
714 $.clock.after(0, () => $.ui.invalidate("ui.render"));
715 return result;
716 }).catch((_$, _e, next) => {
717 // Classic hooks have no chain beneath; a refusal of our own SessionStart
718 // shouldn't block the engine's. Log and swallow.
719 const msg = next && next.error && next.error.message ? next.error.message : "unknown";
720 console.warn(`claude-statusline-widgets: classic.SessionStart failed: ${msg}`);
721 });
722
723 on("turn.complete", async ($, e, next) => {
724 const r = await next(e);
725 if (e.agentId === undefined) {
726 $.clock.after(0, () => $.ui.invalidate("ui.render"));
727 }
728 return r;
729 });
730
731 on("session.measure", async ($, e, next) => {
732 const r = await next(e);
733 $.clock.after(0, () => $.ui.invalidate("ui.render"));
734 return r;
735 });
736
737 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
738 // Compose with other mods' AbovePrompt content first.
739 const below = await next(e).catch(() => null);
740
741 // Yield to surveys; the engine handles them with its own drawing.
742 if (!active || e.props.hasSurvey) return below;
743
744 const tree = await renderAbovePrompt($, e);
745 if (tree === undefined) return below;
746
747 const { Box } = $.ui.resolve(e);
748 return (
749 <Box key="stack" flexDirection="column">
750 {below}
751 {tree}
752 </Box>
753 );
754 });
755};
756
757async function renderAbovePrompt(
758 $: $,
759 _e: RenderInput<"AbovePrompt">,
760): Promise<RenderElement | undefined> {
761 try {
762 const transcriptPath = await getTranscriptPath($);
763 // currentCacheRead is read from a small follow-up usage() so we don't pay
764 // for the whole buildPayloadMod path just for one number — but in practice
765 // buildContextMod also calls $.session.usage(). A future cleanup could
766 // thread the count through to avoid the duplicate. Cost is negligible
767 // because the engine caches the snapshot.
768 const session = await $.session.usage().catch(() => null);
769 const currentCacheRead = session?.context.tokens ?? 0;
770 const [settings, ctx] = await Promise.all([
771 loadSettingsMod($),
772 buildContextMod($, transcriptPath, currentCacheRead),
773 ]);
774 // Wire git_branch from runtime into payload so BranchWidget has a value
775 // even when the user hasn't set up git on the project.
776 if (ctx.runtime.git.branch && !ctx.payload.git_branch) {
777 ctx.payload.git_branch = ctx.runtime.git.branch;
778 }
779 const elements = $.ui.resolve(_e);
780 return renderStatusLineElement(settings, ctx, elements);
781 } catch (err) {
782 $.ui.log(
783 `claude-statusline-widgets: render failed: ${
784 err instanceof Error ? err.message : String(err)
785 }`,
786 { to: "debug" },
787 );
788 return undefined;
789 }
790}src/config/schema.ts 107 lines1export const CURRENT_VERSION = 2;
2
3export interface WidgetItemConfig {
4 id: string;
5 type: string;
6 color?: string;
7 bold?: boolean;
8 variant?: string;
9 rawValue?: boolean;
10 customText?: string;
11 options?: Record<string, string | number | boolean | null>;
12}
13
14export interface Settings {
15 version: number;
16 lines: WidgetItemConfig[][];
17 defaultSeparator?: string;
18 minimalistMode?: boolean;
19}
20
21let _nextId = 1;
22function wid(type: string, extra?: Partial<WidgetItemConfig>): WidgetItemConfig {
23 return { id: String(_nextId++), type, ...extra };
24}
25
26function sep(): WidgetItemConfig {
27 return wid("separator");
28}
29
30export function createDefaultSettings(): Settings {
31 _nextId = 1;
32 return {
33 version: CURRENT_VERSION,
34 lines: [
35 // Line 1: session info
36 [wid("path"), sep(), wid("branch"), sep(), wid("model"), sep(), wid("cost"), sep(), wid("context-bar"), sep(), wid("cache-ttl")],
37 // Line 2: usage
38 [wid("usage-5h"), sep(), wid("usage-7d"), sep(), wid("usage-overage")],
39 // Line 3: headroom
40 [wid("headroom-tokens"), sep(), wid("headroom-compression"), sep(), wid("headroom-cost"), sep(), wid("headroom-cache-hit")],
41 ],
42 };
43}
44
45export const DEFAULT_SETTINGS: Settings = createDefaultSettings();
46
47export function generateId(): string {
48 return Math.random().toString(36).slice(2, 10);
49}
50
51export function validateSettings(raw: unknown): Settings {
52 if (!raw || typeof raw !== "object") return createDefaultSettings();
53 const obj = raw as Record<string, unknown>;
54
55 if (!Array.isArray(obj.lines)) return createDefaultSettings();
56
57 const lines: WidgetItemConfig[][] = [];
58 for (const line of obj.lines) {
59 if (!Array.isArray(line)) continue;
60 const items: WidgetItemConfig[] = [];
61 for (const item of line) {
62 if (!item || typeof item !== "object") continue;
63 const it = item as Record<string, unknown>;
64 if (typeof it.id !== "string" || typeof it.type !== "string") continue;
65 items.push({
66 id: it.id,
67 type: it.type,
68 color: typeof it.color === "string" ? it.color : undefined,
69 bold: typeof it.bold === "boolean" ? it.bold : undefined,
70 variant: typeof it.variant === "string" ? it.variant : undefined,
71 rawValue: typeof it.rawValue === "boolean" ? it.rawValue : undefined,
72 customText: typeof it.customText === "string" ? it.customText : undefined,
73 options: isRecord(it.options) ? sanitizeOptions(it.options) : undefined,
74 });
75 }
76 lines.push(items);
77 }
78
79 return {
80 version: typeof obj.version === "number" ? obj.version : CURRENT_VERSION,
81 lines,
82 defaultSeparator: typeof obj.defaultSeparator === "string" ? obj.defaultSeparator : undefined,
83 minimalistMode: typeof obj.minimalistMode === "boolean" ? obj.minimalistMode : undefined,
84 };
85}
86
87function isRecord(value: unknown): value is Record<string, unknown> {
88 return Boolean(value) && typeof value === "object" && !Array.isArray(value);
89}
90
91function sanitizeOptions(
92 value: Record<string, unknown>
93): Record<string, string | number | boolean | null> {
94 const options: Record<string, string | number | boolean | null> = {};
95 for (const [key, option] of Object.entries(value)) {
96 if (
97 option === null ||
98 typeof option === "string" ||
99 typeof option === "number" ||
100 typeof option === "boolean"
101 ) {
102 options[key] = option;
103 }
104 }
105 return options;
106}
107src/widgets/types.ts 117 lines1import type { CacheTTLResult, CacheSessionStats } from "../cache-core.js";
2import type { UsageData } from "../usage-core.js";
3import type { HeadroomStats } from "../headroom-core.js";
4import type { RuntimeData } from "../runtime-core.js";
5
6export interface StatusLinePayload {
7 cwd?: string;
8 session_id?: string;
9 version?: string;
10 mode?: string;
11 effort?: string | number;
12 thinking?: string | number | boolean;
13 output_style?: string | {
14 name?: string;
15 };
16 vim?: string | boolean | {
17 mode?: string;
18 };
19 skills?: string[] | {
20 active?: string[];
21 };
22 workspace?: {
23 current_dir?: string;
24 project_dir?: string;
25 };
26 model?: {
27 id?: string;
28 display_name?: string;
29 };
30 cost?: {
31 total_cost_usd?: number;
32 };
33 context_window?: {
34 used_percentage?: number | null;
35 context_window_size?: number;
36 total_input_tokens?: number;
37 total_output_tokens?: number;
38 cache_read_input_tokens?: number;
39 current_usage?: {
40 cache_read_input_tokens?: number;
41 input_tokens?: number;
42 output_tokens?: number;
43 total_input_tokens?: number;
44 total_output_tokens?: number;
45 };
46 };
47 transcript_path?: string;
48 git_branch?: string;
49}
50
51export interface RenderContext {
52 payload: StatusLinePayload;
53 cacheTTL: CacheTTLResult;
54 cacheStats: CacheSessionStats;
55 usageData: UsageData | null;
56 headroomStats: HeadroomStats | null;
57 runtime: RuntimeData;
58 displayMode?: "normal" | "minimal";
59 isPreview?: boolean;
60}
61
62export interface WidgetItem {
63 id: string;
64 type: string;
65 color?: string;
66 bold?: boolean;
67 variant?: string;
68 rawValue?: boolean;
69 customText?: string;
70 options?: Record<string, string | number | boolean | null>;
71}
72
73export interface WidgetCatalogEntry {
74 type: string;
75 displayName: string;
76 description: string;
77 category: string;
78 variants?: string[];
79 dataKey?: string;
80}
81
82/**
83 * One styled span produced by a formatter. Both the legacy ANSI path and
84 * the mod's native `<Text>`/`<Link>` path consume the same token list —
85 * the data layer's output is identical; only the renderer differs.
86 */
87export type TokenStyle =
88 | { color?: string; dim?: boolean; bold?: boolean }
89 | { type: "link"; url: string };
90
91export interface StyledToken {
92 text: string;
93 style?: TokenStyle;
94}
95
96export interface Widget {
97 getDisplayName(): string;
98 getDescription(): string;
99 getCategory(): string;
100 getDefaultColor(): string;
101 /**
102 * Legacy ANSI renderer path. Kept for the CLI and any consumer that
103 * wants raw text. The mod path does not call this directly — it asks
104 * for tokens via `renderTokens` and renders them as native elements.
105 */
106 render(item: WidgetItem, context: RenderContext): string | null;
107 /**
108 * Token renderer used by the mod (and by the legacy renderer, which
109 * converts tokens back to ANSI). Optional: defaults to splitting `render`'s
110 * ANSI output into tokens (SGR only; OSC-8 hyperlinks pass through as text).
111 * Widgets that can emit tokens natively should override for fidelity.
112 */
113 renderTokens?(item: WidgetItem, context: RenderContext): StyledToken[] | null;
114 supportsColors(): boolean;
115 getVariants?(): string[];
116 getDataKey?(): string;
117}src/cache-core.ts 33 lines1/**
2 * Pure type definitions for cache data, free of any Node-only imports.
3 * Safe to import from the mod path. The Node-using implementation lives
4 * in `cache.ts`; the mod implementation lives in `cache-mod.ts`.
5 */
6
7export interface CacheTTLResult {
8 /** Seconds remaining on cache TTL. 0 = expired. -1 = no cache data found. */
9 remainingSeconds: number;
10 /** Which TTL tier: "5m", "1h", or "none" */
11 tier: "5m" | "1h" | "none";
12 /** Timestamp of the last cache write (ISO string) */
13 lastWriteTime: string | null;
14 /** Absolute expiration time (epoch ms). null if no cache data. */
15 expiresAt: number | null;
16 /** Whether this is from the current request's cache_read (still active) */
17 cacheReadActive: boolean;
18}
19
20export interface CacheSessionStats {
21 /** Cumulative cache_read_input_tokens across the session */
22 totalReads: number;
23 /** Cumulative cache_creation_input_tokens across the session */
24 totalWrites: number;
25 /** Number of distinct cache breaks (write after expiry, or first write) */
26 breakCount: number;
27 /** ISO timestamp of the most recent break */
28 lastBreakTime: string | null;
29 /** Token count of the most recent break (for large-rewrite detection) */
30 lastBreakTokens: number;
31 /** Average token count per break (for comparison) */
32 avgBreakTokens: number;
33}src/headroom-core.ts 19 lines1/**
2 * Pure type definitions for Headroom stats, free of Node-only imports.
3 * Safe to import from the mod path.
4 */
5
6export interface HeadroomStats {
7 /** Tokens savings percentage, as reported by /tokens, 0-100. */
8 compressionPct: number;
9 /** Total tokens saved (sum of cli + server side). */
10 tokensSaved: number;
11 /** Tokens saved by CLI cache hits alone. */
12 cliTokensSaved: number;
13 /** USD savings as reported by /cost. */
14 costSavedUsd: number;
15 /** Total number of requests processed by Headroom. */
16 requests: number;
17 /** Prefix cache hit rate, 0-1. */
18 cacheHitRate: number;
19}src/usage-core.ts 22 lines1/**
2 * Pure type definitions for OAuth usage data, free of Node-only imports.
3 * Safe to import from the mod path.
4 */
5
6export interface RateLimit {
7 utilization: number | null;
8 resets_at: string | null;
9}
10
11export interface UsageData {
12 five_hour?: RateLimit;
13 seven_day?: RateLimit;
14 seven_day_opus?: RateLimit;
15 seven_day_sonnet?: RateLimit;
16 extra_usage?: {
17 is_enabled: boolean;
18 monthly_limit: number | null;
19 used_credits: number | null;
20 utilization: number;
21 };
22}src/runtime-core.ts 76 lines1/**
2 * Pure type definitions for runtime data, free of Node-only imports.
3 * Safe to import from the mod path.
4 */
5
6export interface RemoteInfo {
7 rawUrl: string;
8 owner: string | null;
9 repo: string | null;
10}
11
12export interface GitInfo {
13 available: boolean;
14 cwd: string | null;
15 branch: string | null;
16 rootPath: string | null;
17 rootName: string | null;
18 sha: string | null;
19 staged: number;
20 unstaged: number;
21 untracked: number;
22 conflicts: number;
23 changes: number;
24 insertions: number;
25 deletions: number;
26 ahead: number;
27 behind: number;
28 origin: RemoteInfo | null;
29 upstream: RemoteInfo | null;
30 isFork: boolean;
31 worktreeMode: string | null;
32 worktreeName: string | null;
33 worktreeBranch: string | null;
34 worktreeOriginalBranch: string | null;
35}
36
37export interface SessionInfo {
38 sessionId: string | null;
39 version: string | null;
40 outputStyle: string | null;
41 vimMode: string | null;
42 thinkingEffort: string | null;
43 skills: string[];
44 accountEmail: string | null;
45 startedAt: string | null;
46 elapsedSeconds: number | null;
47}
48
49export interface SystemInfo {
50 terminalWidth: number | null;
51 memoryUsedBytes: number;
52 memoryTotalBytes: number;
53}
54
55export interface TokenInfo {
56 input: number | null;
57 output: number | null;
58 cached: number | null;
59 total: number | null;
60 inputSpeed: number | null;
61 outputSpeed: number | null;
62 totalSpeed: number | null;
63}
64
65export interface UsageWindowInfo {
66 fiveHourResetSeconds: number | null;
67 sevenDayResetSeconds: number | null;
68}
69
70export interface RuntimeData {
71 git: GitInfo;
72 session: SessionInfo;
73 system: SystemInfo;
74 tokens: TokenInfo;
75 usage: UsageWindowInfo;
76}src/widgets/renderer-mod.ts 122 lines1/**
2 * Mod renderer: convert `Settings` + `RenderContext` to a native React
3 * tree of `Box` / `Text` / `Link` elements.
4 *
5 * The engine provides element constructors via `$.ui.resolve(e)`. We
6 * accept those as a structural `ModSurfaceElements` object so this
7 * module does not need to import from `claude-code` directly.
8 */
9import type { Settings } from "../config/schema.js";
10import type { RenderContext, StyledToken } from "./types.js";
11import { getWidget, renderTokensFor } from "./registry.js";
12
13/** Native element constructors as the engine hands them via `$.ui.resolve(e)`. */
14export interface ModSurfaceElements {
15 Box: (...args: unknown[]) => unknown;
16 Text: (...args: unknown[]) => unknown;
17 Link: (...args: unknown[]) => unknown;
18}
19
20/** Apply the user's color override to a token list. */
21function withColorOverride(
22 tokens: StyledToken[] | null,
23 color: string,
24): StyledToken[] | null {
25 if (tokens === null) return null;
26 return tokens.map((t) => {
27 if (t.style && "type" in t.style && t.style.type === "link") return t;
28 const cur = (t.style ?? {}) as { color?: string; dim?: boolean; bold?: boolean };
29 return { text: t.text, style: { ...cur, color } };
30 });
31}
32
33function tokenToElement(
34 tok: StyledToken,
35 E: ModSurfaceElements,
36 key: string,
37): unknown {
38 const style = tok.style;
39 if (style && "type" in style && style.type === "link") {
40 return E.Link({ key, href: style.url, children: tok.text });
41 }
42 const s = style as { color?: string; dim?: boolean; bold?: boolean } | undefined;
43 return E.Text({
44 key,
45 color: s?.color,
46 dimColor: s?.dim,
47 bold: s?.bold,
48 children: tok.text,
49 });
50}
51
52/**
53 * Render the configured statusline as a React tree. Returns `undefined`
54 * when there is nothing to display — the band vanishes (mod-builder:
55 * "appear only when there is something to say").
56 */
57export function renderStatusLineElement(
58 settings: Settings,
59 ctx: RenderContext,
60 E: ModSurfaceElements,
61): unknown {
62 const lineRows: unknown[] = [];
63 for (let lineIdx = 0; lineIdx < settings.lines.length; lineIdx++) {
64 const lineItems = settings.lines[lineIdx];
65 if (!lineItems) continue;
66 const rendered: { tokens: StyledToken[] | null; isSep: boolean }[] = [];
67 for (const item of lineItems) {
68 const widget = getWidget(item.type);
69 if (!widget) continue;
70 const raw = renderTokensFor(widget, item, ctx);
71 const tokens =
72 raw !== null && item.color && item.color !== "default"
73 ? withColorOverride(raw, item.color)
74 : raw;
75 rendered.push({ tokens, isSep: item.type === "separator" });
76 }
77
78 const segments: unknown[] = [];
79 let lastWasSep = false;
80 for (let i = 0; i < rendered.length; i++) {
81 const { tokens, isSep } = rendered[i];
82 if (tokens === null) continue;
83 if (isSep) {
84 if (lastWasSep) continue;
85 const hasAfter = rendered
86 .slice(i + 1)
87 .some((r) => !r.isSep && r.tokens !== null);
88 if (segments.length > 0 && hasAfter) {
89 segments.push(
90 E.Text({ key: `sep-${lineIdx}-${i}`, dimColor: true, children: " | " }),
91 );
92 lastWasSep = true;
93 }
94 } else {
95 tokens.forEach((tok, j) =>
96 segments.push(tokenToElement(tok, E, `tok-${lineIdx}-${i}-${j}`)),
97 );
98 lastWasSep = false;
99 }
100 }
101
102 if (segments.length > 0) {
103 lineRows.push(
104 E.Box({
105 key: `line-${lineIdx}`,
106 flexDirection: "row",
107 gap: 2,
108 alignItems: "center",
109 children: segments,
110 }),
111 );
112 }
113 }
114
115 if (lineRows.length === 0) return undefined;
116 return E.Box({
117 key: "statusline",
118 flexDirection: "column",
119 paddingRight: 4, // engine reserves 5 cols for `[-]` chrome
120 children: lineRows,
121 });
122}src/widgets/registry.ts 308 lines1import type { Widget, WidgetCatalogEntry, StyledToken } from "./types.js";
2import type { WidgetExtension } from "../extensions/types.js";
3import { tokensToAnsi } from "../colors.js";
4import { PathWidget } from "./PathWidget.js";
5import { BranchWidget } from "./BranchWidget.js";
6import { ModelWidget } from "./ModelWidget.js";
7import { CostWidget } from "./CostWidget.js";
8import { ContextBarWidget } from "./ContextBarWidget.js";
9import { CacheTTLWidget } from "./CacheTTLWidget.js";
10import { CacheTokensWidget } from "./CacheTokensWidget.js";
11import { Usage5hWidget } from "./Usage5hWidget.js";
12import { Usage7dWidget } from "./Usage7dWidget.js";
13import { UsageOverageWidget } from "./UsageOverageWidget.js";
14import { HeadroomTokensWidget } from "./HeadroomTokensWidget.js";
15import { HeadroomCompressionWidget } from "./HeadroomCompressionWidget.js";
16import { HeadroomCostWidget } from "./HeadroomCostWidget.js";
17import { HeadroomCacheHitWidget } from "./HeadroomCacheHitWidget.js";
18import { SeparatorWidget } from "./SeparatorWidget.js";
19import { CustomTextWidget } from "./CustomTextWidget.js";
20import {
21 AccountEmailWidget,
22 CustomCommandWidget,
23 CustomSymbolWidget,
24 LinkWidget,
25 OutputStyleWidget,
26 SessionClockWidget,
27 SessionElapsedWidget,
28 SessionIdWidget,
29 SkillsWidget,
30 TerminalWidthWidget,
31 ThinkingEffortWidget,
32 VersionWidget,
33 VimModeWidget,
34 MemoryUsageWidget,
35} from "./SessionWidgets.js";
36import {
37 GitAheadBehindWidget,
38 GitChangesWidget,
39 GitConflictsWidget,
40 GitDeletionsWidget,
41 GitInsertionsWidget,
42 GitIsForkWidget,
43 GitOriginOwnerRepoWidget,
44 GitOriginOwnerWidget,
45 GitOriginRepoWidget,
46 GitRootDirWidget,
47 GitShaWidget,
48 GitStagedWidget,
49 GitStatusWidget,
50 GitUnstagedWidget,
51 GitUntrackedWidget,
52 GitUpstreamOwnerRepoWidget,
53 GitUpstreamOwnerWidget,
54 GitUpstreamRepoWidget,
55 GitWorktreeBranchWidget,
56 GitWorktreeModeWidget,
57 GitWorktreeNameWidget,
58 GitWorktreeOriginalBranchWidget,
59} from "./GitWidgets.js";
60import {
61 ContextLengthWidget,
62 ContextPercentageWidget,
63 InputSpeedWidget,
64 InputTokensWidget,
65 OutputSpeedWidget,
66 OutputTokensWidget,
67 TotalSpeedWidget,
68 TotalTokensWidget,
69 UsageReset5hWidget,
70 UsageReset7dWidget,
71 ReplayCostWidget,
72 RunwayWidget,
73 LargeCacheWarningWidget,
74} from "./MetricWidgets.js";
75
76interface ManifestEntry {
77 type: string;
78 create: () => Widget;
79}
80
81const WIDGET_MANIFEST: ManifestEntry[] = [
82 { type: "path", create: () => new PathWidget() },
83 { type: "branch", create: () => new BranchWidget() },
84 { type: "model", create: () => new ModelWidget() },
85 { type: "cost", create: () => new CostWidget() },
86 { type: "context-bar", create: () => new ContextBarWidget() },
87 { type: "cache-ttl", create: () => new CacheTTLWidget() },
88 { type: "cache-tokens", create: () => new CacheTokensWidget() },
89 { type: "usage-5h", create: () => new Usage5hWidget() },
90 { type: "usage-7d", create: () => new Usage7dWidget() },
91 { type: "usage-overage", create: () => new UsageOverageWidget() },
92 { type: "headroom-tokens", create: () => new HeadroomTokensWidget() },
93 { type: "headroom-compression", create: () => new HeadroomCompressionWidget() },
94 { type: "headroom-cost", create: () => new HeadroomCostWidget() },
95 { type: "headroom-cache-hit", create: () => new HeadroomCacheHitWidget() },
96 { type: "separator", create: () => new SeparatorWidget() },
97 { type: "custom-text", create: () => new CustomTextWidget() },
98 { type: "session-id", create: () => new SessionIdWidget() },
99 { type: "version", create: () => new VersionWidget() },
100 { type: "output-style", create: () => new OutputStyleWidget() },
101 { type: "session-clock", create: () => new SessionClockWidget() },
102 { type: "session-elapsed", create: () => new SessionElapsedWidget() },
103 { type: "account-email", create: () => new AccountEmailWidget() },
104 { type: "thinking-effort", create: () => new ThinkingEffortWidget() },
105 { type: "vim-mode", create: () => new VimModeWidget() },
106 { type: "skills", create: () => new SkillsWidget() },
107 { type: "terminal-width", create: () => new TerminalWidthWidget() },
108 { type: "memory-usage", create: () => new MemoryUsageWidget() },
109 { type: "custom-symbol", create: () => new CustomSymbolWidget() },
110 { type: "link", create: () => new LinkWidget() },
111 { type: "custom-command", create: () => new CustomCommandWidget() },
112 { type: "git-status", create: () => new GitStatusWidget() },
113 { type: "git-changes", create: () => new GitChangesWidget() },
114 { type: "git-staged", create: () => new GitStagedWidget() },
115 { type: "git-unstaged", create: () => new GitUnstagedWidget() },
116 { type: "git-untracked", create: () => new GitUntrackedWidget() },
117 { type: "git-ahead-behind", create: () => new GitAheadBehindWidget() },
118 { type: "git-conflicts", create: () => new GitConflictsWidget() },
119 { type: "git-sha", create: () => new GitShaWidget() },
120 { type: "git-root", create: () => new GitRootDirWidget() },
121 { type: "git-insertions", create: () => new GitInsertionsWidget() },
122 { type: "git-deletions", create: () => new GitDeletionsWidget() },
123 { type: "git-origin-owner", create: () => new GitOriginOwnerWidget() },
124 { type: "git-origin-repo", create: () => new GitOriginRepoWidget() },
125 { type: "git-origin-owner-repo", create: () => new GitOriginOwnerRepoWidget() },
126 { type: "git-upstream-owner", create: () => new GitUpstreamOwnerWidget() },
127 { type: "git-upstream-repo", create: () => new GitUpstreamRepoWidget() },
128 { type: "git-upstream-owner-repo", create: () => new GitUpstreamOwnerRepoWidget() },
129 { type: "git-is-fork", create: () => new GitIsForkWidget() },
130 { type: "git-worktree-mode", create: () => new GitWorktreeModeWidget() },
131 { type: "git-worktree-name", create: () => new GitWorktreeNameWidget() },
132 { type: "git-worktree-branch", create: () => new GitWorktreeBranchWidget() },
133 { type: "git-worktree-original-branch", create: () => new GitWorktreeOriginalBranchWidget() },
134 { type: "tokens-input", create: () => new InputTokensWidget() },
135 { type: "tokens-output", create: () => new OutputTokensWidget() },
136 { type: "tokens-total", create: () => new TotalTokensWidget() },
137 { type: "input-speed", create: () => new InputSpeedWidget() },
138 { type: "output-speed", create: () => new OutputSpeedWidget() },
139 { type: "total-speed", create: () => new TotalSpeedWidget() },
140 { type: "context-percent", create: () => new ContextPercentageWidget() },
141 { type: "context-length", create: () => new ContextLengthWidget() },
142 { type: "usage-reset-5h", create: () => new UsageReset5hWidget() },
143 { type: "usage-reset-7d", create: () => new UsageReset7dWidget() },
144 { type: "replay-cost", create: () => new ReplayCostWidget() },
145 { type: "runway", create: () => new RunwayWidget() },
146 { type: "large-cache-warning", create: () => new LargeCacheWarningWidget() },
147];
148
149const widgetRegistry = new Map<string, Widget>(
150 WIDGET_MANIFEST.map((entry) => [entry.type, entry.create()])
151);
152
153// Tracks extension-contributed entries separately so the catalog can
154// include them alongside built-in widgets.
155const extensionManifest: ManifestEntry[] = [];
156
157export function getWidget(type: string): Widget | null {
158 return widgetRegistry.get(type) ?? null;
159}
160
161export function getAllWidgetTypes(): string[] {
162 return [
163 ...WIDGET_MANIFEST.map((e) => e.type),
164 ...extensionManifest.map((e) => e.type),
165 ];
166}
167
168export function getWidgetCatalog(): WidgetCatalogEntry[] {
169 const allEntries = [...WIDGET_MANIFEST, ...extensionManifest];
170 return allEntries.map((entry) => {
171 const w = widgetRegistry.get(entry.type)!;
172 return {
173 type: entry.type,
174 displayName: w.getDisplayName(),
175 description: w.getDescription(),
176 category: w.getCategory(),
177 variants: w.getVariants?.(),
178 dataKey: w.getDataKey?.(),
179 };
180 });
181}
182
183export function getWidgetCategories(): string[] {
184 const cats = new Set(getWidgetCatalog().map((e) => e.category));
185 return [...cats];
186}
187
188/**
189 * Registers all widgets contributed by a single extension.
190 * Built-in widget types cannot be overridden — duplicate types are silently skipped.
191 */
192export function registerExtension(extension: WidgetExtension): void {
193 for (const reg of extension.widgets) {
194 if (widgetRegistry.has(reg.type)) continue; // protect built-ins
195 const widget = reg.create();
196 widgetRegistry.set(reg.type, widget);
197 extensionManifest.push({ type: reg.type, create: reg.create });
198 }
199}
200
201/**
202 * (loadExtensions lives in src/extensions/register-cli.ts, not here — the
203 * dynamic import would otherwise reach the mod's import chain via this
204 * file's re-export. CLI code imports it directly.)
205 */
206
207// ─────────────────────────────────────────────────────────────────────
208// Token rendering: the data layer emits StyledToken[]. The legacy ANSI
209// renderer (renderer.ts) joins tokens via tokensToAnsi; the mod renderer
210// (renderer-mod.ts) walks tokens into native <Text>/<Link> elements.
211// Widgets that can emit tokens natively should override `renderTokens`.
212// ─────────────────────────────────────────────────────────────────────
213
214/** Match SGR escape sequences (color/bold/dim/reset). */
215const SGR_RE = /\x1b\[[0-9;]*m/g;
216/** Match OSC-8 hyperlink sequences (link…END). */
217const OSC8_RE = /\x1b\]8;;[^\x07\x1b]*\x07([^\x1b]*)\x1b\]8;;\x07/g;
218
219/**
220 * Default splitter for widgets that don't override `renderTokens`. Walks the
221 * SGR-codes in the widget's ANSI output and emits one token per span. OSC-8
222 * hyperlinks are passed through as plain text (LinkWidget overrides directly).
223 */
224function defaultRenderTokensFor(
225 widget: Widget,
226 item: WidgetItem,
227 ctx: RenderContext,
228): StyledToken[] | null {
229 const out = widget.render(item, ctx);
230 if (out === null) return null;
231 const stripped = out.replace(OSC8_RE, "$1");
232 const tokens: StyledToken[] = [];
233 let lastIndex = 0;
234 let activeStyle: { color?: string; dim?: boolean; bold?: boolean } = {};
235 SGR_RE.lastIndex = 0;
236 let m: RegExpExecArray | null;
237 while ((m = SGR_RE.exec(stripped)) !== null) {
238 if (m.index > lastIndex) {
239 tokens.push({ text: stripped.slice(lastIndex, m.index), style: { ...activeStyle } });
240 }
241 lastIndex = m.index + m[0].length;
242 const code = m[0].slice(2, -1);
243 if (code === "0") {
244 activeStyle = {};
245 } else if (code === "1") {
246 activeStyle = { ...activeStyle, bold: true };
247 } else if (code === "2") {
248 activeStyle = { ...activeStyle, dim: true };
249 } else {
250 const named = Object.entries({
251 "31": "red", "32": "green", "33": "yellow", "34": "blue", "35": "magenta",
252 "36": "cyan", "37": "white", "90": "gray",
253 "91": "redBright", "92": "greenBright", "93": "yellowBright",
254 "94": "blueBright", "95": "magentaBright", "96": "cyanBright",
255 }).find(([c]) => c === code)?.[1];
256 if (named) activeStyle = { ...activeStyle, color: named };
257 }
258 }
259 if (lastIndex < stripped.length) {
260 tokens.push({ text: stripped.slice(lastIndex), style: { ...activeStyle } });
261 }
262 // Merge consecutive same-style tokens.
263 const merged: StyledToken[] = [];
264 for (const tok of tokens) {
265 const prev = merged[merged.length - 1];
266 const same = prev && JSON.stringify(prev.style ?? {}) === JSON.stringify(tok.style ?? {});
267 if (same && prev) {
268 prev.text += tok.text;
269 } else {
270 merged.push({ ...tok, style: tok.style ? { ...tok.style } : undefined });
271 }
272 }
273 return merged.length > 0 ? merged : [{ text: out, style: {} }];
274}
275
276import type { WidgetItem, RenderContext } from "./types.js";
277
278/**
279 * Get tokens for a widget, applying any user-set color override. Returns
280 * null when the widget has nothing to render for this item.
281 */
282export function renderTokensFor(
283 widget: Widget,
284 item: WidgetItem,
285 ctx: RenderContext,
286): StyledToken[] | null {
287 if (widget.renderTokens) {
288 return widget.renderTokens(item, ctx);
289 }
290 return defaultRenderTokensFor(widget, item, ctx);
291}
292
293export function getWidgetsByDataKey(dataKey: string, catalog?: WidgetCatalogEntry[]): WidgetCatalogEntry[] {
294 return (catalog ?? getWidgetCatalog()).filter((e) => e.dataKey === dataKey);
295}
296
297export function getDataKeyGroups(catalog?: WidgetCatalogEntry[]): Map<string, WidgetCatalogEntry[]> {
298 const entries = catalog ?? getWidgetCatalog();
299 const groups = new Map<string, WidgetCatalogEntry[]>();
300 for (const entry of entries) {
301 if (!entry.dataKey) continue;
302 const list = groups.get(entry.dataKey) ?? [];
303 list.push(entry);
304 groups.set(entry.dataKey, list);
305 }
306 return groups;
307}
308src/extensions/types.ts 36 lines1import type { Widget } from "../widgets/types.js";
2
3/**
4 * A single widget entry that an extension contributes to the registry.
5 */
6export interface WidgetRegistration {
7 /** Unique type identifier (e.g. "my-org.git-status"). Must not clash with built-in types. */
8 type: string;
9 /** Factory function that creates a new instance of the widget. */
10 create: () => Widget;
11}
12
13/**
14 * The shape that an extension package must export as `extension` (named export)
15 * or as the default export so that the discovery loader can register its widgets.
16 *
17 * @example
18 * ```ts
19 * // my-statusline-widget/src/index.ts
20 * import type { WidgetExtension } from "claude-statusline-widgets/extension-api";
21 * import { MyCustomWidget } from "./MyCustomWidget.js";
22 *
23 * const extension: WidgetExtension = {
24 * widgets: [
25 * { type: "my-org.custom", create: () => new MyCustomWidget() },
26 * ],
27 * };
28 *
29 * export { extension };
30 * export default extension;
31 * ```
32 */
33export interface WidgetExtension {
34 widgets: WidgetRegistration[];
35}
36src/colors.ts 110 lines1const ESC = "\x1b[";
2const RESET = `${ESC}0m`;
3
4const COLOR_CODE_MAP: Record<string, string> = {
5 red: "31",
6 green: "32",
7 yellow: "33",
8 blue: "34",
9 magenta: "35",
10 cyan: "36",
11 white: "37",
12 gray: "90",
13 redBright: "91",
14 greenBright: "92",
15 yellowBright: "93",
16 blueBright: "94",
17 magentaBright: "95",
18 cyanBright: "96",
19};
20
21/** Apply a named color to text using ANSI escape codes. */
22export function applyColor(text: string, color: string | undefined): string {
23 if (!color || color === "default") return text;
24 const code = COLOR_CODE_MAP[color];
25 if (!code) return text;
26 return `${ESC}${code}m${text}${RESET}`;
27}
28
29/** Return the set of supported named colors (excluding "default"). */
30export function getSupportedColors(): string[] {
31 return Object.keys(COLOR_CODE_MAP);
32}
33
34// eslint-disable-next-line no-control-regex
35const ANSI_RE = /\x1b\[[0-9;]*m/g;
36
37/** Return the visible character width of a string (strips ANSI escapes). */
38export function visibleLength(text: string): number {
39 return text.replace(ANSI_RE, "").length;
40}
41
42export function green(text: string): string {
43 return `${ESC}32m${text}${RESET}`;
44}
45
46export function yellow(text: string): string {
47 return `${ESC}33m${text}${RESET}`;
48}
49
50export function red(text: string): string {
51 return `${ESC}31m${text}${RESET}`;
52}
53
54export function cyan(text: string): string {
55 return `${ESC}36m${text}${RESET}`;
56}
57
58export function dim(text: string): string {
59 return `${ESC}2m${text}${RESET}`;
60}
61
62export function bold(text: string): string {
63 return `${ESC}1m${text}${RESET}`;
64}
65
66const OSC8_START = "\x1b]8;;";
67const OSC8_END = "\x1b]8;;\x07";
68
69/** Wrap text as a clickable OSC-8 hyperlink with the given URL. */
70export function hyperlink(text: string, url: string): string {
71 return `${OSC8_START}${url}${OSC8_END}${text}${OSC8_START}${OSC8_END}`;
72}
73
74/**
75 * Apply the token style to its text using ANSI escape codes.
76 * Returns plain text when the style is undefined or default.
77 */
78function applyStyle(text: string, style: { color?: string; dim?: boolean; bold?: boolean }): string {
79 let out = text;
80 if (style.color && style.color !== "default") {
81 const code = COLOR_CODE_MAP[style.color];
82 if (code) out = `${ESC}${code}m${out}${RESET}`;
83 }
84 if (style.dim) out = `${ESC}2m${out}${RESET}`;
85 if (style.bold) out = `${ESC}1m${out}${RESET}`;
86 return out;
87}
88
89import type { StyledToken, TokenStyle } from "./widgets/types.js";
90
91/**
92 * Convert a StyledToken[] to an ANSI-escaped string. The single renderer
93 * for any consumer (CLI TUI, legacy statusLine command, pipe mode) — the
94 * mod path does not call this; it walks tokens directly into Box/Text/Link.
95 */
96export function tokensToAnsi(tokens: StyledToken[]): string {
97 let out = "";
98 for (const tok of tokens) {
99 const style = tok.style as TokenStyle | undefined;
100 if (style && "type" in style && style.type === "link") {
101 out += hyperlink(tok.text, style.url);
102 } else if (style) {
103 out += applyStyle(tok.text, style as { color?: string; dim?: boolean; bold?: boolean });
104 } else {
105 out += tok.text;
106 }
107 }
108 return out;
109}
110src/widgets/PathWidget.ts 22 lines1import { formatPath } from "../segments.js";
2import { tokensToAnsi } from "../colors.js";
3import type { Widget, WidgetItem, RenderContext } from "./types.js";
4
5export class PathWidget implements Widget {
6 getDisplayName() { return "Path"; }
7 getDescription() { return "Working directory"; }
8 getCategory() { return "Session"; }
9 getDefaultColor() { return "default"; }
10 supportsColors() { return true; }
11 render(_item: WidgetItem, ctx: RenderContext): string | null {
12 if (ctx.isPreview) return "~/projects/my-app";
13 const cwd =
14 ctx.runtime.git.cwd ??
15 ctx.payload.cwd ??
16 ctx.payload.workspace?.current_dir ??
17 ctx.payload.workspace?.project_dir;
18 const tokens = formatPath(cwd);
19 return tokens ? tokensToAnsi(tokens) : null;
20 }
21}
22