@jameslovespancakes/pi-plus 1.0.0 → 1.0.1

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.
Files changed (40) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +190 -190
  3. package/config/pi-plus.example.json +60 -60
  4. package/config/skills/model-routing/SKILL.md +86 -86
  5. package/images/pi-plus.svg +10 -10
  6. package/package.json +67 -67
  7. package/server/board-server.mjs +641 -641
  8. package/server/package.json +17 -17
  9. package/src/core/accounts/registry.ts +93 -93
  10. package/src/core/anthropic/client-identity.ts +241 -241
  11. package/src/core/catalog/quality.ts +314 -314
  12. package/src/core/config.ts +169 -169
  13. package/src/core/env.ts +58 -58
  14. package/src/core/exec/process.ts +146 -146
  15. package/src/core/exec/ssh-config.ts +157 -157
  16. package/src/core/policy/policy.ts +183 -183
  17. package/src/core/quota/pool.ts +64 -64
  18. package/src/core/quota/usage-source.ts +289 -289
  19. package/src/core/store.ts +43 -43
  20. package/src/domains/agents/board-setup.ts +409 -409
  21. package/src/domains/agents/index.ts +462 -462
  22. package/src/domains/models/catalog-tool.ts +361 -361
  23. package/src/domains/models/index.ts +14 -14
  24. package/src/domains/models/policy-gate.ts +169 -169
  25. package/src/domains/models/provider-picker.ts +207 -207
  26. package/src/domains/remote/config-path.ts +41 -41
  27. package/src/domains/remote/index.ts +866 -866
  28. package/src/domains/remote/setup.ts +425 -425
  29. package/src/domains/setup/index.ts +220 -220
  30. package/src/domains/subscriptions/accounts.ts +242 -242
  31. package/src/domains/subscriptions/footer.ts +182 -182
  32. package/src/domains/subscriptions/index.ts +42 -42
  33. package/src/domains/subscriptions/provider.ts +219 -219
  34. package/src/domains/subscriptions/providers/anthropic.ts +149 -149
  35. package/src/domains/subscriptions/providers/codex.ts +148 -148
  36. package/src/domains/subscriptions/routing.ts +72 -72
  37. package/src/services/usage-service.ts +186 -186
  38. package/src/ui/format.ts +73 -73
  39. package/src/ui/usage-bars.ts +154 -154
  40. package/src/vendor/anthropic.ts +109 -109
@@ -1,72 +1,72 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { accountProvider, routableProviders, type RoutingMode } from "../../core/accounts/registry.ts";
3
-
4
- /**
5
- * `/routing [standard|optimal] [provider]`
6
- *
7
- * standard main account first, fall back only when exhausted
8
- * optimal balance across accounts by remaining quota and time to reset
9
- *
10
- * With no provider named, the mode is applied to every provider that supports
11
- * routing. With no mode, the current modes are reported.
12
- */
13
-
14
- const MODES: RoutingMode[] = ["standard", "optimal"];
15
-
16
- function isMode(value: string): value is RoutingMode {
17
- return (MODES as string[]).includes(value);
18
- }
19
-
20
- export function registerRoutingCommands(pi: ExtensionAPI): void {
21
- pi.registerCommand("routing", {
22
- description: "Account routing: standard (main first) or optimal (quota balanced)",
23
- getArgumentCompletions: (prefix) =>
24
- MODES.filter((mode) => mode.startsWith(prefix)).map((mode) => ({
25
- value: mode,
26
- label: mode === "optimal" ? "optimal: balance by remaining quota" : "standard: main account first",
27
- })),
28
- handler: async (args, ctx) => {
29
- const [first, second] = args.trim().toLowerCase().split(/\s+/).filter(Boolean);
30
- const providers = second ? [accountProvider(second)].filter((entry) => !!entry) : routableProviders();
31
-
32
- if (providers.length === 0) {
33
- ctx.ui.notify(
34
- second ? `“${second}” does not support account routing.` : "No providers support account routing.",
35
- "warning",
36
- );
37
- return;
38
- }
39
-
40
- // No mode given: report current state.
41
- if (!first) {
42
- const lines: string[] = [];
43
- for (const provider of providers) {
44
- try {
45
- const mode = await provider.routing!.get();
46
- lines.push(`${provider.label} (${provider.id}): ${mode}`, ` ${provider.routing!.describe(mode)}`);
47
- } catch (error) {
48
- lines.push(`${provider.label}: ${error instanceof Error ? error.message : String(error)}`);
49
- }
50
- }
51
- ctx.ui.notify(lines.join("\n"), "info");
52
- return;
53
- }
54
-
55
- if (!isMode(first)) {
56
- ctx.ui.notify(`Usage: /routing [standard|optimal] [provider]`, "warning");
57
- return;
58
- }
59
-
60
- const results: string[] = [];
61
- for (const provider of providers) {
62
- try {
63
- const mode = await provider.routing!.set(first);
64
- results.push(`${provider.label}: ${mode}`, ` ${provider.routing!.describe(mode)}`);
65
- } catch (error) {
66
- results.push(`${provider.label}: failed, ${error instanceof Error ? error.message : String(error)}`);
67
- }
68
- }
69
- ctx.ui.notify(results.join("\n"), "info");
70
- },
71
- });
72
- }
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { accountProvider, routableProviders, type RoutingMode } from "../../core/accounts/registry.ts";
3
+
4
+ /**
5
+ * `/routing [standard|optimal] [provider]`
6
+ *
7
+ * standard main account first, fall back only when exhausted
8
+ * optimal balance across accounts by remaining quota and time to reset
9
+ *
10
+ * With no provider named, the mode is applied to every provider that supports
11
+ * routing. With no mode, the current modes are reported.
12
+ */
13
+
14
+ const MODES: RoutingMode[] = ["standard", "optimal"];
15
+
16
+ function isMode(value: string): value is RoutingMode {
17
+ return (MODES as string[]).includes(value);
18
+ }
19
+
20
+ export function registerRoutingCommands(pi: ExtensionAPI): void {
21
+ pi.registerCommand("routing", {
22
+ description: "Account routing: standard (main first) or optimal (quota balanced)",
23
+ getArgumentCompletions: (prefix) =>
24
+ MODES.filter((mode) => mode.startsWith(prefix)).map((mode) => ({
25
+ value: mode,
26
+ label: mode === "optimal" ? "optimal: balance by remaining quota" : "standard: main account first",
27
+ })),
28
+ handler: async (args, ctx) => {
29
+ const [first, second] = args.trim().toLowerCase().split(/\s+/).filter(Boolean);
30
+ const providers = second ? [accountProvider(second)].filter((entry) => !!entry) : routableProviders();
31
+
32
+ if (providers.length === 0) {
33
+ ctx.ui.notify(
34
+ second ? `“${second}” does not support account routing.` : "No providers support account routing.",
35
+ "warning",
36
+ );
37
+ return;
38
+ }
39
+
40
+ // No mode given: report current state.
41
+ if (!first) {
42
+ const lines: string[] = [];
43
+ for (const provider of providers) {
44
+ try {
45
+ const mode = await provider.routing!.get();
46
+ lines.push(`${provider.label} (${provider.id}): ${mode}`, ` ${provider.routing!.describe(mode)}`);
47
+ } catch (error) {
48
+ lines.push(`${provider.label}: ${error instanceof Error ? error.message : String(error)}`);
49
+ }
50
+ }
51
+ ctx.ui.notify(lines.join("\n"), "info");
52
+ return;
53
+ }
54
+
55
+ if (!isMode(first)) {
56
+ ctx.ui.notify(`Usage: /routing [standard|optimal] [provider]`, "warning");
57
+ return;
58
+ }
59
+
60
+ const results: string[] = [];
61
+ for (const provider of providers) {
62
+ try {
63
+ const mode = await provider.routing!.set(first);
64
+ results.push(`${provider.label}: ${mode}`, ` ${provider.routing!.describe(mode)}`);
65
+ } catch (error) {
66
+ results.push(`${provider.label}: failed, ${error instanceof Error ? error.message : String(error)}`);
67
+ }
68
+ }
69
+ ctx.ui.notify(results.join("\n"), "info");
70
+ },
71
+ });
72
+ }
@@ -1,186 +1,186 @@
1
- import { agentPath, readJson, writeJson } from "../core/store.ts";
2
- import { isClaudeAccount, type UsageRow } from "../core/quota/pool.ts";
3
- import { fetchAll } from "../core/quota/usage-source.ts";
4
-
5
- /**
6
- * The single owner of subscription usage state.
7
- *
8
- * Previously the only poller lived inside the footer extension and was gated on
9
- * `ctx.hasUI`, so `list_models` silently read empty rows whenever the footer was
10
- * hidden or the session was headless (including every workflow subagent).
11
- * Consumers now call `ensureFresh()` for on-demand data and `subscribe()` for
12
- * push updates; only one poll is ever in flight regardless of consumer count.
13
- */
14
-
15
- export const REFRESH_MS = 5 * 60 * 1000;
16
- const MIN_INTERVAL_MS = 90_000;
17
- const BACKOFF_MS = 10 * 60 * 1000;
18
- const CACHE_MAX_AGE_MS = 60 * 60 * 1000;
19
-
20
- export interface UsageState {
21
- rows: UsageRow[];
22
- errors: string[];
23
- updatedAt?: number;
24
- loading: boolean;
25
- accounts: number;
26
- codexPlan?: string;
27
- /**
28
- * Last time each account group's quota was observed to drop. This is the only
29
- * available proxy for "recently used": providers expose remaining quota but
30
- * never report which account served a request.
31
- */
32
- lastUsedAt?: Record<string, number>;
33
- }
34
-
35
- const state: UsageState = { rows: [], errors: [], loading: true, accounts: 0, lastUsedAt: {} };
36
- const listeners = new Set<() => void>();
37
-
38
- let nextAllowedFetch = 0;
39
- let inFlight: Promise<void> | undefined;
40
- let timer: NodeJS.Timeout | undefined;
41
- let started = false;
42
-
43
- function cachePath(): string {
44
- return agentPath("usage-bar-cache.json");
45
- }
46
-
47
- /** Seed from the last session so a restart shows figures before the first fetch. */
48
- function loadCache(): void {
49
- const cached = readJson<UsageState | undefined>(cachePath(), undefined);
50
- if (!cached || !Array.isArray(cached.rows) || !cached.updatedAt) return;
51
- if (Date.now() - cached.updatedAt > CACHE_MAX_AGE_MS) return;
52
- state.rows = cached.rows.filter((row) => !row.group.startsWith("Claude pool ×"));
53
- state.accounts = cached.accounts ?? 0;
54
- state.codexPlan = cached.codexPlan;
55
- state.updatedAt = cached.updatedAt;
56
- state.lastUsedAt = cached.lastUsedAt ?? {};
57
- state.loading = false;
58
- }
59
-
60
- function saveCache(): void {
61
- writeJson(cachePath(), {
62
- rows: state.rows,
63
- accounts: state.accounts,
64
- codexPlan: state.codexPlan,
65
- updatedAt: state.updatedAt,
66
- lastUsedAt: state.lastUsedAt,
67
- });
68
- }
69
-
70
- loadCache();
71
-
72
- function emit(): void {
73
- for (const listener of listeners) {
74
- try {
75
- listener();
76
- } catch { /* a bad subscriber must not break the poll */ }
77
- }
78
- }
79
-
80
- /**
81
- * Stamps an account as recently used when its headline window falls. A rise
82
- * (quota reset) or an unchanged figure is not a usage signal.
83
- */
84
- function recordUsageDrops(fresh: UsageRow[]): void {
85
- const previous = new Map(
86
- state.rows.filter((row) => row.label === "5h").map((row) => [row.group, row.remaining]),
87
- );
88
- const stamps = { ...(state.lastUsedAt ?? {}) };
89
- for (const row of fresh) {
90
- if (row.label !== "5h") continue;
91
- const before = previous.get(row.group);
92
- if (before !== undefined && row.remaining < before - 0.01) stamps[row.group] = Date.now();
93
- }
94
- state.lastUsedAt = stamps;
95
- }
96
-
97
- export function usageState(): UsageState {
98
- return state;
99
- }
100
-
101
- /**
102
- * Account groups ordered by most recent observed use, capped at `limit`.
103
- * Accounts never seen in use fall back to alphabetical, so the list is stable.
104
- */
105
- export function recentAccounts(limit: number): string[] {
106
- const stamps = state.lastUsedAt ?? {};
107
- const groups = [...new Set(state.rows.filter(isClaudeAccount).map((row) => row.group))];
108
- return groups
109
- .sort((a, b) => (stamps[b] ?? 0) - (stamps[a] ?? 0) || a.localeCompare(b))
110
- .slice(0, limit);
111
- }
112
-
113
- /** Notified after every state change. Returns an unsubscribe function. */
114
- export function subscribe(listener: () => void): () => void {
115
- listeners.add(listener);
116
- return () => listeners.delete(listener);
117
- }
118
-
119
- /**
120
- * The endpoints are rate limited, so results are cached and refreshes are
121
- * throttled. Failures keep the previous figures on screen.
122
- */
123
- export async function refreshUsage(ctx: any, force = false): Promise<void> {
124
- if (inFlight) return inFlight;
125
- const now = Date.now();
126
- const dueAt = Math.max(nextAllowedFetch, (state.updatedAt ?? 0) + MIN_INTERVAL_MS);
127
- if (!force && state.updatedAt && now < dueAt) return;
128
-
129
- inFlight = (async () => {
130
- try {
131
- const result = await fetchAll(ctx);
132
- const rateLimited = result.errors.some((error) => error.includes("429"));
133
- recordUsageDrops(result.rows);
134
-
135
- // Per-account merge: groups that failed this cycle keep their last figures.
136
- const freshGroups = new Set(result.rows.map((row) => row.group));
137
- const retained = state.rows
138
- .filter((row) => !row.group.startsWith("Claude pool ×")
139
- && !freshGroups.has(row.group)
140
- && (!isClaudeAccount(row) || result.groups.includes(row.group)))
141
- .map((row) => ({ ...row, stale: true }));
142
-
143
- state.rows = [...result.rows, ...retained];
144
- state.accounts = result.groups.length;
145
- state.updatedAt = Date.now(); // poll time only; rows retain their own checkedAt
146
- state.errors = result.errors;
147
- if (result.codexPlan !== undefined) state.codexPlan = result.codexPlan;
148
- nextAllowedFetch = rateLimited ? Date.now() + BACKOFF_MS : 0;
149
- saveCache();
150
- } catch (error) {
151
- state.errors = [`Usage refresh failed: ${error instanceof Error ? error.message : String(error)}`];
152
- } finally {
153
- state.loading = false;
154
- }
155
- })().finally(() => {
156
- inFlight = undefined;
157
- emit();
158
- });
159
-
160
- return inFlight;
161
- }
162
-
163
- /**
164
- * Guarantees usable data for a caller that does not own the poll loop.
165
- * This is what makes `list_models` correct in headless sessions.
166
- */
167
- export async function ensureFresh(ctx: any): Promise<UsageState> {
168
- const stale = !state.updatedAt || Date.now() - state.updatedAt > REFRESH_MS;
169
- if (stale) await refreshUsage(ctx);
170
- return state;
171
- }
172
-
173
- /** Starts the shared interval. Safe to call from multiple domains. */
174
- export function startPolling(ctx: any): void {
175
- if (started) return;
176
- started = true;
177
- void refreshUsage(ctx);
178
- timer ??= setInterval(() => void refreshUsage(ctx), REFRESH_MS);
179
- timer.unref?.();
180
- }
181
-
182
- export function stopPolling(): void {
183
- if (timer) clearInterval(timer);
184
- timer = undefined;
185
- started = false;
186
- }
1
+ import { agentPath, readJson, writeJson } from "../core/store.ts";
2
+ import { isClaudeAccount, type UsageRow } from "../core/quota/pool.ts";
3
+ import { fetchAll } from "../core/quota/usage-source.ts";
4
+
5
+ /**
6
+ * The single owner of subscription usage state.
7
+ *
8
+ * Previously the only poller lived inside the footer extension and was gated on
9
+ * `ctx.hasUI`, so `list_models` silently read empty rows whenever the footer was
10
+ * hidden or the session was headless (including every workflow subagent).
11
+ * Consumers now call `ensureFresh()` for on-demand data and `subscribe()` for
12
+ * push updates; only one poll is ever in flight regardless of consumer count.
13
+ */
14
+
15
+ export const REFRESH_MS = 5 * 60 * 1000;
16
+ const MIN_INTERVAL_MS = 90_000;
17
+ const BACKOFF_MS = 10 * 60 * 1000;
18
+ const CACHE_MAX_AGE_MS = 60 * 60 * 1000;
19
+
20
+ export interface UsageState {
21
+ rows: UsageRow[];
22
+ errors: string[];
23
+ updatedAt?: number;
24
+ loading: boolean;
25
+ accounts: number;
26
+ codexPlan?: string;
27
+ /**
28
+ * Last time each account group's quota was observed to drop. This is the only
29
+ * available proxy for "recently used": providers expose remaining quota but
30
+ * never report which account served a request.
31
+ */
32
+ lastUsedAt?: Record<string, number>;
33
+ }
34
+
35
+ const state: UsageState = { rows: [], errors: [], loading: true, accounts: 0, lastUsedAt: {} };
36
+ const listeners = new Set<() => void>();
37
+
38
+ let nextAllowedFetch = 0;
39
+ let inFlight: Promise<void> | undefined;
40
+ let timer: NodeJS.Timeout | undefined;
41
+ let started = false;
42
+
43
+ function cachePath(): string {
44
+ return agentPath("usage-bar-cache.json");
45
+ }
46
+
47
+ /** Seed from the last session so a restart shows figures before the first fetch. */
48
+ function loadCache(): void {
49
+ const cached = readJson<UsageState | undefined>(cachePath(), undefined);
50
+ if (!cached || !Array.isArray(cached.rows) || !cached.updatedAt) return;
51
+ if (Date.now() - cached.updatedAt > CACHE_MAX_AGE_MS) return;
52
+ state.rows = cached.rows.filter((row) => !row.group.startsWith("Claude pool ×"));
53
+ state.accounts = cached.accounts ?? 0;
54
+ state.codexPlan = cached.codexPlan;
55
+ state.updatedAt = cached.updatedAt;
56
+ state.lastUsedAt = cached.lastUsedAt ?? {};
57
+ state.loading = false;
58
+ }
59
+
60
+ function saveCache(): void {
61
+ writeJson(cachePath(), {
62
+ rows: state.rows,
63
+ accounts: state.accounts,
64
+ codexPlan: state.codexPlan,
65
+ updatedAt: state.updatedAt,
66
+ lastUsedAt: state.lastUsedAt,
67
+ });
68
+ }
69
+
70
+ loadCache();
71
+
72
+ function emit(): void {
73
+ for (const listener of listeners) {
74
+ try {
75
+ listener();
76
+ } catch { /* a bad subscriber must not break the poll */ }
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Stamps an account as recently used when its headline window falls. A rise
82
+ * (quota reset) or an unchanged figure is not a usage signal.
83
+ */
84
+ function recordUsageDrops(fresh: UsageRow[]): void {
85
+ const previous = new Map(
86
+ state.rows.filter((row) => row.label === "5h").map((row) => [row.group, row.remaining]),
87
+ );
88
+ const stamps = { ...(state.lastUsedAt ?? {}) };
89
+ for (const row of fresh) {
90
+ if (row.label !== "5h") continue;
91
+ const before = previous.get(row.group);
92
+ if (before !== undefined && row.remaining < before - 0.01) stamps[row.group] = Date.now();
93
+ }
94
+ state.lastUsedAt = stamps;
95
+ }
96
+
97
+ export function usageState(): UsageState {
98
+ return state;
99
+ }
100
+
101
+ /**
102
+ * Account groups ordered by most recent observed use, capped at `limit`.
103
+ * Accounts never seen in use fall back to alphabetical, so the list is stable.
104
+ */
105
+ export function recentAccounts(limit: number): string[] {
106
+ const stamps = state.lastUsedAt ?? {};
107
+ const groups = [...new Set(state.rows.filter(isClaudeAccount).map((row) => row.group))];
108
+ return groups
109
+ .sort((a, b) => (stamps[b] ?? 0) - (stamps[a] ?? 0) || a.localeCompare(b))
110
+ .slice(0, limit);
111
+ }
112
+
113
+ /** Notified after every state change. Returns an unsubscribe function. */
114
+ export function subscribe(listener: () => void): () => void {
115
+ listeners.add(listener);
116
+ return () => listeners.delete(listener);
117
+ }
118
+
119
+ /**
120
+ * The endpoints are rate limited, so results are cached and refreshes are
121
+ * throttled. Failures keep the previous figures on screen.
122
+ */
123
+ export async function refreshUsage(ctx: any, force = false): Promise<void> {
124
+ if (inFlight) return inFlight;
125
+ const now = Date.now();
126
+ const dueAt = Math.max(nextAllowedFetch, (state.updatedAt ?? 0) + MIN_INTERVAL_MS);
127
+ if (!force && state.updatedAt && now < dueAt) return;
128
+
129
+ inFlight = (async () => {
130
+ try {
131
+ const result = await fetchAll(ctx);
132
+ const rateLimited = result.errors.some((error) => error.includes("429"));
133
+ recordUsageDrops(result.rows);
134
+
135
+ // Per-account merge: groups that failed this cycle keep their last figures.
136
+ const freshGroups = new Set(result.rows.map((row) => row.group));
137
+ const retained = state.rows
138
+ .filter((row) => !row.group.startsWith("Claude pool ×")
139
+ && !freshGroups.has(row.group)
140
+ && (!isClaudeAccount(row) || result.groups.includes(row.group)))
141
+ .map((row) => ({ ...row, stale: true }));
142
+
143
+ state.rows = [...result.rows, ...retained];
144
+ state.accounts = result.groups.length;
145
+ state.updatedAt = Date.now(); // poll time only; rows retain their own checkedAt
146
+ state.errors = result.errors;
147
+ if (result.codexPlan !== undefined) state.codexPlan = result.codexPlan;
148
+ nextAllowedFetch = rateLimited ? Date.now() + BACKOFF_MS : 0;
149
+ saveCache();
150
+ } catch (error) {
151
+ state.errors = [`Usage refresh failed: ${error instanceof Error ? error.message : String(error)}`];
152
+ } finally {
153
+ state.loading = false;
154
+ }
155
+ })().finally(() => {
156
+ inFlight = undefined;
157
+ emit();
158
+ });
159
+
160
+ return inFlight;
161
+ }
162
+
163
+ /**
164
+ * Guarantees usable data for a caller that does not own the poll loop.
165
+ * This is what makes `list_models` correct in headless sessions.
166
+ */
167
+ export async function ensureFresh(ctx: any): Promise<UsageState> {
168
+ const stale = !state.updatedAt || Date.now() - state.updatedAt > REFRESH_MS;
169
+ if (stale) await refreshUsage(ctx);
170
+ return state;
171
+ }
172
+
173
+ /** Starts the shared interval. Safe to call from multiple domains. */
174
+ export function startPolling(ctx: any): void {
175
+ if (started) return;
176
+ started = true;
177
+ void refreshUsage(ctx);
178
+ timer ??= setInterval(() => void refreshUsage(ctx), REFRESH_MS);
179
+ timer.unref?.();
180
+ }
181
+
182
+ export function stopPolling(): void {
183
+ if (timer) clearInterval(timer);
184
+ timer = undefined;
185
+ started = false;
186
+ }
package/src/ui/format.ts CHANGED
@@ -1,73 +1,73 @@
1
- /** Presentation helpers with no domain knowledge. */
2
-
3
- export function formatTokens(count: number): string {
4
- if (count < 1000) return String(count);
5
- if (count < 10_000) return `${(count / 1000).toFixed(1)}k`;
6
- if (count < 1_000_000) return `${Math.round(count / 1000)}k`;
7
- if (count < 10_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
8
- return `${Math.round(count / 1_000_000)}M`;
9
- }
10
-
11
- export function sanitize(text: string): string {
12
- return text.replace(/[\r\n\t]/g, " ").replace(/ +/g, " ").trim();
13
- }
14
-
15
- /** Keep long ids readable by trimming the middle rather than the tail. */
16
- export function fitId(id: string, width: number): string {
17
- if (id.length <= width) return id.padEnd(width);
18
- return `${id.slice(0, 12)}…${id.slice(id.length - (width - 13))}`;
19
- }
20
-
21
- export function formatShortReset(resetAt?: number): string {
22
- if (!resetAt) return "";
23
- const seconds = Math.max(0, Math.round((resetAt - Date.now()) / 1000));
24
- if (seconds < 3600) return `${Math.max(1, Math.round(seconds / 60))}m`;
25
- if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
26
- return `${Math.floor(seconds / 86400)}d`;
27
- }
28
-
29
- export function formatReset(resetAt?: number): string {
30
- if (!resetAt) return "";
31
- const seconds = Math.max(0, Math.round((resetAt - Date.now()) / 1000));
32
- if (seconds < 60) return "resets <1m";
33
- const days = Math.floor(seconds / 86400);
34
- const hours = Math.floor((seconds % 86400) / 3600);
35
- const minutes = Math.floor((seconds % 3600) / 60);
36
- if (days > 0) return `resets ${days}d ${hours}h`;
37
- if (hours > 0) return `resets ${hours}h ${minutes}m`;
38
- return `resets ${minutes}m`;
39
- }
40
-
41
- const TRUECOLOR = /truecolor|24bit/i.test(process.env.COLORTERM ?? "") || !!process.env.WT_SESSION;
42
-
43
- export function hasTruecolor(): boolean {
44
- return TRUECOLOR;
45
- }
46
-
47
- function hslToAnsi(hue: number, saturation: number, lightness: number): string {
48
- const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation;
49
- const secondary = chroma * (1 - Math.abs(((hue / 60) % 2) - 1));
50
- const match = lightness - chroma / 2;
51
- const [r, g, b] = hue < 60 ? [chroma, secondary, 0]
52
- : hue < 120 ? [secondary, chroma, 0]
53
- : hue < 180 ? [0, chroma, secondary]
54
- : hue < 240 ? [0, secondary, chroma]
55
- : hue < 300 ? [secondary, 0, chroma]
56
- : [chroma, 0, secondary];
57
- const to255 = (value: number) => Math.round((value + match) * 255);
58
- return `\u001b[38;2;${to255(r)};${to255(g)};${to255(b)}m`;
59
- }
60
-
61
- /** Smooth red (empty) to green (full) ramp for a 0-100 fullness value. */
62
- export function levelColor(remaining: number): (text: string) => string {
63
- const clamped = Math.min(100, Math.max(0, remaining));
64
- if (!TRUECOLOR) return (text: string) => text;
65
- const hue = 120 * Math.pow(clamped / 100, 1.35);
66
- const lightness = clamped <= 12 ? 0.58 : 0.48;
67
- const escape = hslToAnsi(hue, 0.85, lightness);
68
- return (text: string) => `${escape}${text}\u001b[39m`;
69
- }
70
-
71
- export function themeLevel(remaining: number): "error" | "warning" | "success" {
72
- return remaining <= 10 ? "error" : remaining <= 25 ? "warning" : "success";
73
- }
1
+ /** Presentation helpers with no domain knowledge. */
2
+
3
+ export function formatTokens(count: number): string {
4
+ if (count < 1000) return String(count);
5
+ if (count < 10_000) return `${(count / 1000).toFixed(1)}k`;
6
+ if (count < 1_000_000) return `${Math.round(count / 1000)}k`;
7
+ if (count < 10_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
8
+ return `${Math.round(count / 1_000_000)}M`;
9
+ }
10
+
11
+ export function sanitize(text: string): string {
12
+ return text.replace(/[\r\n\t]/g, " ").replace(/ +/g, " ").trim();
13
+ }
14
+
15
+ /** Keep long ids readable by trimming the middle rather than the tail. */
16
+ export function fitId(id: string, width: number): string {
17
+ if (id.length <= width) return id.padEnd(width);
18
+ return `${id.slice(0, 12)}…${id.slice(id.length - (width - 13))}`;
19
+ }
20
+
21
+ export function formatShortReset(resetAt?: number): string {
22
+ if (!resetAt) return "";
23
+ const seconds = Math.max(0, Math.round((resetAt - Date.now()) / 1000));
24
+ if (seconds < 3600) return `${Math.max(1, Math.round(seconds / 60))}m`;
25
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
26
+ return `${Math.floor(seconds / 86400)}d`;
27
+ }
28
+
29
+ export function formatReset(resetAt?: number): string {
30
+ if (!resetAt) return "";
31
+ const seconds = Math.max(0, Math.round((resetAt - Date.now()) / 1000));
32
+ if (seconds < 60) return "resets <1m";
33
+ const days = Math.floor(seconds / 86400);
34
+ const hours = Math.floor((seconds % 86400) / 3600);
35
+ const minutes = Math.floor((seconds % 3600) / 60);
36
+ if (days > 0) return `resets ${days}d ${hours}h`;
37
+ if (hours > 0) return `resets ${hours}h ${minutes}m`;
38
+ return `resets ${minutes}m`;
39
+ }
40
+
41
+ const TRUECOLOR = /truecolor|24bit/i.test(process.env.COLORTERM ?? "") || !!process.env.WT_SESSION;
42
+
43
+ export function hasTruecolor(): boolean {
44
+ return TRUECOLOR;
45
+ }
46
+
47
+ function hslToAnsi(hue: number, saturation: number, lightness: number): string {
48
+ const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation;
49
+ const secondary = chroma * (1 - Math.abs(((hue / 60) % 2) - 1));
50
+ const match = lightness - chroma / 2;
51
+ const [r, g, b] = hue < 60 ? [chroma, secondary, 0]
52
+ : hue < 120 ? [secondary, chroma, 0]
53
+ : hue < 180 ? [0, chroma, secondary]
54
+ : hue < 240 ? [0, secondary, chroma]
55
+ : hue < 300 ? [secondary, 0, chroma]
56
+ : [chroma, 0, secondary];
57
+ const to255 = (value: number) => Math.round((value + match) * 255);
58
+ return `\u001b[38;2;${to255(r)};${to255(g)};${to255(b)}m`;
59
+ }
60
+
61
+ /** Smooth red (empty) to green (full) ramp for a 0-100 fullness value. */
62
+ export function levelColor(remaining: number): (text: string) => string {
63
+ const clamped = Math.min(100, Math.max(0, remaining));
64
+ if (!TRUECOLOR) return (text: string) => text;
65
+ const hue = 120 * Math.pow(clamped / 100, 1.35);
66
+ const lightness = clamped <= 12 ? 0.58 : 0.48;
67
+ const escape = hslToAnsi(hue, 0.85, lightness);
68
+ return (text: string) => `${escape}${text}\u001b[39m`;
69
+ }
70
+
71
+ export function themeLevel(remaining: number): "error" | "warning" | "success" {
72
+ return remaining <= 10 ? "error" : remaining <= 25 ? "warning" : "success";
73
+ }