pi-editor-footer 0.1.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.
Files changed (46) hide show
  1. package/AGENTS.md +19 -0
  2. package/CHANGELOG.md +26 -0
  3. package/CONTEXT.md +29 -0
  4. package/README.md +84 -0
  5. package/docs/adr/0001-tracking-editor-for-skill-descriptions.md +18 -0
  6. package/docs/adr/0002-own-editor-slot-port-model-info-glow.md +18 -0
  7. package/docs/agents/domain.md +51 -0
  8. package/docs/agents/issue-tracker.md +45 -0
  9. package/docs/agents/triage-labels.md +15 -0
  10. package/docs/reference/pi-tui-internals.md +144 -0
  11. package/docs/specs/01-config.md +57 -0
  12. package/docs/specs/02-identity.md +20 -0
  13. package/docs/specs/03-border-telemetry.md +48 -0
  14. package/docs/specs/04-header.md +30 -0
  15. package/docs/specs/05-footer.md +30 -0
  16. package/docs/specs/06-git.md +36 -0
  17. package/docs/specs/07-runtime.md +28 -0
  18. package/docs/specs/theme-overview.md +116 -0
  19. package/package.json +16 -0
  20. package/src/config.ts +184 -0
  21. package/src/detail-render.ts +119 -0
  22. package/src/footer.ts +479 -0
  23. package/src/git.ts +170 -0
  24. package/src/header.ts +185 -0
  25. package/src/icons.ts +197 -0
  26. package/src/index.ts +607 -0
  27. package/src/model-info.ts +341 -0
  28. package/src/runtime.ts +318 -0
  29. package/src/state.ts +144 -0
  30. package/src/telemetry.ts +437 -0
  31. package/src/theme-settings.ts +461 -0
  32. package/src/tracking-editor.ts +352 -0
  33. package/src/utils-workspace.ts +48 -0
  34. package/src/utils.ts +388 -0
  35. package/src/window-presentation.ts +56 -0
  36. package/test/config.test.ts +146 -0
  37. package/test/detail-render.test.ts +202 -0
  38. package/test/footer.test.ts +86 -0
  39. package/test/git.test.ts +45 -0
  40. package/test/header.test.ts +169 -0
  41. package/test/icons.test.ts +24 -0
  42. package/test/runtime.test.ts +71 -0
  43. package/test/telemetry.test.ts +199 -0
  44. package/test/utils.test.ts +71 -0
  45. package/test/window-presentation.test.ts +73 -0
  46. package/tsconfig.json +13 -0
package/src/utils.ts ADDED
@@ -0,0 +1,388 @@
1
+ import { isAbsolute, relative, resolve, sep } from "node:path";
2
+ import {
3
+ truncateToWidth,
4
+ visibleWidth,
5
+ wrapTextWithAnsi,
6
+ } from "@earendil-works/pi-tui";
7
+
8
+ export { truncateToWidth, visibleWidth, wrapTextWithAnsi };
9
+
10
+ export type ThemeColor = string;
11
+ export type ThinkingLevel = string;
12
+
13
+ export interface Theme {
14
+ fg(style: string, s: string): string;
15
+ }
16
+
17
+ export function stripAnsi(text: string): string {
18
+ return text
19
+ .replace(/\x1b\[[0-9;]*m/g, "")
20
+ .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
21
+ .replace(/\x1b_[^\x07]*\x07/g, "");
22
+ }
23
+
24
+ export function formatCwd(cwd: string): string {
25
+ const home = process.env.HOME || process.env.USERPROFILE;
26
+ if (!home) return cwd;
27
+ const resolvedCwd = resolve(cwd);
28
+ const resolvedHome = resolve(home);
29
+ const rel = relative(resolvedHome, resolvedCwd);
30
+ const insideHome =
31
+ rel === "" ||
32
+ (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
33
+ if (!insideHome) return cwd;
34
+ return rel === "" ? "~" : `~${sep}${rel}`;
35
+ }
36
+
37
+ export function basenamePath(path: string): string {
38
+ return path.split(/[\\/]/).filter(Boolean).at(-1) ?? path;
39
+ }
40
+
41
+ export function truncateBranch(branch: string, maxLen: number): string {
42
+ if (branch.length <= maxLen) return branch;
43
+ if (maxLen <= 3) return "...".slice(0, maxLen);
44
+ return `${branch.slice(0, maxLen - 3)}...`;
45
+ }
46
+
47
+ export function truncatePath(path: string, maxLen: number): string {
48
+ if (path.length <= maxLen) return path;
49
+ if (maxLen <= 3) return "...".slice(0, maxLen);
50
+ const sepChar = path.includes("/") ? "/" : "\\";
51
+ const parts = path.split(/[\\/]/);
52
+ if (parts.length <= 2) return path.slice(0, maxLen - 3) + "...";
53
+ const tail: string[] = [];
54
+ let tailLen = 0;
55
+ for (let i = parts.length - 1; i >= 1; i--) {
56
+ const seg = parts[i]!;
57
+ if (tailLen + seg.length + 4 > maxLen) break;
58
+ tail.unshift(seg);
59
+ tailLen += seg.length + 1;
60
+ }
61
+ const head = parts[0]!;
62
+ const result = `${head}${sepChar}...${sepChar}${tail.join(sepChar)}`;
63
+ return result.length > maxLen ? result.slice(0, maxLen - 3) + "..." : result;
64
+ }
65
+
66
+ export function fmtTokens(n: number): string {
67
+ if (n < 1000) return n.toString();
68
+ if (n < 10_000) return `${(n / 1000).toFixed(1)}k`;
69
+ if (n < 1_000_000) return `${Math.round(n / 1000)}k`;
70
+ if (n < 10_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
71
+ return `${Math.round(n / 1_000_000)}M`;
72
+ }
73
+
74
+ export function formatDuration(ms: number): string {
75
+ const totalSeconds = Math.max(0, Math.floor(ms / 1000));
76
+ if (totalSeconds < 60) return `${totalSeconds}s`;
77
+ const s = totalSeconds % 60;
78
+ const totalMinutes = Math.floor(totalSeconds / 60);
79
+ if (totalMinutes < 60) return `${totalMinutes}m ${s}s`;
80
+ const m = totalMinutes % 60;
81
+ const h = Math.floor(totalMinutes / 60);
82
+ return `${h}h ${m}m ${s}s`;
83
+ }
84
+
85
+ export function formatModelLabel(
86
+ model: { provider?: string; id?: string } | null | undefined,
87
+ ): string {
88
+ if (!model?.id) return "no-model";
89
+ return model.provider ? `${model.provider}/${model.id}` : model.id;
90
+ }
91
+
92
+ export function formatProviderLabel(provider: string | undefined): string {
93
+ if (!provider) return "Unknown";
94
+ return provider.charAt(0).toUpperCase() + provider.slice(1);
95
+ }
96
+
97
+ export function alignRight(
98
+ left: string,
99
+ right: string,
100
+ width: number,
101
+ theme: Theme,
102
+ ): string {
103
+ const rightW = visibleWidth(right);
104
+ if (rightW > width) {
105
+ right = truncateToWidth(right, width, theme.fg("dim", "..."));
106
+ }
107
+ const leftW = visibleWidth(left);
108
+ const rightW2 = visibleWidth(right);
109
+ const pad = width - leftW - rightW2;
110
+ if (pad >= 1) {
111
+ return left + " ".repeat(pad) + right;
112
+ }
113
+ const availableForLeft = Math.max(0, width - rightW2 - 1);
114
+ const truncatedLeft =
115
+ availableForLeft > 0
116
+ ? truncateToWidth(left, availableForLeft, theme.fg("dim", "..."))
117
+ : "";
118
+ return truncatedLeft ? truncatedLeft + " " + right : right;
119
+ }
120
+
121
+ export type PrioritizedSegment = {
122
+ text: string;
123
+ priority: number;
124
+ compactText?: string;
125
+ truncate?: (text: string, maxWidth: number, ellipsis: string) => string;
126
+ };
127
+
128
+ export function fitSegmentsByPriority(
129
+ segs: readonly PrioritizedSegment[],
130
+ maxW: number,
131
+ ellipsis = "...",
132
+ ): string[] {
133
+ const items = segs.map((s) => ({
134
+ text: s.text,
135
+ compactText: s.compactText,
136
+ priority: s.priority,
137
+ truncate: s.truncate,
138
+ w: visibleWidth(s.text),
139
+ }));
140
+ const totalW = () => {
141
+ const active = items.filter((it) => it.text !== "");
142
+ return (
143
+ active.reduce((a, it) => a + it.w, 0) + Math.max(0, active.length - 1)
144
+ );
145
+ };
146
+ if (totalW() > maxW) {
147
+ for (const item of items) {
148
+ if (!item.compactText || visibleWidth(item.compactText) >= item.w)
149
+ continue;
150
+ item.text = item.compactText;
151
+ item.w = visibleWidth(item.text);
152
+ if (totalW() <= maxW) break;
153
+ }
154
+ }
155
+ while (totalW() > maxW) {
156
+ let target = -1;
157
+ for (let i = 0; i < items.length; i++) {
158
+ if (
159
+ items[i]!.text !== "" &&
160
+ (target === -1 || items[i]!.priority < items[target]!.priority)
161
+ ) {
162
+ target = i;
163
+ }
164
+ }
165
+ if (target === -1) break;
166
+ const others = items.filter(
167
+ (_, i) => i !== target && items[i]!.text !== "",
168
+ );
169
+ const otherW =
170
+ others.reduce((a, it) => a + it.w, 0) + Math.max(0, others.length - 1);
171
+ const avail = maxW - otherW - (others.length > 0 ? 1 : 0);
172
+ if (avail <= visibleWidth(ellipsis)) {
173
+ items[target]!.text = "";
174
+ items[target]!.w = 0;
175
+ } else if (avail < items[target]!.w) {
176
+ const truncate = items[target]!.truncate;
177
+ items[target]!.text = truncate
178
+ ? truncate(items[target]!.text, avail, ellipsis)
179
+ : truncateToWidth(items[target]!.text, avail, ellipsis);
180
+ items[target]!.w = visibleWidth(items[target]!.text);
181
+ } else {
182
+ break;
183
+ }
184
+ }
185
+ return items.filter((it) => it.text !== "").map((it) => it.text);
186
+ }
187
+
188
+ export function stressColor(value: number, warn = 70, danger = 90): ThemeColor {
189
+ if (value >= danger) return "error";
190
+ if (value >= warn) return "warning";
191
+ return "accent";
192
+ }
193
+
194
+ export function cacheHitColor(value: number): ThemeColor {
195
+ if (value < 30) return "error";
196
+ if (value < 70) return "warning";
197
+ return "success";
198
+ }
199
+
200
+ export function providerColor(provider: string): ThemeColor {
201
+ switch (provider.toLowerCase()) {
202
+ case "anthropic":
203
+ return "accent";
204
+ case "openai":
205
+ case "openai-codex":
206
+ return "success";
207
+ case "google":
208
+ case "google-vertex":
209
+ return "warning";
210
+ case "amazon-bedrock":
211
+ return "thinkingHigh";
212
+ case "github-copilot":
213
+ return "mdLink";
214
+ case "deepseek":
215
+ return "thinkingLow";
216
+ case "xai":
217
+ case "groq":
218
+ return "error";
219
+ default:
220
+ return "muted";
221
+ }
222
+ }
223
+
224
+ export function effortColor(
225
+ level: ThinkingLevel | string | undefined,
226
+ ): ThemeColor {
227
+ switch (level) {
228
+ case "minimal":
229
+ return "thinkingMinimal";
230
+ case "low":
231
+ return "thinkingLow";
232
+ case "medium":
233
+ return "thinkingMedium";
234
+ case "high":
235
+ return "thinkingHigh";
236
+ case "xhigh":
237
+ return "thinkingXhigh";
238
+ default:
239
+ return "thinkingMedium";
240
+ }
241
+ }
242
+
243
+ export function isEditorBorderLine(line: string): boolean {
244
+ const plain = stripAnsi(line);
245
+ if (/^─+$/.test(plain)) return true;
246
+ if (/^─*\s*[↑↓]\s+\d+\s+more\s*─*$/.test(plain)) return true;
247
+ return false;
248
+ }
249
+
250
+ export function findBottomBorderIndex(lines: string[]): number {
251
+ for (let i = lines.length - 1; i >= 1; i--) {
252
+ if (isEditorBorderLine(lines[i]!)) return i;
253
+ }
254
+ return Math.max(0, lines.length - 1);
255
+ }
256
+
257
+ export function padRight(text: string, width: number, ellipsis = ""): string {
258
+ const clipped = truncateToWidth(text, width, ellipsis);
259
+ return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
260
+ }
261
+
262
+ export function center(text: string, width: number): string {
263
+ if (width <= 0) return "";
264
+ const w = visibleWidth(text);
265
+ if (w >= width) return truncateToWidth(text, width, "...");
266
+ return `${" ".repeat(Math.floor((width - w) / 2))}${text}`;
267
+ }
268
+
269
+ export function sanitizeStatus(text: string): string {
270
+ return stripAnsi(text)
271
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
272
+ .replace(/ +/g, " ")
273
+ .trim();
274
+ }
275
+
276
+ export function formatThinkingLabel(level: string): string {
277
+ if (level === "off") return "thinking off";
278
+ return `${level} effort`;
279
+ }
280
+
281
+ export const PI_BUILTIN_SLASH_COMMAND_NAMES = [
282
+ "settings",
283
+ "model",
284
+ "scoped-models",
285
+ "export",
286
+ "import",
287
+ "share",
288
+ "copy",
289
+ "name",
290
+ "session",
291
+ "changelog",
292
+ "hotkeys",
293
+ "fork",
294
+ "clone",
295
+ "tree",
296
+ "trust",
297
+ "login",
298
+ "logout",
299
+ "new",
300
+ "compact",
301
+ "resume",
302
+ "reload",
303
+ "quit",
304
+ ] as const;
305
+
306
+ export function collectPiCommandNames(
307
+ sessionCommands: readonly { name: string }[],
308
+ ): string[] {
309
+ const names = new Set<string>(PI_BUILTIN_SLASH_COMMAND_NAMES);
310
+ for (const command of sessionCommands) {
311
+ if (command.name) names.add(command.name);
312
+ }
313
+ return [...names];
314
+ }
315
+
316
+ export function pickSlashCommandTips(
317
+ availableNames: readonly string[],
318
+ options: {
319
+ fixed?: readonly string[];
320
+ count?: number;
321
+ exclude?: readonly string[];
322
+ random?: () => number;
323
+ } = {},
324
+ ): string[] {
325
+ const fixed = [...(options.fixed ?? [])];
326
+ const count = options.count ?? 3;
327
+ const exclude = new Set<string>([...(options.exclude ?? []), ...fixed]);
328
+ const random = options.random ?? Math.random;
329
+
330
+ const pool = [
331
+ ...new Set(availableNames.map((n) => n.trim()).filter(Boolean)),
332
+ ].filter((name) => !exclude.has(name));
333
+
334
+ for (let i = pool.length - 1; i > 0; i--) {
335
+ const j = Math.floor(random() * (i + 1));
336
+ const tmp = pool[i]!;
337
+ pool[i] = pool[j]!;
338
+ pool[j] = tmp;
339
+ }
340
+
341
+ const picked = pool.slice(0, Math.max(0, count));
342
+ return [...fixed, ...picked].map((name) =>
343
+ name.startsWith("/") ? name : `/${name}`,
344
+ );
345
+ }
346
+
347
+ export const MIN_LEFT_WIDTH = 28;
348
+ export const MIN_TIPS_WIDTH = 16;
349
+ export const MAX_TIPS_WIDTH = 28;
350
+ const COLUMN_GAP = 3;
351
+
352
+ export function headerColumnWidths(
353
+ innerWidth: number,
354
+ minTipsWidth = MIN_TIPS_WIDTH,
355
+ maxTipsWidth = MAX_TIPS_WIDTH,
356
+ minLeftWidth = MIN_LEFT_WIDTH,
357
+ ): { leftWidth: number; rightWidth: number; useTips: boolean } {
358
+ if (innerWidth <= 0) {
359
+ return { leftWidth: 0, rightWidth: 0, useTips: false };
360
+ }
361
+
362
+ const gap = COLUMN_GAP;
363
+ if (innerWidth < minLeftWidth + gap + minTipsWidth) {
364
+ return { leftWidth: innerWidth, rightWidth: 0, useTips: false };
365
+ }
366
+
367
+ let rightWidth = Math.min(
368
+ maxTipsWidth,
369
+ Math.max(minTipsWidth, Math.round(innerWidth * 0.28)),
370
+ );
371
+ let leftWidth = innerWidth - gap - rightWidth;
372
+
373
+ if (leftWidth < minLeftWidth) {
374
+ leftWidth = minLeftWidth;
375
+ rightWidth = innerWidth - gap - leftWidth;
376
+ }
377
+
378
+ if (leftWidth <= rightWidth) {
379
+ leftWidth = Math.ceil((innerWidth - gap) * 0.65);
380
+ rightWidth = innerWidth - gap - leftWidth;
381
+ }
382
+
383
+ if (rightWidth < minTipsWidth || leftWidth < minLeftWidth) {
384
+ return { leftWidth: innerWidth, rightWidth: 0, useTips: false };
385
+ }
386
+
387
+ return { leftWidth, rightWidth, useTips: true };
388
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Bordered presentation of the detail window.
3
+ *
4
+ * Pure module: takes `renderDetail`'s `[header, ...description]` lines and a
5
+ * theme-like styler, and returns the bordered, styled box:
6
+ *
7
+ * ┌──────────────────────────────┐
8
+ * │ name · kind │ ← header, theme highlight (accent+bold)
9
+ * │ description line 1 │ ← description, theme dim
10
+ * │ description line 2 │
11
+ * └──────────────────────────────┘
12
+ *
13
+ * Input `lines` must already be wrapped to (width - 4) columns (renderDetail's
14
+ * wrap width); this module adds the two border columns and two border rows.
15
+ */
16
+ import { visibleWidth } from "@earendil-works/pi-tui";
17
+
18
+ /** Styling functions injected from the live theme. */
19
+ export interface WindowThemeLike {
20
+ /** Border color function (theme "border"). */
21
+ border(s: string): string;
22
+ /** Header style — the theme's highlight (accent + bold). */
23
+ highlight(s: string): string;
24
+ /** Description style — the theme's dim. */
25
+ dim(s: string): string;
26
+ }
27
+
28
+ /**
29
+ * Wrap `[header, ...body]` in a bordered box. Returns `[]` for empty input
30
+ * (window hidden). Row layout: one `┌─…─┐` border row, the highlighted header
31
+ * row, the dim body rows, and a `└─…─┘` border row. Every row is exactly
32
+ * `width` columns (ANSI-aware padding).
33
+ */
34
+ export function decorateWindow(
35
+ lines: string[],
36
+ width: number,
37
+ theme: WindowThemeLike,
38
+ ): string[] {
39
+ if (lines.length === 0) {
40
+ return [];
41
+ }
42
+ const innerWidth = Math.max(1, width - 4);
43
+ const padTo = (s: string): string =>
44
+ s + " ".repeat(Math.max(0, innerWidth - visibleWidth(s)));
45
+
46
+ const header = lines[0] ?? "";
47
+ const body = lines.slice(1);
48
+
49
+ const borderRun = theme.border("─".repeat(Math.max(0, width - 2)));
50
+ return [
51
+ `┌${borderRun}┐`,
52
+ `│ ${padTo(theme.highlight(header))} │`,
53
+ ...body.map((line) => `│ ${padTo(theme.dim(line))} │`),
54
+ `└${borderRun}┘`,
55
+ ];
56
+ }
@@ -0,0 +1,146 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, test, beforeEach, afterEach } from "node:test";
3
+ import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ import { DEFAULT_CONFIG, getConfigPath, loadConfig, saveConfig } from "../src/config.js";
8
+ import { formatCwd, basenamePath, truncatePath, displayCwd } from "../src/utils-workspace.js";
9
+
10
+ describe("DEFAULT_CONFIG", () => {
11
+ test("has expected defaults", () => {
12
+ assert.equal(DEFAULT_CONFIG.enabled, true);
13
+ assert.equal(DEFAULT_CONFIG.workspaceDisplay, "path");
14
+ assert.equal(DEFAULT_CONFIG.cursorStyle, "block");
15
+ assert.equal(DEFAULT_CONFIG.icons.mode, "auto");
16
+ assert.equal(DEFAULT_CONFIG.telemetry.enabled, true);
17
+ assert.equal(DEFAULT_CONFIG.footerSegments.cwd, true);
18
+ });
19
+ });
20
+
21
+ describe("getConfigPath", () => {
22
+ test("returns homedir based path", () => {
23
+ const p = getConfigPath();
24
+ assert.ok(p.endsWith(".pi/agent/pi-skill-desc.json"), p);
25
+ });
26
+ });
27
+
28
+ describe("loadConfig / saveConfig with temp HOME", () => {
29
+ let origHome: string | undefined;
30
+ let origUserProfile: string | undefined;
31
+ let tmpHome: string;
32
+
33
+ beforeEach(() => {
34
+ origHome = process.env.HOME;
35
+ origUserProfile = process.env.USERPROFILE;
36
+ tmpHome = mkdtempSync(join(tmpdir(), "pi-skill-desc-test-"));
37
+ process.env.HOME = tmpHome;
38
+ process.env.USERPROFILE = tmpHome;
39
+ });
40
+
41
+ afterEach(() => {
42
+ if (origHome === undefined) delete process.env.HOME;
43
+ else process.env.HOME = origHome;
44
+ if (origUserProfile === undefined) delete process.env.USERPROFILE;
45
+ else process.env.USERPROFILE = origUserProfile;
46
+ try { rmSync(tmpHome, { recursive: true, force: true }); } catch {}
47
+ });
48
+
49
+ test("loadConfig returns defaults when no file exists", () => {
50
+ const cfg = loadConfig();
51
+ assert.deepEqual(cfg, DEFAULT_CONFIG);
52
+ });
53
+
54
+ test("saveConfig merges and persists", () => {
55
+ const saved = saveConfig({ workspaceDisplay: "name" } as any);
56
+ assert.equal(saved.workspaceDisplay, "name");
57
+ // reload should persist
58
+ const reloaded = loadConfig();
59
+ assert.equal(reloaded.workspaceDisplay, "name");
60
+ assert.equal(reloaded.cursorStyle, DEFAULT_CONFIG.cursorStyle);
61
+ });
62
+
63
+ test("malformed JSON falls back to defaults with warning", () => {
64
+ const cfgPath = getConfigPath();
65
+ mkdirSync(join(cfgPath, ".."), { recursive: true });
66
+ writeFileSync(cfgPath, "{ not valid json", "utf8");
67
+ const cfg = loadConfig();
68
+ assert.deepEqual(cfg, DEFAULT_CONFIG);
69
+ });
70
+
71
+ test("invalid enum values fallback to defaults", () => {
72
+ const cfgPath = getConfigPath();
73
+ mkdirSync(join(cfgPath, ".."), { recursive: true });
74
+ writeFileSync(cfgPath, JSON.stringify({ workspaceDisplay: "invalid", cursorStyle: "bad", icons: { mode: "nope" } }), "utf8");
75
+ const cfg = loadConfig();
76
+ assert.equal(cfg.workspaceDisplay, DEFAULT_CONFIG.workspaceDisplay);
77
+ assert.equal(cfg.cursorStyle, DEFAULT_CONFIG.cursorStyle);
78
+ assert.equal(cfg.icons.mode, DEFAULT_CONFIG.icons.mode);
79
+ });
80
+
81
+ test("saveConfig validates enums", () => {
82
+ const saved = saveConfig({ workspaceDisplay: "invalid" as any, cursorStyle: "bar" });
83
+ assert.equal(saved.workspaceDisplay, DEFAULT_CONFIG.workspaceDisplay);
84
+ assert.equal(saved.cursorStyle, "bar");
85
+ });
86
+
87
+ test("deep merge preserves nested defaults", () => {
88
+ const cfgPath = getConfigPath();
89
+ mkdirSync(join(cfgPath, ".."), { recursive: true });
90
+ writeFileSync(cfgPath, JSON.stringify({ telemetry: { tps: false } }), "utf8");
91
+ const cfg = loadConfig();
92
+ assert.equal(cfg.telemetry.tps, false);
93
+ assert.equal(cfg.telemetry.ttft, true); // preserved
94
+ assert.equal(cfg.telemetry.duration, true);
95
+ });
96
+ });
97
+
98
+ describe("utils-workspace helpers", () => {
99
+ const origHome = process.env.HOME;
100
+ beforeEach(() => {
101
+ // Use a stable HOME for these tests
102
+ process.env.HOME = "/home/testuser";
103
+ });
104
+ afterEach(() => {
105
+ if (origHome === undefined) delete process.env.HOME;
106
+ else process.env.HOME = origHome;
107
+ });
108
+
109
+ test("formatCwd: inside HOME becomes ~", () => {
110
+ // HOME is /home/testuser, cwd is /home/testuser/projects/foo
111
+ const formatted = formatCwd("/home/testuser/projects/foo");
112
+ assert.ok(formatted.startsWith("~"), formatted);
113
+ });
114
+
115
+ test("formatCwd: outside HOME stays absolute", () => {
116
+ const formatted = formatCwd("/tmp/other");
117
+ assert.equal(formatted, "/tmp/other");
118
+ });
119
+
120
+ test("basenamePath", () => {
121
+ assert.equal(basenamePath("/foo/bar/baz"), "baz");
122
+ assert.equal(basenamePath("~/projects/my-workspace"), "my-workspace");
123
+ assert.equal(basenamePath("single"), "single");
124
+ });
125
+
126
+ test("truncatePath: short path unchanged", () => {
127
+ assert.equal(truncatePath("~/a/b", 20), "~/a/b");
128
+ });
129
+
130
+ test("truncatePath: long path truncated with ...", () => {
131
+ const long = "~/a/b/c/d/e/f/g/h";
132
+ const truncated = truncatePath(long, 8);
133
+ assert.ok(truncated.includes("..."), truncated);
134
+ assert.ok(truncated.length <= 8, `len ${truncated.length} <= 8`);
135
+ });
136
+
137
+ test("displayCwd: path vs name mode", () => {
138
+ const cwd = "/home/testuser/projects/my-workspace";
139
+ const asPath = displayCwd(cwd, "path");
140
+ const asName = displayCwd(cwd, "name");
141
+ assert.ok(asPath.includes("my-workspace"), asPath);
142
+ assert.equal(asName, "my-workspace");
143
+ // name mode should be basename of formatted path
144
+ assert.equal(asName, basenamePath(formatCwd(cwd)));
145
+ });
146
+ });