SLOPSHOPPER

cache-ttl-statusline

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

newbandcommandprocessnetworktimer
★ 4v1.6.1MITupdated 2026-10-07JerrettDavis/ClaudeStatusLineWidgets
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-ttl-statusline
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /statusline-mode ⎿ cache-ttl-statusline: Status line mode: dev. Use /statusline-mode hook|mod to switch. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Claude StatusLine Widgets

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.

TypeScript Node.js License


What it looks like

Full default layout

The default layout renders three lines below your Claude Code prompt:

LineContent
1 — SessionWorking directory · git branch · model · cost · context bar · cache TTL
2 — Usage5-hour and 7-day rate-limit bars · overage spend (hidden when unavailable)
3 — HeadroomTokens saved · compression % · cost saved · cache hit rate (hidden unless proxy is active)

Context window color coding

The context bar changes color as you approach the limit:

High context warning

Cache TTL color coding

The cache TTL indicator turns from green → yellow → red as expiry approaches:

Cache TTL states


Installation

From the Marketplace (recommended)

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.

Local development / standalone

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.

Choose how the status line is drawn

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:

ModeHow it is drawnTrade-offs
hook (default)Classic statusLine command written to settings.json at SessionStartFull ANSI colour and every row, below the prompt
modNative Claude Code Mod, no settings.json entryColoured 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.

Install the ccfooter-config CLI globally

Requires 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.


Configuration

Interactive TUI

Launch the TUI configurator with:

ccfooter-config

TUI configurator

The TUI lets you:

  • Add / remove / reorder widgets on each line
  • Cycle display variants for widgets that support multiple representations
  • Pick colors from the full ANSI palette with a live preview
  • Toggle a global minimalist mode for label-light output
  • Add or delete entire lines
  • Reset to the factory 3-line layout
  • See a live preview that updates as you make changes
Keyboard shortcuts
ContextKeyAction
GlobalCtrl+SSave settings
GlobalCtrl+CQuit
Line EditoraAdd a widget
Line EditorvCycle the selected widget's display variant
Line Editord / DeleteRemove selected widget
Line EditormToggle move mode (reorder with arrow keys)
Line EditorxDelete entire line
Line EditorEscGo back
Line SelectoraAdd a new line
Widget PickerArrow keysNavigate widgets
Widget PickerEnterSelect widget to add
Widget PickerEscCancel

Settings file

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.


Available Widgets

Claude StatusLine Widgets now ships with 62 built-in widgets across seven categories, plus variants for context, cache, usage, and skills widgets.

CategoryIncluded widgets
Sessionpath, branch, model, cost, session-id, version, output-style, session-clock, session-elapsed, account-email, thinking-effort, vim-mode, skills
Contextcontext-bar, context-percent, context-length, cache-ttl, cache-tokens
Usageusage-5h, usage-7d, usage-overage, usage-reset-5h, usage-reset-7d
Tokenstokens-input, tokens-output, tokens-total, input-speed, output-speed, total-speed
Gitgit-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
Headroomheadroom-tokens, headroom-compression, headroom-cost, headroom-cache-hit
Environmentterminal-width, memory-usage
Layoutseparator, custom-text, custom-symbol, link, custom-command

Shared widget variants include:

  • context-bar: bar, percent, remaining
  • context-percent: percent, bar, remaining
  • cache-ttl: time, countdown, badge
  • usage-5h / usage-7d: bar, percent, countdown
  • usage-overage: bar, percent
  • skills: count, list

For per-widget documentation, examples, and configuration options see docs/widgets.md.


API Usage tracking

API usage line

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.


Headroom proxy integration

Headroom stats line

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.


How it works

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):

  1. Observes native session lifecycle and measurement events such as session.start, session.measure, and turn.complete.
  2. Reads session state through the Mod API including model, cwd, session id, context usage, version, and transcript metadata.
  3. Renders in-process: reads the transcript, caches and git state through the Mod's $.fs/$.process/$.http, then builds the configured widget tree with native Box/Text elements.
  4. Draws the tree in the 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.
  5. Keeps the existing widget registry, settings file, transcript-based cache TTL logic, API usage cache, Headroom integration, and TUI configurator unchanged.
  6. Refreshes on session measurements, completed turns, session changes, and a low-frequency timer so countdown-style widgets continue to move while idle.

Running dist/index.js interactively still launches the React/Ink TUI configurator, and its piped-input mode remains available as a legacy/standalone renderer.


Architecture

.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

Development

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.


License

MIT

Source 34 files
hooks/statusline.tsx 790 lines
1/**
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 lines
1export 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}
107
src/widgets/types.ts 117 lines
1import 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 lines
1/**
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 lines
1/**
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 lines
1/**
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 lines
1/**
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 lines
1/**
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 lines
1import 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}
308
src/extensions/types.ts 36 lines
1import 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}
36
src/colors.ts 110 lines
1const 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}
110
src/widgets/PathWidget.ts 22 lines
1import { 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