rinco-pi-hud 1.0.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/LICENSE +21 -0
- package/NOTICE +18 -0
- package/README.md +204 -0
- package/docs/CONTRIBUTING.md +110 -0
- package/docs/README.zh-CN.md +204 -0
- package/docs/footer.md +139 -0
- package/extensions/hud/commands/settings.ts +765 -0
- package/extensions/hud/config/config-counts.ts +87 -0
- package/extensions/hud/config/config.ts +65 -0
- package/extensions/hud/config/file-io.ts +128 -0
- package/extensions/hud/config/normalize.ts +374 -0
- package/extensions/hud/config/persist.ts +277 -0
- package/extensions/hud/config/schema.ts +303 -0
- package/extensions/hud/footer/format.ts +279 -0
- package/extensions/hud/footer/index.ts +543 -0
- package/extensions/hud/footer/layout.ts +183 -0
- package/extensions/hud/footer/render-labels.ts +305 -0
- package/extensions/hud/index.ts +466 -0
- package/extensions/hud/segments/extension-status.ts +88 -0
- package/extensions/hud/segments/git.ts +384 -0
- package/extensions/hud/segments/mcp-status.ts +25 -0
- package/extensions/hud/segments/package-version.ts +651 -0
- package/extensions/hud/segments/project-state.ts +67 -0
- package/extensions/hud/segments/runtime-definitions.ts +695 -0
- package/extensions/hud/segments/runtime.ts +167 -0
- package/extensions/hud/segments/skill-activity.ts +152 -0
- package/extensions/hud/session/context.ts +19 -0
- package/extensions/hud/session/lifecycle.ts +60 -0
- package/extensions/hud/session/live-context.ts +75 -0
- package/extensions/hud/state/index.ts +67 -0
- package/extensions/hud/state/project-refresh.ts +104 -0
- package/extensions/hud/state/telemetry.ts +261 -0
- package/extensions/hud/telemetry/codex-usage/LICENSE +21 -0
- package/extensions/hud/telemetry/codex-usage/app-server-client.ts +231 -0
- package/extensions/hud/telemetry/codex-usage/format.ts +278 -0
- package/extensions/hud/telemetry/codex-usage/index.ts +462 -0
- package/extensions/hud/telemetry/codex-usage/normalize.ts +264 -0
- package/extensions/hud/telemetry/codex-usage/query.ts +207 -0
- package/extensions/hud/telemetry/codex-usage/safety.ts +27 -0
- package/extensions/hud/telemetry/codex-usage/types.ts +137 -0
- package/extensions/hud/telemetry/format.ts +434 -0
- package/extensions/hud/telemetry/token-switch.ts +114 -0
- package/extensions/hud/ui/icons.ts +239 -0
- package/extensions/hud/ui/style.ts +276 -0
- package/package.json +56 -0
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
import { homedir, hostname, userInfo } from "node:os";
|
|
2
|
+
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
3
|
+
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
5
|
+
import type {
|
|
6
|
+
ColorSource,
|
|
7
|
+
ColorSpec,
|
|
8
|
+
ContextStyle,
|
|
9
|
+
ContextThresholds,
|
|
10
|
+
GitBranchMaxLength,
|
|
11
|
+
PathDisplayMode,
|
|
12
|
+
} from "../config/config.js";
|
|
13
|
+
import type { GitCommitInfo, GitMetricsInfo } from "../segments/git.js";
|
|
14
|
+
import type { IconMode } from "../ui/icons.js";
|
|
15
|
+
import { resolveOsIcon, resolvePackageIcon, resolveRuntimeSymbol } from "../ui/icons.js";
|
|
16
|
+
import type { PackageVersionResult } from "../segments/package-version.js";
|
|
17
|
+
import type { RuntimeInfo } from "../segments/runtime.js";
|
|
18
|
+
import { renderStyleForSource } from "../ui/style.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Starship `git_commit` style — render a short hash, optionally with an
|
|
22
|
+
* exact-match tag. See https://starship.rs/config/#git-commit
|
|
23
|
+
*
|
|
24
|
+
* Visibility is decided by the caller; this helper only formats the data.
|
|
25
|
+
* `hashLength` is clamped to [4, 40] upstream.
|
|
26
|
+
*/
|
|
27
|
+
export function formatGitCommitSegment(
|
|
28
|
+
theme: Pick<Theme, "fg">,
|
|
29
|
+
commit: GitCommitInfo | undefined,
|
|
30
|
+
config: { hashLength: number; onlyDetached: boolean; showTag: boolean },
|
|
31
|
+
colorSource: ColorSource,
|
|
32
|
+
style: ColorSpec,
|
|
33
|
+
): string {
|
|
34
|
+
if (!commit?.oid) return "";
|
|
35
|
+
// Starship's only_detached hides the whole module when attached.
|
|
36
|
+
if (config.onlyDetached && !commit.detached) return "";
|
|
37
|
+
const hash = commit.oid.slice(0, config.hashLength);
|
|
38
|
+
const tag = config.showTag && commit.tag ? commit.tag : "";
|
|
39
|
+
if (!hash && !tag) return "";
|
|
40
|
+
const label = [hash, tag].filter(Boolean).join(" ");
|
|
41
|
+
return renderStyleForSource(theme, colorSource, style, label);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Starship `git_metrics` style — render `+added −deleted` line counts.
|
|
46
|
+
* See https://starship.rs/config/#git-metrics
|
|
47
|
+
*
|
|
48
|
+
* When `onlyNonzero` is true, each zero component is omitted independently
|
|
49
|
+
* and the whole segment hides at 0/0.
|
|
50
|
+
*/
|
|
51
|
+
export function formatGitMetricsSegment(
|
|
52
|
+
theme: Pick<Theme, "fg">,
|
|
53
|
+
metrics: GitMetricsInfo | null | undefined,
|
|
54
|
+
config: { onlyNonzero: boolean },
|
|
55
|
+
colorSource: ColorSource,
|
|
56
|
+
addedStyle: ColorSpec,
|
|
57
|
+
deletedStyle: ColorSpec,
|
|
58
|
+
): string {
|
|
59
|
+
if (!metrics) return "";
|
|
60
|
+
const showAdded = !config.onlyNonzero || metrics.added > 0;
|
|
61
|
+
const showDeleted = !config.onlyNonzero || metrics.deleted > 0;
|
|
62
|
+
if (!showAdded && !showDeleted) return "";
|
|
63
|
+
const parts: string[] = [];
|
|
64
|
+
if (showAdded) {
|
|
65
|
+
parts.push(renderStyleForSource(theme, colorSource, addedStyle, `+ ${metrics.added}`));
|
|
66
|
+
}
|
|
67
|
+
if (showDeleted) {
|
|
68
|
+
parts.push(renderStyleForSource(theme, colorSource, deletedStyle, `− ${metrics.deleted}`));
|
|
69
|
+
}
|
|
70
|
+
return parts.join(" ");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type UsageTotals = {
|
|
74
|
+
input: number;
|
|
75
|
+
output: number;
|
|
76
|
+
cacheRead: number;
|
|
77
|
+
cacheWrite: number;
|
|
78
|
+
latestCacheHitRate?: number;
|
|
79
|
+
cost: number;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export type ContextColorTier = "normal" | "warning" | "error";
|
|
83
|
+
|
|
84
|
+
type SessionEntry = {
|
|
85
|
+
type?: string;
|
|
86
|
+
id?: string | number;
|
|
87
|
+
timestamp?: string | number;
|
|
88
|
+
message?: {
|
|
89
|
+
role?: string;
|
|
90
|
+
usage?: AssistantMessage["usage"];
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
type UsageCacheEntry = {
|
|
95
|
+
key: string;
|
|
96
|
+
totals: UsageTotals;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
let usageTotalsCache: UsageCacheEntry | undefined;
|
|
100
|
+
let usageTotalsComputeCount = 0;
|
|
101
|
+
|
|
102
|
+
export function formatCount(value: number): string {
|
|
103
|
+
if (value < 1000) return value.toString();
|
|
104
|
+
if (value < 10_000) return `${(value / 1000).toFixed(1)}k`;
|
|
105
|
+
if (value < 1_000_000) return `${Math.round(value / 1000)}k`;
|
|
106
|
+
if (value < 10_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
|
107
|
+
return `${Math.round(value / 1_000_000)}M`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function formatProviderLabel(provider: string | undefined): string {
|
|
111
|
+
if (!provider) return "Unknown";
|
|
112
|
+
|
|
113
|
+
const known: Record<string, string> = {
|
|
114
|
+
anthropic: "Anthropic",
|
|
115
|
+
gemini: "Google",
|
|
116
|
+
google: "Google",
|
|
117
|
+
ollama: "Ollama",
|
|
118
|
+
openai: "OpenAI",
|
|
119
|
+
"openai-codex": "OpenAI",
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
known[provider] ?? provider.replace(/[-_]/g, " ").replace(/\b\w/g, (char) => char.toUpperCase())
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function calculateCacheHitRate(
|
|
128
|
+
input: number,
|
|
129
|
+
cacheRead: number,
|
|
130
|
+
cacheWrite: number,
|
|
131
|
+
): number | undefined {
|
|
132
|
+
const promptTokens = input + cacheRead + cacheWrite;
|
|
133
|
+
return promptTokens > 0 ? (cacheRead / promptTokens) * 100 : undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function entryIdentity(entry: SessionEntry | undefined): string {
|
|
137
|
+
if (!entry) return "";
|
|
138
|
+
const usage = entry.message?.usage;
|
|
139
|
+
const usageKey = usage
|
|
140
|
+
? `${usage.input ?? 0}:${usage.output ?? 0}:${usage.cacheRead ?? 0}:${usage.cacheWrite ?? 0}:${usage.cost?.total ?? 0}`
|
|
141
|
+
: "";
|
|
142
|
+
return `${entry.id ?? ""}|${entry.timestamp ?? ""}|${entry.type ?? ""}|${entry.message?.role ?? ""}|${usageKey}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function buildUsageFingerprint(entries: readonly SessionEntry[]): string {
|
|
146
|
+
const first = entries[0];
|
|
147
|
+
const last = entries[entries.length - 1];
|
|
148
|
+
return `${entries.length}\0${entryIdentity(first)}\0${entryIdentity(last)}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function computeUsageTotals(entries: readonly SessionEntry[]): UsageTotals {
|
|
152
|
+
usageTotalsComputeCount += 1;
|
|
153
|
+
let input = 0;
|
|
154
|
+
let output = 0;
|
|
155
|
+
let cacheRead = 0;
|
|
156
|
+
let cacheWrite = 0;
|
|
157
|
+
let latestCacheHitRate: number | undefined;
|
|
158
|
+
let cost = 0;
|
|
159
|
+
|
|
160
|
+
for (const entry of entries) {
|
|
161
|
+
if (entry.type !== "message" || entry.message?.role !== "assistant") continue;
|
|
162
|
+
const usage = entry.message.usage;
|
|
163
|
+
const entryInput = usage?.input ?? 0;
|
|
164
|
+
const entryCacheRead = usage?.cacheRead ?? 0;
|
|
165
|
+
const entryCacheWrite = usage?.cacheWrite ?? 0;
|
|
166
|
+
|
|
167
|
+
input += entryInput;
|
|
168
|
+
output += usage?.output ?? 0;
|
|
169
|
+
cacheRead += entryCacheRead;
|
|
170
|
+
cacheWrite += entryCacheWrite;
|
|
171
|
+
cost += usage?.cost?.total ?? 0;
|
|
172
|
+
latestCacheHitRate = calculateCacheHitRate(entryInput, entryCacheRead, entryCacheWrite);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return Object.freeze({ input, output, cacheRead, cacheWrite, latestCacheHitRate, cost });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function invalidateUsageTotalsCache(): void {
|
|
179
|
+
usageTotalsCache = undefined;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Test helper: number of full usage scans performed since process start / last reset. */
|
|
183
|
+
export function __usageTotalsComputeCount(): number {
|
|
184
|
+
return usageTotalsComputeCount;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Test helper: reset memoization counters/cache. */
|
|
188
|
+
export function __resetUsageTotalsCacheForTests(): void {
|
|
189
|
+
usageTotalsCache = undefined;
|
|
190
|
+
usageTotalsComputeCount = 0;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function getUsageTotals(ctx: ExtensionContext): UsageTotals {
|
|
194
|
+
const sessionManager = ctx.sessionManager as {
|
|
195
|
+
getEntries?: () => SessionEntry[];
|
|
196
|
+
getBranch: () => SessionEntry[];
|
|
197
|
+
};
|
|
198
|
+
const entries = sessionManager.getEntries?.() ?? sessionManager.getBranch();
|
|
199
|
+
const key = buildUsageFingerprint(entries);
|
|
200
|
+
if (usageTotalsCache?.key === key) return usageTotalsCache.totals;
|
|
201
|
+
|
|
202
|
+
const totals = computeUsageTotals(entries);
|
|
203
|
+
usageTotalsCache = { key, totals };
|
|
204
|
+
return totals;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function buildTokenLabel(totals: UsageTotals, cacheHitIcon = ""): string {
|
|
208
|
+
const parts: string[] = [];
|
|
209
|
+
if (totals.input) parts.push(`↑ ${formatCount(totals.input)}`);
|
|
210
|
+
if (totals.output) parts.push(`↓ ${formatCount(totals.output)}`);
|
|
211
|
+
|
|
212
|
+
const hasCacheTokens = totals.cacheRead > 0 || totals.cacheWrite > 0;
|
|
213
|
+
if (hasCacheTokens && totals.latestCacheHitRate !== undefined) {
|
|
214
|
+
const cacheHitRate = `${totals.latestCacheHitRate.toFixed(1)}%`;
|
|
215
|
+
parts.push(cacheHitIcon ? `${cacheHitIcon} ${cacheHitRate}` : cacheHitRate);
|
|
216
|
+
}
|
|
217
|
+
return parts.length > 0 ? parts.join(" ") : "↑ 0 ↓ 0";
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function buildCostLabel(totals: UsageTotals): string {
|
|
221
|
+
return `$ ${totals.cost.toFixed(3)}`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function buildSessionDurationLabel(startEpoch: number): string {
|
|
225
|
+
const totalSeconds = Math.max(0, Math.floor((Date.now() - startEpoch) / 1000));
|
|
226
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
227
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
228
|
+
const seconds = totalSeconds % 60;
|
|
229
|
+
if (hours > 0) return `${hours}h ${minutes}m`;
|
|
230
|
+
if (minutes > 0) return `${minutes}m ${seconds}s`;
|
|
231
|
+
return `${seconds}s`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function contextColorTier(
|
|
235
|
+
percent: number | null | undefined,
|
|
236
|
+
thresholds: ContextThresholds = { warning: 70, error: 90 },
|
|
237
|
+
): ContextColorTier {
|
|
238
|
+
if (percent === null || percent === undefined || !Number.isFinite(percent)) return "normal";
|
|
239
|
+
if (percent >= thresholds.error) return "error";
|
|
240
|
+
if (percent >= thresholds.warning) return "warning";
|
|
241
|
+
return "normal";
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function buildContextGauge(percent: number, width = 10, ascii = false): string {
|
|
245
|
+
const clamped = Math.max(0, Math.min(100, percent));
|
|
246
|
+
const filled = Math.round((clamped / 100) * width);
|
|
247
|
+
const on = ascii ? "#" : "█";
|
|
248
|
+
const off = ascii ? "-" : "░";
|
|
249
|
+
return `${on.repeat(filled)}${off.repeat(Math.max(0, width - filled))}`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function formatContextPercentLabel(
|
|
253
|
+
percent: number | null | undefined,
|
|
254
|
+
contextWindow: number | undefined,
|
|
255
|
+
): string {
|
|
256
|
+
if (!contextWindow || contextWindow <= 0) return "--";
|
|
257
|
+
const percentLabel =
|
|
258
|
+
percent === null || percent === undefined
|
|
259
|
+
? "?"
|
|
260
|
+
: `${Math.max(0, Math.min(999, Math.round(percent)))}%`;
|
|
261
|
+
return `${percentLabel}/${formatCount(contextWindow)}`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function buildContextDisplayLabel(options: {
|
|
265
|
+
percent: number | null | undefined;
|
|
266
|
+
contextWindow: number | undefined;
|
|
267
|
+
style?: ContextStyle;
|
|
268
|
+
asciiGauge?: boolean;
|
|
269
|
+
}): string {
|
|
270
|
+
const { percent, contextWindow, style = "text", asciiGauge = false } = options;
|
|
271
|
+
if (!contextWindow || contextWindow <= 0) return "--";
|
|
272
|
+
|
|
273
|
+
const text = formatContextPercentLabel(percent, contextWindow);
|
|
274
|
+
const numericPercent =
|
|
275
|
+
percent === null || percent === undefined || !Number.isFinite(percent)
|
|
276
|
+
? 0
|
|
277
|
+
: Math.max(0, Math.min(100, percent));
|
|
278
|
+
const gauge = buildContextGauge(numericPercent, 10, asciiGauge);
|
|
279
|
+
|
|
280
|
+
if (style === "gauge") return `[${gauge}]`;
|
|
281
|
+
if (style === "text+gauge") return `[${gauge}] ${text}`;
|
|
282
|
+
return text;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function buildContextLabel(ctx: ExtensionContext): string {
|
|
286
|
+
const usage = ctx.getContextUsage();
|
|
287
|
+
const contextWindow = ctx.model?.contextWindow ?? usage?.contextWindow;
|
|
288
|
+
return formatContextPercentLabel(usage?.percent, contextWindow);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function formatRuntimeSegment(
|
|
292
|
+
theme: Pick<Theme, "fg">,
|
|
293
|
+
runtime: RuntimeInfo | undefined,
|
|
294
|
+
prefixStyle: ColorSpec,
|
|
295
|
+
colorSource: ColorSource,
|
|
296
|
+
mode: IconMode = "auto",
|
|
297
|
+
): string {
|
|
298
|
+
if (!runtime) return "";
|
|
299
|
+
const symbol = resolveRuntimeSymbol(runtime.name, runtime.symbol, mode);
|
|
300
|
+
const label = runtime.version ? `${symbol} ${runtime.version}` : symbol;
|
|
301
|
+
return `${renderStyleForSource(theme, colorSource, prefixStyle, "via")} ${renderStyleForSource(theme, colorSource, runtime.style, label)}`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Render the package-version segment in Starship `is <glyph> <version>` shape.
|
|
306
|
+
*
|
|
307
|
+
* Distinct from the runtime segment: this surfaces the project's own
|
|
308
|
+
* manifest version (e.g. `package.json#version`), not the installed
|
|
309
|
+
* toolchain version. Glyph comes from the Starship Nerd Font preset
|
|
310
|
+
* (https://starship.rs/presets/nerd-font); default color `208` matches
|
|
311
|
+
* the Starship `package` module default
|
|
312
|
+
* (https://starship.rs/config/#package-version).
|
|
313
|
+
*/
|
|
314
|
+
export function formatPackageVersionSegment(
|
|
315
|
+
theme: Pick<Theme, "fg">,
|
|
316
|
+
pkg: PackageVersionResult | undefined,
|
|
317
|
+
colorSource: ColorSource,
|
|
318
|
+
mode: IconMode = "auto",
|
|
319
|
+
configuredIcon: string = "",
|
|
320
|
+
versionStyle: ColorSpec = "208",
|
|
321
|
+
): string {
|
|
322
|
+
if (!pkg) return "";
|
|
323
|
+
const icon = resolvePackageIcon(configuredIcon, mode);
|
|
324
|
+
const label = `${icon} ${pkg.version}`;
|
|
325
|
+
return `${renderStyleForSource(theme, colorSource, "", "is")} ${renderStyleForSource(theme, colorSource, versionStyle, label)}`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export type FormatCwdOptions = {
|
|
329
|
+
mode?: PathDisplayMode;
|
|
330
|
+
/** Trailing directory components to keep in full mode. 0 = unlimited. */
|
|
331
|
+
depth?: number;
|
|
332
|
+
home?: string;
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
function normalizeDisplayPath(cwd: string): string {
|
|
336
|
+
const withSlashes = cwd.replace(/\\/g, "/");
|
|
337
|
+
if (withSlashes === "/" || /^\/+$/.test(withSlashes)) return "/";
|
|
338
|
+
const stripped = withSlashes.replace(/\/+$/, "");
|
|
339
|
+
return stripped === "" ? withSlashes : stripped;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function toHomePath(path: string, home: string): string {
|
|
343
|
+
if (!home) return path;
|
|
344
|
+
const homeNorm = home.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
345
|
+
if (!homeNorm) return path;
|
|
346
|
+
if (path === homeNorm) return "~";
|
|
347
|
+
if (path.startsWith(`${homeNorm}/`)) return `~${path.slice(homeNorm.length)}`;
|
|
348
|
+
return path;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Starship-style: keep last `depth` components; prefix with `…/` when parents were dropped. */
|
|
352
|
+
function applyPathDepth(path: string, depth: number): string {
|
|
353
|
+
if (!Number.isFinite(depth) || depth <= 0) return path;
|
|
354
|
+
const limit = Math.floor(depth);
|
|
355
|
+
if (path === "~" || path === "/") return path;
|
|
356
|
+
|
|
357
|
+
let components: string[];
|
|
358
|
+
if (path.startsWith("~/")) {
|
|
359
|
+
components = path.slice(2).split("/").filter(Boolean);
|
|
360
|
+
} else if (/^[A-Za-z]:\//.test(path)) {
|
|
361
|
+
components = path.slice(3).split("/").filter(Boolean);
|
|
362
|
+
} else if (path.startsWith("/")) {
|
|
363
|
+
components = path.slice(1).split("/").filter(Boolean);
|
|
364
|
+
} else {
|
|
365
|
+
components = path.split("/").filter(Boolean);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (components.length <= limit) return path;
|
|
369
|
+
return `…/${components.slice(-limit).join("/")}`;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export function formatCwdLabel(cwd: string, cwdIcon: string, options?: FormatCwdOptions): string {
|
|
373
|
+
const mode = options?.mode ?? "basename";
|
|
374
|
+
const normalized = normalizeDisplayPath(cwd);
|
|
375
|
+
let pathText: string;
|
|
376
|
+
if (mode === "full") {
|
|
377
|
+
const home =
|
|
378
|
+
options?.home ??
|
|
379
|
+
(() => {
|
|
380
|
+
try {
|
|
381
|
+
return homedir();
|
|
382
|
+
} catch {
|
|
383
|
+
return "";
|
|
384
|
+
}
|
|
385
|
+
})();
|
|
386
|
+
pathText = applyPathDepth(toHomePath(normalized, home), options?.depth ?? 0);
|
|
387
|
+
} else if (normalized === "/") {
|
|
388
|
+
pathText = "/";
|
|
389
|
+
} else {
|
|
390
|
+
const parts = normalized.split("/").filter(Boolean);
|
|
391
|
+
pathText = parts[parts.length - 1] ?? cwd;
|
|
392
|
+
}
|
|
393
|
+
return cwdIcon ? `${cwdIcon} ${pathText}` : pathText;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function stripAnsi(text: string): string {
|
|
397
|
+
return text.replace(/\u001B\[[0-9;]*m/g, "");
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export function formatGitBranchText(
|
|
401
|
+
branch: string,
|
|
402
|
+
maxLength: GitBranchMaxLength = "full",
|
|
403
|
+
): string {
|
|
404
|
+
if (maxLength === "full" || visibleWidth(branch) <= maxLength) return branch;
|
|
405
|
+
return stripAnsi(truncateToWidth(branch, maxLength, "…"));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export function formatUsernameHostLabel(icon: string): string {
|
|
409
|
+
try {
|
|
410
|
+
const user = userInfo().username;
|
|
411
|
+
const host = hostname();
|
|
412
|
+
if (!user || !host) return "";
|
|
413
|
+
const label = `${user}@${host}`;
|
|
414
|
+
return icon ? `${icon} ${label}` : label;
|
|
415
|
+
} catch {
|
|
416
|
+
return "";
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export function formatTimeLabel(icon: string): string {
|
|
421
|
+
const now = new Date();
|
|
422
|
+
const hours = String(now.getHours()).padStart(2, "0");
|
|
423
|
+
const minutes = String(now.getMinutes()).padStart(2, "0");
|
|
424
|
+
const label = `${hours}:${minutes}`;
|
|
425
|
+
return icon ? `${icon} ${label}` : label;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export function formatOsLabel(
|
|
429
|
+
configuredIcon: string,
|
|
430
|
+
mode: IconMode = "auto",
|
|
431
|
+
platform: string = process.platform,
|
|
432
|
+
): string {
|
|
433
|
+
return resolveOsIcon(configuredIcon, mode, platform);
|
|
434
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
const TOKEN_SWITCH_PROVIDER_ID = "token-switch";
|
|
2
|
+
const TOKEN_SWITCH_SUBSCRIPTION_URL =
|
|
3
|
+
"https://neolink.com/backend/v1/dashboard/billing/subscription";
|
|
4
|
+
const TOKEN_SWITCH_USAGE_URL = "https://neolink.com/backend/v1/dashboard/billing/usage";
|
|
5
|
+
const MAX_RESPONSE_BODY_BYTES = 64 * 1024;
|
|
6
|
+
|
|
7
|
+
export type TokenSwitchModel = { provider: string };
|
|
8
|
+
|
|
9
|
+
type QueryTokenSwitchBalanceOptions = {
|
|
10
|
+
apiKey?: string;
|
|
11
|
+
fetchImpl?: typeof fetch;
|
|
12
|
+
timeoutMs: number;
|
|
13
|
+
signal?: AbortSignal;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function isTokenSwitchModel(
|
|
17
|
+
model: Pick<TokenSwitchModel, "provider"> | undefined,
|
|
18
|
+
): boolean {
|
|
19
|
+
return model?.provider === TOKEN_SWITCH_PROVIDER_ID;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function parseTokenSwitchBalance(
|
|
23
|
+
subscriptionPayload: unknown,
|
|
24
|
+
usagePayload: unknown,
|
|
25
|
+
): number {
|
|
26
|
+
if (
|
|
27
|
+
!subscriptionPayload ||
|
|
28
|
+
typeof subscriptionPayload !== "object" ||
|
|
29
|
+
Array.isArray(subscriptionPayload)
|
|
30
|
+
) {
|
|
31
|
+
throw new Error("Token Switch subscription response did not contain hard_limit_usd.");
|
|
32
|
+
}
|
|
33
|
+
const hardLimitUsd = (subscriptionPayload as Record<string, unknown>).hard_limit_usd;
|
|
34
|
+
if (typeof hardLimitUsd !== "number" || !Number.isFinite(hardLimitUsd) || hardLimitUsd < 0) {
|
|
35
|
+
throw new Error("Token Switch hard_limit_usd was not a non-negative number.");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!usagePayload || typeof usagePayload !== "object" || Array.isArray(usagePayload)) {
|
|
39
|
+
throw new Error("Token Switch usage response did not contain total_usage.");
|
|
40
|
+
}
|
|
41
|
+
const totalUsage = (usagePayload as Record<string, unknown>).total_usage;
|
|
42
|
+
if (typeof totalUsage !== "number" || !Number.isFinite(totalUsage) || totalUsage < 0) {
|
|
43
|
+
throw new Error("Token Switch total_usage was not a non-negative number.");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return hardLimitUsd - totalUsage / 100;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function formatTokenSwitchBalance(balance: number): string {
|
|
50
|
+
return `token-switch $${balance.toFixed(2)}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function queryTokenSwitchBalance({
|
|
54
|
+
apiKey = process.env.TOKEN_SWITCH_API_KEY,
|
|
55
|
+
fetchImpl = fetch,
|
|
56
|
+
timeoutMs,
|
|
57
|
+
signal,
|
|
58
|
+
}: QueryTokenSwitchBalanceOptions): Promise<number> {
|
|
59
|
+
if (!apiKey?.trim()) {
|
|
60
|
+
throw new Error("TOKEN_SWITCH_API_KEY is not configured.");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const controller = new AbortController();
|
|
64
|
+
const abortFromExternal = () => controller.abort();
|
|
65
|
+
if (signal?.aborted) controller.abort();
|
|
66
|
+
else signal?.addEventListener("abort", abortFromExternal, { once: true });
|
|
67
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
68
|
+
|
|
69
|
+
const fetchPayload = async (url: string, label: string): Promise<unknown> => {
|
|
70
|
+
const response = await fetchImpl(url, {
|
|
71
|
+
method: "GET",
|
|
72
|
+
headers: {
|
|
73
|
+
Accept: "application/json",
|
|
74
|
+
Authorization: `Bearer ${apiKey}`,
|
|
75
|
+
},
|
|
76
|
+
redirect: "error",
|
|
77
|
+
signal: controller.signal,
|
|
78
|
+
});
|
|
79
|
+
if (!response.ok) {
|
|
80
|
+
throw new Error(`Token Switch ${label} endpoint returned HTTP ${response.status}.`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const text = await response.text();
|
|
84
|
+
if (Buffer.byteLength(text, "utf8") > MAX_RESPONSE_BODY_BYTES) {
|
|
85
|
+
throw new Error(`Token Switch ${label} response exceeded the size limit.`);
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
return JSON.parse(text) as unknown;
|
|
89
|
+
} catch {
|
|
90
|
+
throw new Error(`Token Switch ${label} response was not valid JSON.`);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const subscriptionPayload = await fetchPayload(
|
|
96
|
+
TOKEN_SWITCH_SUBSCRIPTION_URL,
|
|
97
|
+
"subscription",
|
|
98
|
+
);
|
|
99
|
+
const usagePayload = await fetchPayload(TOKEN_SWITCH_USAGE_URL, "usage");
|
|
100
|
+
return parseTokenSwitchBalance(subscriptionPayload, usagePayload);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (controller.signal.aborted) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
signal?.aborted
|
|
105
|
+
? "Token Switch balance query was cancelled."
|
|
106
|
+
: `Timed out after ${Math.round(timeoutMs / 1000)}s while fetching Token Switch balance.`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
throw error;
|
|
110
|
+
} finally {
|
|
111
|
+
clearTimeout(timeout);
|
|
112
|
+
signal?.removeEventListener("abort", abortFromExternal);
|
|
113
|
+
}
|
|
114
|
+
}
|