pi-spark 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,10 +6,10 @@ A small, opinionated collection of [pi](https://pi.dev/) extensions.
6
6
 
7
7
  ## Extensions
8
8
 
9
- - **Codex usage:** shows your OpenAI Codex (ChatGPT) rate-limit usage as a footer status when a Codex model is active.
10
9
  - **Editor:** replaces the default editor with a compact working indicator (inspired by [Amp](https://ampcode.com/)) and current model info.
11
10
  - **Footer:** shows session information, extension statuses, cost, and context usage on one line.
12
11
  - **Fullscreen:** clears the screen and scrollback on session start, pins the editor and footer to the bottom for a full-screen session, and clears again on exit.
12
+ - **Models:** exposes a `model` tool so the agent can list the models the user can use currently and inspect the provider, model, and thinking level in use.
13
13
  - **Name:** exposes a `name` tool so the agent can give the current session a concise, recognizable name in the session selector.
14
14
  - **Presets:** switches named model presets with `/preset`, `--preset`, and quick cycle shortcuts.
15
15
  - **Recap:** generates a short idle-session recap and exposes a `/recap` command for manual generation, inspired by [Claude Code's session recap](https://code.claude.com/docs/en/interactive-mode#session-recap).
@@ -46,29 +46,25 @@ Example:
46
46
  "footer": false,
47
47
  "presets": {
48
48
  "claude-opus": {
49
- "model": "claude-opus-4-8",
50
49
  "provider": "anthropic",
50
+ "model": "claude-opus-4-8",
51
51
  "thinkingLevel": "high"
52
52
  },
53
53
  "gpt": {
54
- "model": "gpt-5.5",
55
54
  "provider": "openai-codex",
55
+ "model": "gpt-5.5",
56
56
  "thinkingLevel": "medium"
57
57
  }
58
58
  },
59
59
  "recap": {
60
- "idle": 180000,
61
- "model": "gpt-5.4-mini",
60
+ "idle": "5m",
62
61
  "provider": "openai-codex",
62
+ "model": "gpt-5.4-mini",
63
63
  "thinkingLevel": "off"
64
64
  }
65
65
  }
66
66
  ```
67
67
 
68
- ### Codex usage
69
-
70
- - pi-spark queries the ChatGPT backend and shows your Codex 5-hour (`5h`) and 7-day (`7d`) rate-limit usage as a footer status, refreshing on session start, model change, and after billable turns. The status appears only while a Codex (`openai-codex`) model is active and uses its stored OAuth credential.
71
-
72
68
  ### Editor
73
69
 
74
70
  - `editor.spinner` controls the working indicator style and can be `dots`, `lights`, `tildes`, or `pulse`.
@@ -81,6 +77,12 @@ Example:
81
77
 
82
78
  - pi-spark clears the screen and scrollback at session start and exit, pins the editor and footer to the bottom, and enables pi's `clearOnShrink` behavior programmatically so pinned UI stays aligned after taller components close.
83
79
 
80
+ ### Models
81
+
82
+ - The agent can call the `model` tool with two actions:
83
+ - `list`: gets all currently usable models with their metadata, with optional `provider` and `model` substring filters and `offset`/`limit` paging.
84
+ - `current`: gets the active provider, model, and thinking level.
85
+
84
86
  ### Name
85
87
 
86
88
  - The agent can set or refresh the current session's name and optionally give a reason.
@@ -98,7 +100,8 @@ Use presets in these ways:
98
100
  ### Recap
99
101
 
100
102
  - pi-spark can generate a short recap after the session has been idle or when you run `/recap` manually.
101
- - The `recap.idle` value is in milliseconds and must be at least `5000`. The recap model can be customized with `provider`, `model`, and `thinkingLevel`.
103
+ - The `recap.idle` value sets how long the session must stay idle before a recap is generated. It accepts either a millisecond number or a human-readable duration string parsed by [vercel/ms](https://github.com/vercel/ms) (e.g., `"3m"`, `"30s"`, `"2 minutes"`), and must resolve to at least 5000ms.
104
+ - The recap model can be customized with `provider`, `model`, and `thinkingLevel`.
102
105
 
103
106
  ## Recommended pi settings
104
107
 
@@ -111,3 +114,7 @@ Use presets in these ways:
111
114
  ```
112
115
 
113
116
  Project trust is an [input-loading guard](https://pi.dev/docs/latest/security#project-trust), so use `"always"` only if you trust the projects you open.
117
+
118
+ ## Other pi packages
119
+
120
+ - [pi-credits](https://github.com/zlliang/pi-credits): shows the active provider's credit balance or rate-limit usage as a footer status.
@@ -0,0 +1,3 @@
1
+ import * as z from "zod";
2
+
3
+ export const modelsConfigSchema = z.object({});
@@ -0,0 +1,188 @@
1
+ import { clampThinkingLevel, getSupportedThinkingLevels, StringEnum } from "@earendil-works/pi-ai";
2
+ import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, truncateHead } from "@earendil-works/pi-coding-agent";
3
+ import { Container, Spacer, Text } from "@earendil-works/pi-tui";
4
+ import { Type } from "typebox";
5
+
6
+ import { loadConfig } from "../shared/config";
7
+ import { formatModel } from "../shared/format";
8
+
9
+ import type { Api, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
10
+ import type { ExtensionAPI, TruncationResult } from "@earendil-works/pi-coding-agent";
11
+
12
+ /** Pi's built-in default thinking level, clamped per model. Not exported by pi's public API. */
13
+ const DEFAULT_THINKING_LEVEL: ModelThinkingLevel = "medium";
14
+
15
+ type ModelMetadata = Omit<Model<Api>, "headers" | "compat"> & {
16
+ thinkingLevels: ModelThinkingLevel[];
17
+ defaultThinkingLevel: ModelThinkingLevel;
18
+ };
19
+
20
+ type ModelToolDetails =
21
+ | { action: "current"; current: { model: ModelMetadata; thinkingLevel: ModelThinkingLevel } }
22
+ | { action: "list"; models: ModelMetadata[]; total: number; truncation?: TruncationResult }
23
+
24
+ function toMetadata(model: Model<Api>): ModelMetadata {
25
+ const { headers: _headers, compat: _compat, ...metadata } = model;
26
+
27
+ return {
28
+ ...metadata,
29
+ thinkingLevels: getSupportedThinkingLevels(model),
30
+ defaultThinkingLevel: clampThinkingLevel(model, DEFAULT_THINKING_LEVEL),
31
+ };
32
+ }
33
+
34
+ export default function (pi: ExtensionAPI) {
35
+ pi.on("session_start", (_event, ctx) => {
36
+ const config = loadConfig(ctx, "models");
37
+ if (!config) return;
38
+
39
+ pi.registerTool({
40
+ name: "model",
41
+ label: "model",
42
+ description:
43
+ "Inspect pi's model state. The \"current\" action returns the active model with metadata " +
44
+ "and thinking level. The \"list\" action returns all models the user can use " +
45
+ "currently (auth configured), including metadata such as provider, id, name, API type, " +
46
+ "reasoning support, input modalities, cost, context window, and max output tokens. " +
47
+ "Lists can be filtered with provider/model queries and paged with offset/limit. " +
48
+ `List output is one JSON object per line, truncated to ${DEFAULT_MAX_LINES} models or ` +
49
+ `${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first).`,
50
+ promptSnippet: "List available models or show the current model in use",
51
+ promptGuidelines: [
52
+ "Use model when the user asks which models are available in pi or which model and thinking level are currently in use.",
53
+ ],
54
+ parameters: Type.Object({
55
+ action: StringEnum(["list", "current"] as const, {
56
+ description:
57
+ "\"list\" returns all currently usable models with metadata; " +
58
+ "\"current\" returns the active model with metadata and thinking level.",
59
+ }),
60
+ provider: Type.Optional(Type.String({
61
+ description: "For list: filter by provider, case-insensitive substring (e.g., \"vercel\", \"moonshot\")",
62
+ })),
63
+ model: Type.Optional(Type.String({
64
+ description: "For list: filter by model id or display name, case-insensitive substring (e.g., \"claude\", \"deepseek\")",
65
+ })),
66
+ offset: Type.Optional(Type.Number({
67
+ description: "For list: model number to start from (1-indexed)"
68
+ })),
69
+ limit: Type.Optional(Type.Number({
70
+ description: "For list: maximum number of models to return"
71
+ })),
72
+ }),
73
+ renderCall(args, theme) {
74
+ let text = `${theme.bold(theme.fg("toolTitle", "model"))} ${theme.fg("accent", args.action)}`;
75
+
76
+ if (args.provider) text += theme.fg("muted", ` provider:${args.provider}`);
77
+ if (args.model) text += theme.fg("muted", ` model:${args.model}`);
78
+ if (args.offset !== undefined || args.limit !== undefined) text += theme.fg("warning", ` from:${args.offset ?? 1}`);
79
+ if (args.limit !== undefined) text += theme.fg("warning", ` to:${(args.offset ?? 1) + args.limit - 1}`);
80
+
81
+ return new Text(text, 0, 0);
82
+ },
83
+ renderResult(result, { expanded }, theme, context) {
84
+ const details = result.details as ModelToolDetails | undefined;
85
+
86
+ const container = new Container();
87
+ container.addChild(new Spacer(1));
88
+
89
+ if (context.isError || !details) {
90
+ const output = result.content
91
+ .filter((content) => content.type === "text")
92
+ .map((content) => content.text)
93
+ .join("\n");
94
+ container.addChild(new Text(theme.fg("error", output), 0, 0));
95
+
96
+ return container;
97
+ }
98
+
99
+ if (details.action === "current") {
100
+ const { model, thinkingLevel } = details.current;
101
+ container.addChild(new Text(theme.fg("muted", formatModel(model.provider, model.id, thinkingLevel)), 0, 0));
102
+
103
+ return container;
104
+ }
105
+
106
+ const filtered = context.args.provider !== undefined || context.args.model !== undefined;
107
+ const models = details.models ?? [];
108
+ const total = details.total ?? models.length;
109
+
110
+ if (expanded) {
111
+ models.forEach((model) => {
112
+ container.addChild(new Text(theme.fg("muted", formatModel(model.provider, model.id)), 0, 0));
113
+ });
114
+
115
+ container.addChild(new Spacer(1));
116
+ }
117
+
118
+ const summary = `${models.length !== total ? `${models.length} of ` : ""}${total} ${filtered ? "matched" : "available"} model${total === 1 ? "" : "s"} listed`;
119
+ container.addChild(new Text(theme.fg("muted", summary) + (details.truncation?.truncated ? theme.fg("warning", " (truncated)") : ""), 0, 0));
120
+
121
+ return container;
122
+ },
123
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
124
+ if (params.action === "current") {
125
+ const model = ctx.model;
126
+ if (!model) throw new Error("No model is currently selected.");
127
+
128
+ const thinkingLevel = pi.getThinkingLevel();
129
+ const current = { model: toMetadata(model), thinkingLevel };
130
+
131
+ return {
132
+ content: [{ type: "text", text: JSON.stringify(current) }],
133
+ details: { action: "current", current } satisfies ModelToolDetails,
134
+ };
135
+ }
136
+
137
+ const providerQuery = params.provider?.trim().toLowerCase();
138
+ const modelQuery = params.model?.trim().toLowerCase();
139
+ const filtered = providerQuery !== undefined || modelQuery !== undefined;
140
+
141
+ const matched = ctx.modelRegistry.getAvailable().filter((model) => {
142
+ if (providerQuery && !model.provider.toLowerCase().includes(providerQuery)) return false;
143
+ if (modelQuery && !model.id.toLowerCase().includes(modelQuery) && !model.name.toLowerCase().includes(modelQuery)) return false;
144
+ return true;
145
+ }).map(toMetadata);
146
+ const total = matched.length;
147
+
148
+ if (total === 0) {
149
+ return {
150
+ content: [{ type: "text", text: `0 models ${filtered ? "matched" : "available"}.` }],
151
+ details: { action: "list", models: [], total } satisfies ModelToolDetails,
152
+ };
153
+ }
154
+
155
+ // Convert from 1-indexed offset to 0-indexed array access.
156
+ const startIndex = params.offset ? Math.max(0, params.offset - 1) : 0;
157
+ if (startIndex >= total) {
158
+ throw new Error(`Offset ${params.offset} is beyond end of list (${total} models total).`);
159
+ }
160
+
161
+ const endIndex = params.limit !== undefined ? Math.min(startIndex + params.limit, total) : total;
162
+ const selected = matched.slice(startIndex, endIndex);
163
+
164
+ // JSONL: one compact object per line, so truncation cuts at record boundaries.
165
+ const truncation = truncateHead(selected.map((model) => JSON.stringify(model)).join("\n"), {
166
+ maxLines: DEFAULT_MAX_LINES,
167
+ maxBytes: DEFAULT_MAX_BYTES,
168
+ });
169
+
170
+ const delivered = truncation.truncated ? selected.slice(0, truncation.outputLines) : selected;
171
+ const endDisplay = startIndex + delivered.length;
172
+ const nextOffset = endDisplay + 1;
173
+
174
+ let text = truncation.content;
175
+ if (truncation.truncated) {
176
+ text += `\n\n[Truncated: showing models ${startIndex + 1}-${endDisplay} of ${total}; use offset=${nextOffset} to continue]`;
177
+ } else if (endDisplay < total) {
178
+ text += `\n\n[${total - endDisplay} more models in list; use offset=${nextOffset} to continue]`;
179
+ }
180
+
181
+ return {
182
+ content: [{ type: "text", text }],
183
+ details: { action: "list", models: delivered, total, truncation } satisfies ModelToolDetails,
184
+ };
185
+ },
186
+ });
187
+ });
188
+ }
@@ -1,6 +1,32 @@
1
+ import { ms } from "ms";
1
2
  import * as z from "zod";
2
3
 
3
- export const idleTimeoutSchema = z.number().min(5000);
4
+ import type { StringValue } from "ms";
5
+
6
+ const MIN_IDLE_MS = 5_000;
7
+
8
+ /** Accept a millisecond number or a human-readable duration (e.g., "3m"), normalized to milliseconds. */
9
+ export const idleTimeoutSchema = z
10
+ .union([z.number(), z.string()])
11
+ .transform((value, ctx) => {
12
+ if (typeof value === "number") return value;
13
+
14
+ let parsed: number;
15
+ try {
16
+ parsed = ms(value as StringValue);
17
+ } catch (error) {
18
+ ctx.addIssue({ code: "custom", message: error instanceof Error ? error.message : String(error) });
19
+ return z.NEVER;
20
+ }
21
+
22
+ if (Number.isNaN(parsed)) {
23
+ ctx.addIssue({ code: "custom", message: `Value is not a valid duration. value=${JSON.stringify(value)}` });
24
+ return z.NEVER;
25
+ }
26
+
27
+ return parsed;
28
+ })
29
+ .pipe(z.number().min(MIN_IDLE_MS));
4
30
 
5
31
  type IdleTimeout = z.infer<typeof idleTimeoutSchema>;
6
32
  type IdleHash = string | number | boolean;
@@ -1,18 +1,18 @@
1
1
  import * as z from "zod";
2
2
 
3
- import { codexUsageConfigSchema } from "../../codex-usage/config";
4
3
  import { editorConfigSchema } from "../../editor/config";
5
4
  import { footerConfigSchema } from "../../footer/config";
6
5
  import { fullscreenConfigSchema } from "../../fullscreen/config";
6
+ import { modelsConfigSchema } from "../../models/config";
7
7
  import { nameConfigSchema } from "../../name/config";
8
8
  import { presetsConfigSchema } from "../../presets/config";
9
9
  import { recapConfigSchema } from "../../recap/config";
10
10
 
11
11
  export const configSchemas = {
12
- codexUsage: codexUsageConfigSchema,
13
12
  editor: editorConfigSchema,
14
13
  footer: footerConfigSchema,
15
14
  fullscreen: fullscreenConfigSchema,
15
+ models: modelsConfigSchema,
16
16
  name: nameConfigSchema,
17
17
  presets: presetsConfigSchema,
18
18
  recap: recapConfigSchema,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-spark",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "A small, opinionated collection of pi extensions",
5
5
  "keywords": [
6
6
  "pi-coding-agent",
@@ -28,6 +28,7 @@
28
28
  "typecheck": "tsc --noEmit"
29
29
  },
30
30
  "dependencies": {
31
+ "ms": "4.0.0-nightly.202508271359",
31
32
  "zod": "^4.4.3"
32
33
  },
33
34
  "peerDependencies": {
@@ -1,69 +0,0 @@
1
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
-
3
- export const CODEX_PROVIDER = "openai-codex";
4
- const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
5
- const REQUEST_TIMEOUT_MS = 30_000;
6
-
7
- /** Normalized Codex usage with rate-limit windows reduced to used percentages. */
8
- export interface CodexUsage {
9
- unlimited: boolean;
10
- /** Used percentage of the 5-hour window, clamped to 0-100. */
11
- primaryPercent: number | undefined;
12
- /** Used percentage of the 7-day window, clamped to 0-100. */
13
- secondaryPercent: number | undefined;
14
- }
15
-
16
- interface CodexUsageResponse {
17
- rate_limit?: {
18
- primary_window?: CodexRateWindow | null;
19
- secondary_window?: CodexRateWindow | null;
20
- } | null;
21
- credits?: {
22
- unlimited?: boolean;
23
- } | null;
24
- }
25
-
26
- interface CodexRateWindow {
27
- used_percent?: number | string;
28
- }
29
-
30
- /** Read the ChatGPT account id from the stored Codex OAuth credential, if present. */
31
- export function readAccountId(ctx: ExtensionContext): string | undefined {
32
- const credential = ctx.modelRegistry.authStorage.get(CODEX_PROVIDER) as { accountId?: string } | undefined;
33
- const accountId = credential?.accountId;
34
-
35
- return typeof accountId === "string" && accountId.trim() ? accountId.trim() : undefined;
36
- }
37
-
38
- /** Fetch and normalize Codex usage, aborting on timeout or when the parent signal aborts. */
39
- export async function fetchCodexUsage(token: string, accountId?: string, parentSignal?: AbortSignal): Promise<CodexUsage> {
40
- const signals = [AbortSignal.timeout(REQUEST_TIMEOUT_MS)];
41
- if (parentSignal) signals.push(parentSignal);
42
-
43
- const headers: Record<string, string> = {
44
- Accept: "application/json",
45
- Authorization: `Bearer ${token}`,
46
- };
47
- if (accountId) headers["ChatGPT-Account-Id"] = accountId;
48
-
49
- const response = await fetch(USAGE_URL, { method: "GET", headers, signal: AbortSignal.any(signals) });
50
- if (!response.ok) throw new Error("request failed");
51
-
52
- return normalizeUsage((await response.json()) as CodexUsageResponse);
53
- }
54
-
55
- function normalizeUsage(payload: CodexUsageResponse): CodexUsage {
56
- return {
57
- unlimited: payload.credits?.unlimited === true,
58
- primaryPercent: parseUsedPercent(payload.rate_limit?.primary_window),
59
- secondaryPercent: parseUsedPercent(payload.rate_limit?.secondary_window),
60
- };
61
- }
62
-
63
- function parseUsedPercent(window: CodexRateWindow | null | undefined): number | undefined {
64
- const raw = window?.used_percent;
65
- if (raw === undefined || raw === null) return undefined;
66
-
67
- const value = typeof raw === "number" ? raw : Number(raw);
68
- return Number.isFinite(value) ? Math.min(100, Math.max(0, value)) : undefined;
69
- }
@@ -1,5 +0,0 @@
1
- import * as z from "zod";
2
-
3
- export const codexUsageConfigSchema = z.object({});
4
-
5
- export type CodexUsageConfig = z.infer<typeof codexUsageConfigSchema>;
@@ -1,51 +0,0 @@
1
- import { CodexUsageManager } from "./manager";
2
- import { loadConfig } from "../shared/config";
3
- import { isUsage } from "../shared/usage";
4
-
5
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
- import type { AgentMessage } from "@earendil-works/pi-agent-core";
7
-
8
- function hasCost(message: AgentMessage): boolean {
9
- const usage = (message as { usage?: unknown }).usage;
10
- if (!isUsage(usage)) return false;
11
-
12
- return usage.cost.total > 0 || usage.input > 0 || usage.output > 0;
13
- }
14
-
15
- export default function (pi: ExtensionAPI) {
16
- let codexUsageManager: CodexUsageManager | undefined = undefined;
17
-
18
- pi.on("session_start", (_event, ctx) => {
19
- if (!ctx.hasUI) return;
20
-
21
- const config = loadConfig(ctx, "codexUsage");
22
- if (!config) return;
23
-
24
- codexUsageManager = new CodexUsageManager();
25
- codexUsageManager.refresh(ctx);
26
- });
27
-
28
- pi.on("model_select", (_event, ctx) => {
29
- codexUsageManager?.refresh(ctx);
30
- });
31
-
32
- pi.on("turn_end", (event, ctx) => {
33
- if (!hasCost(event.message)) return;
34
-
35
- codexUsageManager?.refresh(ctx);
36
- });
37
-
38
- pi.on("session_compact", (_event, ctx) => {
39
- codexUsageManager?.refresh(ctx);
40
- });
41
-
42
- pi.on("session_tree", (event, ctx) => {
43
- if (!event.summaryEntry) return;
44
-
45
- codexUsageManager?.refresh(ctx);
46
- });
47
-
48
- pi.on("session_shutdown", () => {
49
- codexUsageManager = undefined;
50
- });
51
- }
@@ -1,47 +0,0 @@
1
- import { CODEX_PROVIDER, fetchCodexUsage, readAccountId } from "./client";
2
- import { renderError, renderUsage } from "./status";
3
-
4
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
-
6
- const STATUS_KEY = "codex-usage";
7
-
8
- export class CodexUsageManager {
9
- private inflight: Promise<void> | undefined = undefined;
10
-
11
- async refresh(ctx: ExtensionContext): Promise<void> {
12
- if (!this.isCodexContext(ctx)) {
13
- ctx.ui.setStatus(STATUS_KEY, undefined);
14
- return;
15
- }
16
-
17
- if (this.inflight) return this.inflight;
18
-
19
- this.inflight = this.fetch(ctx).finally(() => {
20
- this.inflight = undefined;
21
- });
22
-
23
- return this.inflight;
24
- }
25
-
26
- private async fetch(ctx: ExtensionContext): Promise<void> {
27
- try {
28
- const token = await ctx.modelRegistry.getApiKeyForProvider(CODEX_PROVIDER);
29
- if (!token) {
30
- ctx.ui.setStatus(STATUS_KEY, undefined);
31
- return;
32
- }
33
-
34
- const usage = await fetchCodexUsage(token, readAccountId(ctx), ctx.signal);
35
- if (!this.isCodexContext(ctx)) return;
36
-
37
- ctx.ui.setStatus(STATUS_KEY, renderUsage(ctx.ui.theme, usage));
38
- } catch (error) {
39
- const message = error instanceof Error ? error.message : String(error);
40
- ctx.ui.setStatus(STATUS_KEY, renderError(ctx.ui.theme, message));
41
- }
42
- }
43
-
44
- private isCodexContext(ctx: ExtensionContext): boolean {
45
- return ctx.model?.provider === CODEX_PROVIDER;
46
- }
47
- }
@@ -1,31 +0,0 @@
1
- import type { Theme } from "@earendil-works/pi-coding-agent";
2
-
3
- import type { CodexUsage } from "./client";
4
-
5
- const LABEL = "Codex";
6
-
7
- /** Render a footer status string for normalized Codex usage. */
8
- export function renderUsage(theme: Theme, usage: CodexUsage): string {
9
- const label = theme.fg("dim", LABEL);
10
-
11
- if (usage.unlimited || (usage.primaryPercent === undefined && usage.secondaryPercent === undefined)) {
12
- return `${label} ${theme.fg("success", "unlimited")}`;
13
- }
14
-
15
- const fiveHour = renderLane(theme, "5h", usage.primaryPercent);
16
- const sevenDay = renderLane(theme, "7d", usage.secondaryPercent);
17
- return `${label} ${fiveHour} ${sevenDay}`;
18
- }
19
-
20
- /** Render a footer status string for a usage fetch error. */
21
- export function renderError(theme: Theme, message: string): string {
22
- return theme.fg("error", `${LABEL} usage unavailable: ${message}`);
23
- }
24
-
25
- function renderLane(theme: Theme, label: string, percent?: number): string {
26
- const text = `${label} ${percent === undefined ? "?" : percent.toFixed(0)}%`;
27
-
28
- if (percent && percent > 90) return theme.fg("error", text);
29
- if (percent && percent > 70) return theme.fg("warning", text);
30
- return theme.fg("dim", text);
31
- }