pi-opencode-go-provider 1.0.23 → 1.0.25

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
@@ -21,6 +21,7 @@ _Go-optimized endpoints for lower latency — 14+ models for [pi](https://github
21
21
  - **Cost Tracking** with per-model pricing for budget management
22
22
  - **Reasoning Models** with thinking level maps for proper effort control
23
23
  - **Prompt-cache session affinity** — sends `x-opencode-session` and `x-opencode-client` so OpenCode Go can pin a session to the same cache node
24
+ - **Usage widget** — shows how much of the OpenCode Go 5h / 7d / 30d budgets you have spent, below the editor (footer status line outside the TUI)
24
25
 
25
26
  ## Installation
26
27
 
@@ -110,6 +111,54 @@ Then select "opencode-go" as the provider and choose from the available models.
110
111
 
111
112
  The default model for this provider is `kimi-k2.6` (matching pi core's built-in default); use `/model` to pick another.
112
113
 
114
+ ## Usage Widget
115
+
116
+ OpenCode Go meters the plan with three dollar budgets — a rolling 5-hour window,
117
+ a weekly window and a monthly window. The extension polls
118
+ `GET https://opencode.ai/zen/go/v1/usage` with your API key and shows how much of
119
+ each budget is **left**, below the editor. When no terminal UI is attached (print
120
+ or JSON mode) the same line goes to the footer status bar instead.
121
+
122
+ ```
123
+ Usage: 5h: 63% · 7d: 41% · 30d: 12% · 5h ↺ 2h14m · 7d ↺ 3d20h · 30d ↺ 20d0h
124
+ ```
125
+
126
+ The line matches the pi-better-openai usage line: the remaining percentages come
127
+ first, then a countdown per window. Countdowns only — three windows with three
128
+ wall-clock reset times run past the terminal width, and `/opencode-go-usage`
129
+ lists the exact local reset time for each. Colours track what is left — green,
130
+ amber at 30% or less, red at 10% or less or when a window reports
131
+ `rate-limited` — and the widget only appears while an `opencode-go` model is
132
+ selected. The Go API publishes no banked reset credits,
133
+ so that trailing segment appears only if a response ever carries a
134
+ `bankedResets` count.
135
+
136
+ ```
137
+ /opencode-go-usage # refresh and show the full breakdown
138
+ /opencode-go-usage refresh # same, but always re-reads the API
139
+ /opencode-go-usage off # hide the widget (persisted)
140
+ /opencode-go-usage on # show it again
141
+ /opencode-go-usage debug # config, last fetch/error, endpoint
142
+ ```
143
+
144
+ Polling runs every 60 seconds, plus after every turn and whenever the selected
145
+ model changes. Settings are read from `~/.pi/agent/opencode-go-provider.json`:
146
+
147
+ ```json
148
+ {
149
+ "usage": {
150
+ "enabled": true,
151
+ "refreshIntervalMs": 60000,
152
+ "showOnlyOnProvider": true,
153
+ "showResetTimes": true,
154
+ "placement": "belowEditor"
155
+ }
156
+ }
157
+ ```
158
+
159
+ `placement` accepts `belowEditor` (default) or `aboveEditor`. Every key is
160
+ optional; `/opencode-go-usage on|off` writes only `enabled`.
161
+
113
162
  ## Authentication
114
163
 
115
164
  The opencode-go API key can be configured in multiple ways. Credentials are resolved in this order:
@@ -126,7 +175,9 @@ The opencode-go API key can be configured in multiple ways. Credentials are reso
126
175
 
127
176
  | Variable | Required | Description |
128
177
  |----------|----------|-------------|
129
- | `OPENCODE_API_KEY` | No | Your opencode.ai API key (fallback if not in auth.json) |
178
+ | `OPENCODE_API_KEY` | No | Your opencode.ai API key (fallback if not in auth.json). Also used for usage requests |
179
+ | `OPENCODE_GO_USAGE` | No | Set to `off`/`false`/`0` to disable the usage widget without editing the config file |
180
+ | `OPENCODE_GO_USAGE_INTERVAL_MS` | No | Override the usage poll interval (clamped to 15s–10m) |
130
181
 
131
182
  ## Configuration
132
183
 
package/config.ts ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Persistent settings for the opencode-go provider extension.
3
+ *
4
+ * The file is deliberately tiny and optional: every field falls back to
5
+ * DEFAULT_USAGE_CONFIG, then an environment override wins. Written only by
6
+ * `/opencode-go-usage on|off` so the widget choice survives a restart.
7
+ */
8
+
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
12
+
13
+ export const PROVIDER_ID = "opencode-go";
14
+ export const CONFIG_BASENAME = "opencode-go-provider.json";
15
+ /** Shared key so the widget and the status fallback never collide with other extensions. */
16
+ export const USAGE_WIDGET_KEY = "opencode-go-usage";
17
+
18
+ export type UsagePlacement = "aboveEditor" | "belowEditor";
19
+
20
+ export interface UsageConfig {
21
+ /** Show the usage widget/status at all. */
22
+ enabled: boolean;
23
+ /** Poll interval while a session is open. */
24
+ refreshIntervalMs: number;
25
+ /** Only display usage while the selected model belongs to opencode-go. */
26
+ showOnlyOnProvider: boolean;
27
+ /** Include reset countdowns in the widget line. */
28
+ showResetTimes: boolean;
29
+ placement: UsagePlacement;
30
+ }
31
+
32
+ export const DEFAULT_USAGE_CONFIG: UsageConfig = {
33
+ enabled: true,
34
+ refreshIntervalMs: 60_000,
35
+ showOnlyOnProvider: true,
36
+ showResetTimes: true,
37
+ placement: "belowEditor",
38
+ };
39
+
40
+ export const MIN_USAGE_REFRESH_MS = 15_000;
41
+ export const MAX_USAGE_REFRESH_MS = 10 * 60_000;
42
+
43
+ const USAGE_PLACEMENTS: UsagePlacement[] = ["aboveEditor", "belowEditor"];
44
+ const DISABLED_VALUES = new Set(["0", "false", "off", "no", "disable", "disabled"]);
45
+ const ENABLED_VALUES = new Set(["1", "true", "on", "yes", "enable", "enabled"]);
46
+
47
+ export function configPath(): string {
48
+ return path.join(getAgentDir(), CONFIG_BASENAME);
49
+ }
50
+
51
+ export function clampRefreshInterval(milliseconds: number): number {
52
+ if (!Number.isFinite(milliseconds)) return DEFAULT_USAGE_CONFIG.refreshIntervalMs;
53
+ return Math.max(MIN_USAGE_REFRESH_MS, Math.min(MAX_USAGE_REFRESH_MS, Math.round(milliseconds)));
54
+ }
55
+
56
+ function parseFlag(value: string | undefined): boolean | undefined {
57
+ const normalized = value?.trim().toLowerCase();
58
+ if (!normalized) return undefined;
59
+ if (DISABLED_VALUES.has(normalized)) return false;
60
+ if (ENABLED_VALUES.has(normalized)) return true;
61
+ return undefined;
62
+ }
63
+
64
+ function readFileUsage(): Record<string, unknown> {
65
+ try {
66
+ const parsed: unknown = JSON.parse(fs.readFileSync(configPath(), "utf8"));
67
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
68
+ const usage = (parsed as Record<string, unknown>).usage;
69
+ if (usage && typeof usage === "object" && !Array.isArray(usage)) {
70
+ return usage as Record<string, unknown>;
71
+ }
72
+ }
73
+ } catch {
74
+ // Missing or unreadable config is the normal first-run state.
75
+ }
76
+ return {};
77
+ }
78
+
79
+ /**
80
+ * Resolve the effective usage config: defaults <- config file <- environment.
81
+ * Never throws; a broken config file degrades to defaults.
82
+ */
83
+ export function readUsageConfig(env: NodeJS.ProcessEnv = process.env): UsageConfig {
84
+ const config = { ...DEFAULT_USAGE_CONFIG };
85
+ const file = readFileUsage();
86
+
87
+ if (typeof file.enabled === "boolean") config.enabled = file.enabled;
88
+ if (typeof file.showOnlyOnProvider === "boolean") config.showOnlyOnProvider = file.showOnlyOnProvider;
89
+ if (typeof file.showResetTimes === "boolean") config.showResetTimes = file.showResetTimes;
90
+ if (typeof file.refreshIntervalMs === "number") {
91
+ config.refreshIntervalMs = clampRefreshInterval(file.refreshIntervalMs);
92
+ }
93
+ if (USAGE_PLACEMENTS.includes(file.placement as UsagePlacement)) {
94
+ config.placement = file.placement as UsagePlacement;
95
+ }
96
+
97
+ const envEnabled = parseFlag(env.OPENCODE_GO_USAGE);
98
+ if (envEnabled !== undefined) config.enabled = envEnabled;
99
+ const envInterval = env.OPENCODE_GO_USAGE_INTERVAL_MS
100
+ ? Number(env.OPENCODE_GO_USAGE_INTERVAL_MS)
101
+ : undefined;
102
+ if (envInterval !== undefined) config.refreshIntervalMs = clampRefreshInterval(envInterval);
103
+
104
+ return config;
105
+ }
106
+
107
+ /** Merge a patch into the config file, preserving unrelated keys. */
108
+ export function writeUsageConfig(patch: Partial<UsageConfig>): boolean {
109
+ try {
110
+ const file = configPath();
111
+ let document: Record<string, unknown> = {};
112
+ try {
113
+ const parsed: unknown = JSON.parse(fs.readFileSync(file, "utf8"));
114
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
115
+ document = parsed as Record<string, unknown>;
116
+ }
117
+ } catch {
118
+ document = {};
119
+ }
120
+ document.usage = { ...readFileUsage(), ...patch };
121
+ fs.mkdirSync(path.dirname(file), { recursive: true });
122
+ fs.writeFileSync(file, `${JSON.stringify(document, null, 2)}\n`);
123
+ return true;
124
+ } catch {
125
+ return false;
126
+ }
127
+ }
@@ -27,5 +27,34 @@
27
27
  "contextWindow": 500000,
28
28
  "maxTokens": 128000,
29
29
  "deprecatedAt": "2026-09-10T06:11:15.915Z"
30
+ },
31
+ "deepseek-flash": {
32
+ "id": "deepseek-flash",
33
+ "name": "DeepSeek V4.1 Flash",
34
+ "api": "openai-completions",
35
+ "baseUrl": "https://opencode.ai/zen/go/v1",
36
+ "reasoning": true,
37
+ "thinkingLevelMap": {
38
+ "off": null,
39
+ "minimal": null,
40
+ "low": "low",
41
+ "medium": null,
42
+ "high": "high",
43
+ "xhigh": null,
44
+ "max": "max"
45
+ },
46
+ "input": [
47
+ "text",
48
+ "image"
49
+ ],
50
+ "cost": {
51
+ "input": 0.15,
52
+ "output": 0.6,
53
+ "cacheRead": 0.003,
54
+ "cacheWrite": 0
55
+ },
56
+ "contextWindow": 1000000,
57
+ "maxTokens": 384000,
58
+ "deprecatedAt": "2026-09-11T02:00:25.277Z"
30
59
  }
31
60
  }
package/format.ts ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Terminal text helpers for the usage widget.
3
+ *
4
+ * Kept dependency-free (no @earendil-works/pi-tui import) so the usage logic
5
+ * stays unit-testable with a bare Node test run and the extension keeps its
6
+ * single devDependency.
7
+ */
8
+
9
+ const ANSI_PATTERN = "\u001B\\[[0-?]*[ -/]*[@-~]";
10
+ const ANSI_SPLIT_REGEXP = new RegExp("(" + ANSI_PATTERN + ")");
11
+ const ANSI_TEST_REGEXP = new RegExp(ANSI_PATTERN);
12
+
13
+ export function stripAnsi(value: string): string {
14
+ return value.replace(new RegExp(ANSI_PATTERN, "g"), "");
15
+ }
16
+
17
+ /** Approximate terminal cell width of a single code point. */
18
+ function charWidth(char: string): number {
19
+ const code = char.codePointAt(0) ?? 0;
20
+ if (code === 0) return 0;
21
+ // Combining marks and zero-width joiners occupy no cells.
22
+ if (code >= 0x0300 && code <= 0x036f) return 0;
23
+ if (code === 0x200d || code === 0xfe0f) return 0;
24
+ if (
25
+ (code >= 0x1100 && code <= 0x115f) ||
26
+ (code >= 0x2e80 && code <= 0xa4cf) ||
27
+ (code >= 0xac00 && code <= 0xd7a3) ||
28
+ (code >= 0xf900 && code <= 0xfaff) ||
29
+ (code >= 0xfe30 && code <= 0xfe6f) ||
30
+ (code >= 0xff00 && code <= 0xff60) ||
31
+ (code >= 0xffe0 && code <= 0xffe6) ||
32
+ (code >= 0x1f300 && code <= 0x1faff) ||
33
+ (code >= 0x20000 && code <= 0x3fffd)
34
+ ) {
35
+ return 2;
36
+ }
37
+ return 1;
38
+ }
39
+
40
+ /** Visible width of a string, ignoring ANSI escape sequences. */
41
+ export function visibleWidth(value: string): number {
42
+ let width = 0;
43
+ for (const char of stripAnsi(value)) width += charWidth(char);
44
+ return width;
45
+ }
46
+
47
+ /**
48
+ * Slice a possibly-colored string to a terminal width, appending an ellipsis.
49
+ * ANSI sequences before the cut are preserved and a reset is emitted so the
50
+ * ellipsis (and anything after it) keeps the caller's own styling.
51
+ */
52
+ export function truncateToWidth(value: string, width: number, ellipsis = "\u2026"): string {
53
+ if (width <= 0) return "";
54
+ if (visibleWidth(value) <= width) return value;
55
+ const budget = Math.max(0, width - visibleWidth(ellipsis));
56
+ const tokens = value.split(ANSI_SPLIT_REGEXP);
57
+ let result = "";
58
+ let used = 0;
59
+ let truncated = false;
60
+ for (const token of tokens) {
61
+ if (token === "") continue;
62
+ if (ANSI_TEST_REGEXP.test(token)) {
63
+ result += token;
64
+ continue;
65
+ }
66
+ for (const char of token) {
67
+ const cellWidth = charWidth(char);
68
+ if (used + cellWidth > budget) {
69
+ truncated = true;
70
+ break;
71
+ }
72
+ result += char;
73
+ used += cellWidth;
74
+ }
75
+ if (truncated) break;
76
+ }
77
+ const reset = ANSI_TEST_REGEXP.test(value) ? "\u001B[0m" : "";
78
+ return result + reset + ellipsis;
79
+ }
80
+
81
+ /** Collapse whitespace so a multi-segment line survives a one-line status bar. */
82
+ export function sanitizeStatusText(text: string): string {
83
+ return text.replace(/[ \r\n\t]+/g, " ").trim();
84
+ }
package/index.ts CHANGED
@@ -22,7 +22,17 @@
22
22
  * Then use /model to select from available models
23
23
  */
24
24
 
25
- import { getAgentDir, type ExtensionAPI, type ModelRegistry } from "@earendil-works/pi-coding-agent";
25
+ import {
26
+ getAgentDir,
27
+ type ExtensionAPI,
28
+ type ExtensionContext,
29
+ type ModelRegistry,
30
+ type ThemeColor,
31
+ } from "@earendil-works/pi-coding-agent";
32
+ import { USAGE_WIDGET_KEY, readUsageConfig, writeUsageConfig } from "./config.ts";
33
+ import { sanitizeStatusText, truncateToWidth } from "./format.ts";
34
+ import { usageSegments, type UsageSegment, type UsageSeverity } from "./usage.ts";
35
+ import { UsageController } from "./usage-controller.ts";
26
36
  import modelsData from "./models.json" with { type: "json" };
27
37
  import customModelsData from "./custom-models.json" with { type: "json" };
28
38
  import patchData from "./patch.json" with { type: "json" };
@@ -402,12 +412,105 @@ export default function (pi: ExtensionAPI) {
402
412
  })),
403
413
  });
404
414
 
415
+ // Usage widget (5h / 7d / 30d).
416
+ // opencode-go meters the Go plan with a rolling 5h window, a weekly window and
417
+ // a monthly window. GET /zen/go/v1/usage publishes all three. The widget below
418
+ // the editor mirrors pi-better-openai's usage widget; the footer status line is
419
+ // the fallback when no terminal UI is attached.
420
+
421
+ let usageConfig = readUsageConfig();
422
+ let usageWidgetInstalled = false;
423
+ const usageController = new UsageController(() => usageConfig, updateUsageWidget);
424
+
425
+ const USAGE_SEVERITY_COLORS: Record<UsageSeverity, ThemeColor> = {
426
+ ok: "success",
427
+ warning: "warning",
428
+ critical: "error",
429
+ muted: "dim",
430
+ };
431
+
432
+ function usageSegmentsFor(ctx: ExtensionContext): UsageSegment[] | undefined {
433
+ const snapshot = usageController.snapshot;
434
+ if (!snapshot || !usageConfig.enabled || !usageController.isEligible(ctx)) return undefined;
435
+ const segments = usageSegments(snapshot, { showResetTimes: usageConfig.showResetTimes });
436
+ if (usageController.isStale()) segments.push({ text: " · stale", severity: "warning" });
437
+ return segments;
438
+ }
439
+
440
+ function updateUsageWidget(ctx: ExtensionContext): void {
441
+ try {
442
+ const segments = usageSegmentsFor(ctx);
443
+ if (!segments) {
444
+ if (usageWidgetInstalled) {
445
+ ctx.ui.setWidget(USAGE_WIDGET_KEY, undefined);
446
+ ctx.ui.setStatus(USAGE_WIDGET_KEY, undefined);
447
+ usageWidgetInstalled = false;
448
+ }
449
+ return;
450
+ }
451
+ if (ctx.mode === "tui") {
452
+ ctx.ui.setStatus(USAGE_WIDGET_KEY, undefined);
453
+ ctx.ui.setWidget(
454
+ USAGE_WIDGET_KEY,
455
+ (_tui, theme) => ({
456
+ invalidate() {},
457
+ render(width: number): string[] {
458
+ // Segments are captured per install, keeping render() free of the
459
+ // extension context so a stale ctx can never be touched mid-draw.
460
+ const line = segments
461
+ .map((segment) => theme.fg(USAGE_SEVERITY_COLORS[segment.severity], segment.text))
462
+ .join("");
463
+ return [truncateToWidth(line, width, theme.fg("dim", "\u2026"))];
464
+ },
465
+ }),
466
+ { placement: usageConfig.placement },
467
+ );
468
+ } else {
469
+ ctx.ui.setWidget(USAGE_WIDGET_KEY, undefined);
470
+ ctx.ui.setStatus(USAGE_WIDGET_KEY, sanitizeStatusText(segments.map((s) => s.text).join("")));
471
+ }
472
+ usageWidgetInstalled = true;
473
+ } catch {
474
+ // A stale extension context can surface here; the next session re-installs.
475
+ }
476
+ }
477
+
478
+ pi.registerCommand("opencode-go-usage", {
479
+ description: "Show, refresh, or toggle the OpenCode Go 5h / 7d / 30d usage widget",
480
+ handler: async (args, ctx) => {
481
+ const action = args.trim().toLowerCase();
482
+ if (action === "on" || action === "off") {
483
+ const enabled = action === "on";
484
+ usageConfig = { ...usageConfig, enabled };
485
+ const persisted = writeUsageConfig({ enabled });
486
+ const suffix = persisted ? "" : " for this session (could not write the config file)";
487
+ if (enabled) {
488
+ usageController.start(ctx);
489
+ ctx.ui.notify(`OpenCode Go usage widget enabled${suffix}.`, "info");
490
+ } else {
491
+ usageController.stop();
492
+ usageController.clear();
493
+ updateUsageWidget(ctx);
494
+ ctx.ui.notify(`OpenCode Go usage widget disabled${suffix}.`, "info");
495
+ }
496
+ return;
497
+ }
498
+ if (action === "debug") {
499
+ ctx.ui.notify(usageController.formatDebug(ctx), "info");
500
+ return;
501
+ }
502
+ // No argument (and "refresh") always re-reads the API and reports the breakdown.
503
+ await usageController.refresh(ctx, { notify: true, force: true });
504
+ },
505
+ });
506
+
405
507
  pi.on("before_provider_headers", (event, ctx) => {
406
508
  if (ctx.model?.provider !== PROVIDER_ID) return;
407
509
  applyOpenCodeSessionHeaders(event.headers, ctx.sessionManager.getSessionId());
408
510
  });
409
511
 
410
512
  pi.on("session_start", async (_event, ctx) => {
513
+ usageController.start(ctx);
411
514
  revalidateAbort?.abort();
412
515
  revalidateAbort = new AbortController();
413
516
  const signal = revalidateAbort.signal;
@@ -438,7 +541,16 @@ export default function (pi: ExtensionAPI) {
438
541
  });
439
542
  });
440
543
 
544
+ pi.on("turn_end", (_event, ctx) => {
545
+ void usageController.refresh(ctx);
546
+ });
547
+
548
+ pi.on("model_select", (_event, ctx) => {
549
+ void usageController.refresh(ctx);
550
+ });
551
+
441
552
  pi.on("session_shutdown", () => {
442
553
  revalidateAbort?.abort();
554
+ usageController.shutdown();
443
555
  });
444
556
  }
package/models.json CHANGED
@@ -239,6 +239,34 @@
239
239
  "contextWindow": 262144,
240
240
  "maxTokens": 262144
241
241
  },
242
+ {
243
+ "id": "deepseek-v4.1-flash",
244
+ "name": "DeepSeek V4.1 Flash",
245
+ "api": "openai-completions",
246
+ "baseUrl": "https://opencode.ai/zen/go/v1",
247
+ "reasoning": true,
248
+ "thinkingLevelMap": {
249
+ "off": null,
250
+ "minimal": null,
251
+ "low": "low",
252
+ "medium": null,
253
+ "high": "high",
254
+ "xhigh": null,
255
+ "max": "max"
256
+ },
257
+ "input": [
258
+ "text",
259
+ "image"
260
+ ],
261
+ "cost": {
262
+ "input": 0.15,
263
+ "output": 0.6,
264
+ "cacheRead": 0.003,
265
+ "cacheWrite": 0
266
+ },
267
+ "contextWindow": 1000000,
268
+ "maxTokens": 384000
269
+ },
242
270
  {
243
271
  "id": "hy3",
244
272
  "name": "Hy3",
@@ -580,34 +608,6 @@
580
608
  "contextWindow": 1000000,
581
609
  "maxTokens": 131072
582
610
  },
583
- {
584
- "id": "deepseek-flash",
585
- "name": "DeepSeek V4.1 Flash",
586
- "api": "openai-completions",
587
- "baseUrl": "https://opencode.ai/zen/go/v1",
588
- "reasoning": true,
589
- "thinkingLevelMap": {
590
- "off": null,
591
- "minimal": null,
592
- "low": "low",
593
- "medium": null,
594
- "high": "high",
595
- "xhigh": null,
596
- "max": "max"
597
- },
598
- "input": [
599
- "text",
600
- "image"
601
- ],
602
- "cost": {
603
- "input": 0.15,
604
- "output": 0.6,
605
- "cacheRead": 0.003,
606
- "cacheWrite": 0
607
- },
608
- "contextWindow": 1000000,
609
- "maxTokens": 384000
610
- },
611
611
  {
612
612
  "id": "mimo-v2.5",
613
613
  "name": "MiMo V2.5",
package/package.json CHANGED
@@ -1,14 +1,16 @@
1
1
  {
2
2
  "name": "pi-opencode-go-provider",
3
- "version": "1.0.23",
3
+ "version": "1.0.25",
4
4
  "description": "Opencode Go provider extension for pi - Fast, efficient GLM, Kimi, and MiniMax models through the opencode.ai API",
5
5
  "type": "module",
6
6
  "main": "index.ts",
7
7
  "scripts": {
8
8
  "clean": "echo 'nothing to clean'",
9
9
  "build": "echo 'nothing to build'",
10
- "check": "echo 'nothing to check'",
11
- "update-models": "node scripts/update-models.js"
10
+ "check": "npm run typecheck && npm run test",
11
+ "update-models": "node scripts/update-models.js",
12
+ "test": "node --test",
13
+ "typecheck": "tsc --noEmit"
12
14
  },
13
15
  "keywords": [
14
16
  "pi",
@@ -30,6 +32,8 @@
30
32
  ]
31
33
  },
32
34
  "devDependencies": {
33
- "@earendil-works/pi-coding-agent": "0.85.1"
35
+ "@earendil-works/pi-coding-agent": "0.85.1",
36
+ "@types/node": "~22.19.0",
37
+ "typescript": "^6.0.3"
34
38
  }
35
39
  }
@@ -0,0 +1,35 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { sanitizeStatusText, stripAnsi, truncateToWidth, visibleWidth } from "../format.ts";
4
+
5
+ const RED = "";
6
+ const RESET = "";
7
+
8
+ test("measures visible width without ANSI escapes", () => {
9
+ assert.equal(visibleWidth("abc"), 3);
10
+ assert.equal(visibleWidth(`${RED}abc${RESET}`), 3);
11
+ assert.equal(visibleWidth(""), 0);
12
+ });
13
+
14
+ test("strips ANSI escapes", () => {
15
+ assert.equal(stripAnsi(`${RED}abc${RESET}`), "abc");
16
+ });
17
+
18
+ test("truncates to a width with an ellipsis", () => {
19
+ assert.equal(truncateToWidth("abcdef", 10), "abcdef");
20
+ assert.equal(truncateToWidth("abcdef", 4), "abc…");
21
+ assert.equal(truncateToWidth("abcdef", 0), "");
22
+ assert.equal(truncateToWidth("abcdef", 4, "..."), "a...");
23
+ });
24
+
25
+ test("truncation keeps colour codes and stays within the width", () => {
26
+ const colored = `${RED}abcdef${RESET}`;
27
+ const cut = truncateToWidth(colored, 4);
28
+ assert.equal(cut.includes(RED), true);
29
+ assert.equal(visibleWidth(cut), 4);
30
+ assert.equal(stripAnsi(cut), "abc…");
31
+ });
32
+
33
+ test("collapses whitespace for the status bar", () => {
34
+ assert.equal(sanitizeStatusText(" a \n b \t c "), "a b c");
35
+ });