gsd-pi 2.36.0 → 2.37.0-dev.68605cd

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 (71) hide show
  1. package/dist/resources/extensions/cmux/index.js +321 -0
  2. package/dist/resources/extensions/cmux/package.json +7 -0
  3. package/dist/resources/extensions/gsd/auto-dashboard.js +334 -104
  4. package/dist/resources/extensions/gsd/auto-loop.js +29 -4
  5. package/dist/resources/extensions/gsd/auto.js +58 -5
  6. package/dist/resources/extensions/gsd/commands-cmux.js +120 -0
  7. package/dist/resources/extensions/gsd/commands-prefs-wizard.js +1 -1
  8. package/dist/resources/extensions/gsd/commands.js +131 -34
  9. package/dist/resources/extensions/gsd/docs/preferences-reference.md +25 -0
  10. package/dist/resources/extensions/gsd/git-service.js +9 -1
  11. package/dist/resources/extensions/gsd/history.js +2 -1
  12. package/dist/resources/extensions/gsd/index.js +5 -0
  13. package/dist/resources/extensions/gsd/metrics.js +4 -2
  14. package/dist/resources/extensions/gsd/notifications.js +10 -1
  15. package/dist/resources/extensions/gsd/preferences-types.js +2 -0
  16. package/dist/resources/extensions/gsd/preferences-validation.js +29 -0
  17. package/dist/resources/extensions/gsd/preferences.js +3 -0
  18. package/dist/resources/extensions/gsd/prompts/research-milestone.md +4 -3
  19. package/dist/resources/extensions/gsd/prompts/research-slice.md +3 -2
  20. package/dist/resources/extensions/gsd/session-lock.js +26 -6
  21. package/dist/resources/extensions/gsd/templates/preferences.md +6 -0
  22. package/dist/resources/extensions/search-the-web/native-search.js +45 -4
  23. package/dist/resources/extensions/shared/format-utils.js +5 -41
  24. package/dist/resources/extensions/shared/layout-utils.js +46 -0
  25. package/dist/resources/extensions/shared/mod.js +2 -1
  26. package/dist/resources/extensions/shared/terminal.js +5 -0
  27. package/dist/resources/extensions/subagent/index.js +180 -60
  28. package/package.json +1 -1
  29. package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts.map +1 -1
  30. package/packages/pi-coding-agent/dist/core/extensions/loader.js +8 -4
  31. package/packages/pi-coding-agent/dist/core/extensions/loader.js.map +1 -1
  32. package/packages/pi-coding-agent/package.json +1 -1
  33. package/packages/pi-coding-agent/src/core/extensions/loader.ts +8 -4
  34. package/packages/pi-tui/dist/terminal-image.d.ts.map +1 -1
  35. package/packages/pi-tui/dist/terminal-image.js +4 -0
  36. package/packages/pi-tui/dist/terminal-image.js.map +1 -1
  37. package/packages/pi-tui/src/terminal-image.ts +5 -0
  38. package/pkg/package.json +1 -1
  39. package/src/resources/extensions/cmux/index.ts +384 -0
  40. package/src/resources/extensions/cmux/package.json +7 -0
  41. package/src/resources/extensions/gsd/auto-dashboard.ts +363 -116
  42. package/src/resources/extensions/gsd/auto-loop.ts +66 -6
  43. package/src/resources/extensions/gsd/auto.ts +77 -5
  44. package/src/resources/extensions/gsd/commands-cmux.ts +143 -0
  45. package/src/resources/extensions/gsd/commands-prefs-wizard.ts +1 -1
  46. package/src/resources/extensions/gsd/commands.ts +139 -32
  47. package/src/resources/extensions/gsd/docs/preferences-reference.md +25 -0
  48. package/src/resources/extensions/gsd/git-service.ts +12 -1
  49. package/src/resources/extensions/gsd/history.ts +2 -1
  50. package/src/resources/extensions/gsd/index.ts +8 -0
  51. package/src/resources/extensions/gsd/metrics.ts +4 -2
  52. package/src/resources/extensions/gsd/notifications.ts +10 -1
  53. package/src/resources/extensions/gsd/preferences-types.ts +13 -0
  54. package/src/resources/extensions/gsd/preferences-validation.ts +26 -0
  55. package/src/resources/extensions/gsd/preferences.ts +4 -0
  56. package/src/resources/extensions/gsd/prompts/research-milestone.md +4 -3
  57. package/src/resources/extensions/gsd/prompts/research-slice.md +3 -2
  58. package/src/resources/extensions/gsd/session-lock.ts +41 -6
  59. package/src/resources/extensions/gsd/templates/preferences.md +6 -0
  60. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +39 -1
  61. package/src/resources/extensions/gsd/tests/auto-worktree.test.ts +19 -0
  62. package/src/resources/extensions/gsd/tests/cmux.test.ts +122 -0
  63. package/src/resources/extensions/gsd/tests/preferences.test.ts +23 -0
  64. package/src/resources/extensions/gsd/tests/session-lock-regression.test.ts +45 -0
  65. package/src/resources/extensions/search-the-web/native-search.ts +50 -4
  66. package/src/resources/extensions/shared/format-utils.ts +5 -44
  67. package/src/resources/extensions/shared/layout-utils.ts +49 -0
  68. package/src/resources/extensions/shared/mod.ts +7 -4
  69. package/src/resources/extensions/shared/terminal.ts +5 -0
  70. package/src/resources/extensions/shared/tests/format-utils.test.ts +5 -3
  71. package/src/resources/extensions/subagent/index.ts +236 -79
@@ -14,6 +14,7 @@ import {
14
14
  type AgentEndEvent,
15
15
  type LoopDeps,
16
16
  } from "../auto-loop.js";
17
+ import type { SessionLockStatus } from "../session-lock.js";
17
18
 
18
19
  // ─── Helpers ─────────────────────────────────────────────────────────────────
19
20
 
@@ -317,6 +318,8 @@ function makeMockDeps(
317
318
  },
318
319
  clearUnitTimeout: () => {},
319
320
  updateProgressWidget: () => {},
321
+ syncCmuxSidebar: () => {},
322
+ logCmuxEvent: () => {},
320
323
  invalidateAllCaches: () => {
321
324
  callLog.push("invalidateAllCaches");
322
325
  },
@@ -339,7 +342,7 @@ function makeMockDeps(
339
342
  preDispatchHealthGate: async () => ({ proceed: true, fixesApplied: [] }),
340
343
  syncProjectRootToWorktree: () => {},
341
344
  checkResourcesStale: () => null,
342
- validateSessionLock: () => true,
345
+ validateSessionLock: () => ({ valid: true } as SessionLockStatus),
343
346
  updateSessionLock: () => {
344
347
  callLog.push("updateSessionLock");
345
348
  },
@@ -530,6 +533,41 @@ test("autoLoop exits on terminal complete state", async (t) => {
530
533
  );
531
534
  });
532
535
 
536
+ test("autoLoop passes structured session-lock failure details to the handler", async () => {
537
+ _resetPendingResolve();
538
+
539
+ const ctx = makeMockCtx();
540
+ ctx.ui.setStatus = () => {};
541
+ const pi = makeMockPi();
542
+ const s = makeLoopSession();
543
+ let observedLockStatus: SessionLockStatus | undefined;
544
+
545
+ const deps = makeMockDeps({
546
+ validateSessionLock: () =>
547
+ ({
548
+ valid: false,
549
+ failureReason: "compromised",
550
+ expectedPid: process.pid,
551
+ }) as SessionLockStatus,
552
+ handleLostSessionLock: (_ctx, lockStatus) => {
553
+ observedLockStatus = lockStatus;
554
+ deps.callLog.push("handleLostSessionLock");
555
+ },
556
+ });
557
+
558
+ await autoLoop(ctx, pi, s, deps);
559
+
560
+ assert.deepEqual(observedLockStatus, {
561
+ valid: false,
562
+ failureReason: "compromised",
563
+ expectedPid: process.pid,
564
+ });
565
+ assert.ok(
566
+ !deps.callLog.includes("resolveDispatch"),
567
+ "should stop before dispatch after lock validation fails",
568
+ );
569
+ });
570
+
533
571
  test("autoLoop exits on terminal blocked state", async (t) => {
534
572
  _resetPendingResolve();
535
573
 
@@ -153,6 +153,25 @@ async function main(): Promise<void> {
153
153
  // After teardown, originalBase should be null
154
154
  assertEq(getAutoWorktreeOriginalBase(), null, "no split-brain: originalBase cleared");
155
155
 
156
+ // ─── #1526: getMainBranch returns milestone branch in auto-worktree ──
157
+ console.log("\n=== #1526: getMainBranch() returns milestone/<MID> in auto-worktree ===");
158
+ {
159
+ const { GitServiceImpl } = await import("../git-service.ts");
160
+
161
+ // Create worktree
162
+ const wtPath = createAutoWorktree(tempDir, "M005");
163
+ // Don't set main_branch pref so getMainBranch falls through to worktree detection
164
+ const gitService = new GitServiceImpl(wtPath);
165
+ gitService.setMilestoneId("M005");
166
+
167
+ // Verify getMainBranch returns the milestone branch
168
+ const mainBranch = gitService.getMainBranch();
169
+ assertEq(mainBranch, "milestone/M005", "getMainBranch returns milestone/<MID> in auto-worktree");
170
+
171
+ // Cleanup
172
+ teardownAutoWorktree(tempDir, "M005");
173
+ }
174
+
156
175
  // ─── #778: reconcile plan checkboxes on re-attach ─────────────────
157
176
  console.log("\n=== #778: reconcile plan checkboxes on re-attach ===");
158
177
  {
@@ -0,0 +1,122 @@
1
+ import test, { describe } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import * as fs from "node:fs";
4
+ import * as path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import {
7
+ buildCmuxProgress,
8
+ buildCmuxStatusLabel,
9
+ detectCmuxEnvironment,
10
+ markCmuxPromptShown,
11
+ resetCmuxPromptState,
12
+ resolveCmuxConfig,
13
+ shouldPromptToEnableCmux,
14
+ } from "../../cmux/index.ts";
15
+ import type { GSDState } from "../types.ts";
16
+
17
+ test("detectCmuxEnvironment requires workspace, surface, and socket", () => {
18
+ const detected = detectCmuxEnvironment(
19
+ {
20
+ CMUX_WORKSPACE_ID: "workspace:1",
21
+ CMUX_SURFACE_ID: "surface:2",
22
+ CMUX_SOCKET_PATH: "/tmp/cmux.sock",
23
+ },
24
+ (path) => path === "/tmp/cmux.sock",
25
+ () => true,
26
+ );
27
+ assert.equal(detected.available, true);
28
+ assert.equal(detected.cliAvailable, true);
29
+ });
30
+
31
+ test("resolveCmuxConfig enables only when preference and environment are both active", () => {
32
+ const config = resolveCmuxConfig(
33
+ { cmux: { enabled: true, notifications: true, sidebar: true, splits: true } },
34
+ {
35
+ CMUX_WORKSPACE_ID: "workspace:1",
36
+ CMUX_SURFACE_ID: "surface:2",
37
+ CMUX_SOCKET_PATH: "/tmp/cmux.sock",
38
+ },
39
+ () => true,
40
+ () => true,
41
+ );
42
+ assert.equal(config.enabled, true);
43
+ assert.equal(config.notifications, true);
44
+ assert.equal(config.sidebar, true);
45
+ assert.equal(config.splits, true);
46
+ });
47
+
48
+ test("shouldPromptToEnableCmux only prompts once per session", () => {
49
+ resetCmuxPromptState();
50
+ assert.equal(shouldPromptToEnableCmux({}, {}, () => false, () => true), false);
51
+
52
+ assert.equal(
53
+ shouldPromptToEnableCmux(
54
+ {},
55
+ {
56
+ CMUX_WORKSPACE_ID: "workspace:1",
57
+ CMUX_SURFACE_ID: "surface:2",
58
+ CMUX_SOCKET_PATH: "/tmp/cmux.sock",
59
+ },
60
+ () => true,
61
+ () => true,
62
+ ),
63
+ true,
64
+ );
65
+ markCmuxPromptShown();
66
+ assert.equal(
67
+ shouldPromptToEnableCmux(
68
+ {},
69
+ {
70
+ CMUX_WORKSPACE_ID: "workspace:1",
71
+ CMUX_SURFACE_ID: "surface:2",
72
+ CMUX_SOCKET_PATH: "/tmp/cmux.sock",
73
+ },
74
+ () => true,
75
+ () => true,
76
+ ),
77
+ false,
78
+ );
79
+ resetCmuxPromptState();
80
+ });
81
+
82
+ test("buildCmuxStatusLabel and progress prefer deepest active unit", () => {
83
+ const state: GSDState = {
84
+ activeMilestone: { id: "M001", title: "Milestone" },
85
+ activeSlice: { id: "S02", title: "Slice" },
86
+ activeTask: { id: "T03", title: "Task" },
87
+ phase: "executing",
88
+ recentDecisions: [],
89
+ blockers: [],
90
+ nextAction: "Keep going",
91
+ registry: [],
92
+ progress: {
93
+ milestones: { done: 0, total: 1 },
94
+ slices: { done: 1, total: 3 },
95
+ tasks: { done: 2, total: 5 },
96
+ },
97
+ };
98
+
99
+ assert.equal(buildCmuxStatusLabel(state), "M001 S02/T03 · executing");
100
+ assert.deepEqual(buildCmuxProgress(state), { value: 0.4, label: "2/5 tasks" });
101
+ });
102
+
103
+ describe("cmux extension discovery opt-out", () => {
104
+ test("cmux directory has package.json with pi manifest to prevent auto-discovery as extension", () => {
105
+ const cmuxDir = path.resolve(
106
+ path.dirname(fileURLToPath(import.meta.url)),
107
+ "../../cmux",
108
+ );
109
+ const pkgPath = path.join(cmuxDir, "package.json");
110
+ assert.ok(fs.existsSync(pkgPath), `${pkgPath} must exist`);
111
+
112
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
113
+ assert.ok(
114
+ pkg.pi !== undefined && typeof pkg.pi === "object",
115
+ 'package.json must have a "pi" field to opt out of extension auto-discovery',
116
+ );
117
+ assert.ok(
118
+ !pkg.pi.extensions?.length,
119
+ "pi.extensions must be empty or absent — cmux is a library, not an extension",
120
+ );
121
+ });
122
+ });
@@ -171,6 +171,29 @@ test("notification fields validate correctly", () => {
171
171
  assert.equal(preferences.notifications?.on_complete, false);
172
172
  });
173
173
 
174
+ test("cmux fields validate correctly", () => {
175
+ const { preferences, errors } = validatePreferences({
176
+ cmux: {
177
+ enabled: true,
178
+ notifications: true,
179
+ sidebar: false,
180
+ splits: true,
181
+ browser: false,
182
+ },
183
+ });
184
+ assert.equal(errors.length, 0);
185
+ assert.equal(preferences.cmux?.enabled, true);
186
+ assert.equal(preferences.cmux?.sidebar, false);
187
+ assert.equal(preferences.cmux?.splits, true);
188
+ });
189
+
190
+ test("cmux unknown keys produce warnings", () => {
191
+ const { warnings } = validatePreferences({
192
+ cmux: { enabled: true, strange_mode: true } as any,
193
+ });
194
+ assert.ok(warnings.some((warning) => warning.includes('unknown cmux key "strange_mode"')));
195
+ });
196
+
174
197
  test("git fields comprehensive validation", () => {
175
198
  const { preferences, errors } = validatePreferences({
176
199
  git: {
@@ -17,6 +17,7 @@ import { tmpdir } from 'node:os';
17
17
 
18
18
  import {
19
19
  acquireSessionLock,
20
+ getSessionLockStatus,
20
21
  validateSessionLock,
21
22
  releaseSessionLock,
22
23
  readSessionLockData,
@@ -201,6 +202,50 @@ async function main(): Promise<void> {
201
202
  }
202
203
  }
203
204
 
205
+ // ─── 7b. getSessionLockStatus with missing metadata → reason surfaced ──
206
+ console.log('\n=== 7b. missing lock metadata → structured reason ===');
207
+ {
208
+ const base = mkdtempSync(join(tmpdir(), 'gsd-session-lock-'));
209
+ mkdirSync(join(base, '.gsd'), { recursive: true });
210
+
211
+ try {
212
+ const status = getSessionLockStatus(base);
213
+ assertEq(status.valid, false, 'missing lock metadata is invalid');
214
+ assertEq(status.failureReason, 'missing-metadata', 'missing metadata reason is surfaced');
215
+ assertEq(status.expectedPid, process.pid, 'expected PID is included');
216
+ } finally {
217
+ rmSync(base, { recursive: true, force: true });
218
+ }
219
+ }
220
+
221
+ // ─── 7c. getSessionLockStatus with foreign PID → reason surfaced ───────
222
+ console.log('\n=== 7c. foreign PID in lock file → structured reason ===');
223
+ {
224
+ const base = mkdtempSync(join(tmpdir(), 'gsd-session-lock-'));
225
+ mkdirSync(join(base, '.gsd'), { recursive: true });
226
+
227
+ try {
228
+ const foreignPid = process.pid + 1000;
229
+ const lockFile = join(gsdRoot(base), 'auto.lock');
230
+ writeFileSync(lockFile, JSON.stringify({
231
+ pid: foreignPid,
232
+ startedAt: new Date().toISOString(),
233
+ unitType: 'execute-task',
234
+ unitId: 'M001/S01/T01',
235
+ unitStartedAt: new Date().toISOString(),
236
+ completedUnits: 0,
237
+ }, null, 2));
238
+
239
+ const status = getSessionLockStatus(base);
240
+ assertEq(status.valid, false, 'foreign PID lock is invalid');
241
+ assertEq(status.failureReason, 'pid-mismatch', 'PID mismatch reason is surfaced');
242
+ assertEq(status.existingPid, foreignPid, 'existing PID is included');
243
+ assertEq(status.expectedPid, process.pid, 'expected PID is included');
244
+ } finally {
245
+ rmSync(base, { recursive: true, force: true });
246
+ }
247
+ }
248
+
204
249
  // ─── 8. Acquire after release is possible ─────────────────────────────
205
250
  console.log('\n=== 8. acquire after release → re-acquirable ===');
206
251
  {
@@ -16,6 +16,16 @@ export const CUSTOM_SEARCH_TOOL_NAMES = ["search-the-web", "search_and_read", "g
16
16
  /** Thinking block types that require signature validation by the API */
17
17
  const THINKING_TYPES = new Set(["thinking", "redacted_thinking"]);
18
18
 
19
+ /**
20
+ * Maximum number of native web searches allowed per session (agent unit).
21
+ * The Anthropic API's `max_uses` is per-request — it resets on each API call.
22
+ * When `pause_turn` triggers a resubmit, the model gets a fresh budget.
23
+ * This session-level cap prevents unbounded search accumulation (#1309).
24
+ *
25
+ * 15 = 3 full turns of 5 searches each — generous for research, but bounded.
26
+ */
27
+ export const MAX_NATIVE_SEARCHES_PER_SESSION = 15;
28
+
19
29
  /** When true, skip native web search injection and keep Brave/custom tools active on Anthropic. */
20
30
  export function preferBraveSearch(): boolean {
21
31
  // preferences.md takes priority over env var
@@ -74,6 +84,11 @@ export function registerNativeSearchHooks(pi: NativeSearchPI): { getIsAnthropic:
74
84
  let isAnthropicProvider = false;
75
85
  let modelSelectFired = false;
76
86
 
87
+ // Session-level native search counter (#1309).
88
+ // Tracks cumulative web_search_tool_result blocks across all turns in a session.
89
+ // Reset on session_start. Used to compute remaining budget for max_uses.
90
+ let sessionSearchCount = 0;
91
+
77
92
  // Track provider changes via model selection — also handles diagnostics
78
93
  // since model_select fires AFTER session_start and knows the provider.
79
94
  pi.on("model_select", async (event: any, ctx: any) => {
@@ -161,13 +176,41 @@ export function registerNativeSearchHooks(pi: NativeSearchPI): { getIsAnthropic:
161
176
  );
162
177
  payload.tools = tools;
163
178
 
179
+ // ── Session-level search budget (#1309) ──────────────────────────────
180
+ // Count web_search_tool_result blocks in the conversation history to
181
+ // determine how many native searches have already been used this session.
182
+ // The Anthropic API's max_uses resets per request, so without this guard,
183
+ // pause_turn → resubmit cycles allow unlimited total searches.
184
+ if (Array.isArray(messages)) {
185
+ let historySearchCount = 0;
186
+ for (const msg of messages) {
187
+ const content = msg.content;
188
+ if (!Array.isArray(content)) continue;
189
+ for (const block of content) {
190
+ if ((block as any)?.type === "web_search_tool_result") {
191
+ historySearchCount++;
192
+ }
193
+ }
194
+ }
195
+ // Sync counter from history (handles session restore / context replay)
196
+ sessionSearchCount = historySearchCount;
197
+ }
198
+
199
+ const remaining = Math.max(0, MAX_NATIVE_SEARCHES_PER_SESSION - sessionSearchCount);
200
+
201
+ if (remaining <= 0) {
202
+ // Budget exhausted — don't inject the search tool at all.
203
+ // The model will proceed without web search capability.
204
+ return payload;
205
+ }
206
+
164
207
  tools.push({
165
208
  type: "web_search_20250305",
166
209
  name: "web_search",
167
- // Cap server-side searches per response to prevent the model from
168
- // looping on web_search without synthesizing results (#817).
169
- // 5 searches is generous most queries need 1-2.
170
- max_uses: 5,
210
+ // Cap per-request searches to the lesser of 5 (per-turn cap) or the
211
+ // remaining session budget (#1309). This prevents the model from
212
+ // consuming unlimited searches via pause_turn resubmit cycles.
213
+ max_uses: Math.min(5, remaining),
171
214
  });
172
215
 
173
216
  return payload;
@@ -175,6 +218,9 @@ export function registerNativeSearchHooks(pi: NativeSearchPI): { getIsAnthropic:
175
218
 
176
219
  // Basic startup diagnostics — provider-specific info comes from model_select
177
220
  pi.on("session_start", async (_event: any, ctx: any) => {
221
+ // Reset session-level search budget (#1309)
222
+ sessionSearchCount = 0;
223
+
178
224
  const hasBrave = !!process.env.BRAVE_API_KEY;
179
225
  const hasJina = !!process.env.JINA_API_KEY;
180
226
  const hasAnswers = !!process.env.BRAVE_ANSWERS_KEY;
@@ -1,12 +1,12 @@
1
1
  /**
2
- * Shared formatting and layout utilities for TUI dashboard components.
2
+ * Shared pure formatting utilities no @gsd/pi-tui dependency.
3
3
  *
4
- * Consolidates helpers that were previously duplicated across
5
- * auto-dashboard.ts, dashboard-overlay.ts, and visualizer-views.ts.
4
+ * ANSI-aware layout helpers (padRight, joinColumns, centerLine, fitColumns)
5
+ * live in layout-utils.ts to avoid pulling @gsd/pi-tui into modules that
6
+ * run outside jiti's alias resolution (e.g. HTML report generation via
7
+ * dynamic import in auto-loop).
6
8
  */
7
9
 
8
- import { truncateToWidth, visibleWidth } from "@gsd/pi-tui";
9
-
10
10
  // ─── Duration Formatting ──────────────────────────────────────────────────────
11
11
 
12
12
  /** Format a millisecond duration as a compact human-readable string. */
@@ -31,45 +31,6 @@ export function formatTokenCount(count: number): string {
31
31
  return `${(count / 1_000_000).toFixed(2)}M`;
32
32
  }
33
33
 
34
- // ─── Layout Helpers ───────────────────────────────────────────────────────────
35
-
36
- /** Pad a string with trailing spaces to fill `width` (ANSI-aware). */
37
- export function padRight(content: string, width: number): string {
38
- const vis = visibleWidth(content);
39
- return content + " ".repeat(Math.max(0, width - vis));
40
- }
41
-
42
- /** Build a line with left-aligned and right-aligned content. */
43
- export function joinColumns(left: string, right: string, width: number): string {
44
- const leftW = visibleWidth(left);
45
- const rightW = visibleWidth(right);
46
- if (leftW + rightW + 2 > width) {
47
- return truncateToWidth(`${left} ${right}`, width);
48
- }
49
- return left + " ".repeat(width - leftW - rightW) + right;
50
- }
51
-
52
- /** Center content within `width` (ANSI-aware). */
53
- export function centerLine(content: string, width: number): string {
54
- const vis = visibleWidth(content);
55
- if (vis >= width) return truncateToWidth(content, width);
56
- const leftPad = Math.floor((width - vis) / 2);
57
- return " ".repeat(leftPad) + content;
58
- }
59
-
60
- /** Join as many parts as fit within `width`, separated by `separator`. */
61
- export function fitColumns(parts: string[], width: number, separator = " "): string {
62
- const filtered = parts.filter(Boolean);
63
- if (filtered.length === 0) return "";
64
- let result = filtered[0];
65
- for (let i = 1; i < filtered.length; i++) {
66
- const candidate = `${result}${separator}${filtered[i]}`;
67
- if (visibleWidth(candidate) > width) break;
68
- result = candidate;
69
- }
70
- return truncateToWidth(result, width);
71
- }
72
-
73
34
  // ─── Text Truncation ─────────────────────────────────────────────────────────
74
35
 
75
36
  /** Truncate a string to `maxLength` characters, replacing the last character with an ellipsis if needed. */
@@ -0,0 +1,49 @@
1
+ /**
2
+ * ANSI-aware TUI layout utilities that depend on @gsd/pi-tui.
3
+ *
4
+ * Separated from format-utils.ts so that modules needing only pure
5
+ * formatting (e.g. HTML report generation) can import format-utils
6
+ * without pulling in the @gsd/pi-tui dependency — which fails when
7
+ * loaded outside jiti's alias resolution context.
8
+ */
9
+
10
+ import { truncateToWidth, visibleWidth } from "@gsd/pi-tui";
11
+
12
+ // ─── Layout Helpers ───────────────────────────────────────────────────────────
13
+
14
+ /** Pad a string with trailing spaces to fill `width` (ANSI-aware). */
15
+ export function padRight(content: string, width: number): string {
16
+ const vis = visibleWidth(content);
17
+ return content + " ".repeat(Math.max(0, width - vis));
18
+ }
19
+
20
+ /** Build a line with left-aligned and right-aligned content. */
21
+ export function joinColumns(left: string, right: string, width: number): string {
22
+ const leftW = visibleWidth(left);
23
+ const rightW = visibleWidth(right);
24
+ if (leftW + rightW + 2 > width) {
25
+ return truncateToWidth(`${left} ${right}`, width);
26
+ }
27
+ return left + " ".repeat(width - leftW - rightW) + right;
28
+ }
29
+
30
+ /** Center content within `width` (ANSI-aware). */
31
+ export function centerLine(content: string, width: number): string {
32
+ const vis = visibleWidth(content);
33
+ if (vis >= width) return truncateToWidth(content, width);
34
+ const leftPad = Math.floor((width - vis) / 2);
35
+ return " ".repeat(leftPad) + content;
36
+ }
37
+
38
+ /** Join as many parts as fit within `width`, separated by `separator`. */
39
+ export function fitColumns(parts: string[], width: number, separator = " "): string {
40
+ const filtered = parts.filter(Boolean);
41
+ if (filtered.length === 0) return "";
42
+ let result = filtered[0];
43
+ for (let i = 1; i < filtered.length; i++) {
44
+ const candidate = `${result}${separator}${filtered[i]}`;
45
+ if (visibleWidth(candidate) > width) break;
46
+ result = candidate;
47
+ }
48
+ return truncateToWidth(result, width);
49
+ }
@@ -13,15 +13,18 @@ export {
13
13
  stripAnsi,
14
14
  formatTokenCount,
15
15
  formatDuration,
16
- padRight,
17
- joinColumns,
18
- centerLine,
19
- fitColumns,
20
16
  sparkline,
21
17
  normalizeStringArray,
22
18
  fileLink,
23
19
  } from "./format-utils.js";
24
20
 
21
+ export {
22
+ padRight,
23
+ joinColumns,
24
+ centerLine,
25
+ fitColumns,
26
+ } from "./layout-utils.js";
27
+
25
28
  export { shortcutDesc } from "./terminal.js";
26
29
  export { toPosixPath } from "./path-display.js";
27
30
  export { showInterviewRound } from "./interview-ui.js";
@@ -7,9 +7,14 @@
7
7
 
8
8
  const UNSUPPORTED_TERMS = ["apple_terminal", "warpterm"];
9
9
 
10
+ export function isCmuxTerminal(env: NodeJS.ProcessEnv = process.env): boolean {
11
+ return Boolean(env.CMUX_WORKSPACE_ID && env.CMUX_SURFACE_ID);
12
+ }
13
+
10
14
  export function supportsCtrlAltShortcuts(): boolean {
11
15
  const term = (process.env.TERM_PROGRAM || "").toLowerCase();
12
16
  const jetbrains = (process.env.TERMINAL_EMULATOR || "").toLowerCase().includes("jetbrains");
17
+ if (isCmuxTerminal()) return true;
13
18
  return !UNSUPPORTED_TERMS.some((t) => term.includes(t)) && !jetbrains;
14
19
  }
15
20
 
@@ -2,13 +2,15 @@ import { describe, it } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import {
4
4
  formatDuration,
5
+ sparkline,
6
+ stripAnsi,
7
+ } from "../format-utils.js";
8
+ import {
5
9
  padRight,
6
10
  joinColumns,
7
11
  centerLine,
8
12
  fitColumns,
9
- sparkline,
10
- stripAnsi,
11
- } from "../format-utils.js";
13
+ } from "../layout-utils.js";
12
14
 
13
15
  describe("formatDuration", () => {
14
16
  it("formats seconds", () => {