pi-better-harness 0.3.16 → 0.3.17

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-background-tasks",
3
- "version": "0.2.14",
3
+ "version": "0.2.15",
4
4
  "description": "Pi extension for durable background shell tasks, watchers, logs, and status inspection.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth as piTruncateToWidth, visibleWidth as piVisibleWidth } from "@earendil-works/pi-tui";
2
3
  import type { Component } from "@earendil-works/pi-tui";
3
4
  import { createRenderScheduler, type RenderScheduler } from "./shared-render-scheduler.ts";
4
5
 
@@ -280,10 +281,10 @@ function refreshMainListWidget(): void {
280
281
  }) : undefined;
281
282
  const changed = nextSignature !== s.lastMainListSignature;
282
283
  s.lastMainListSignature = nextSignature;
283
- s.lastMainListLines = rows.length ? buildMainListLines(rows, MAIN_LIST_FALLBACK_WIDTH, deps.truncate, themeFg(ctx), {
284
+ s.lastMainListLines = rows.length ? ["", ...buildMainListLines(rows, MAIN_LIST_FALLBACK_WIDTH, deps.truncate, themeFg(ctx), {
284
285
  selectedId: s.mainListFocused ? s.mainListSelectedId : undefined,
285
286
  focused: s.mainListFocused === true,
286
- }) : undefined;
287
+ })] : undefined;
287
288
  if (!rows.length) {
288
289
  if (!s.mainListWidgetInstalled) return;
289
290
  try { (ctx.ui as any).setWidget?.(MAIN_LIST_WIDGET_KEY, undefined); } catch { /* ignore */ }
@@ -323,10 +324,10 @@ function createMainListWidget(tui: { requestRender?(): void }, theme: unknown, d
323
324
  state().lastMainListLines = undefined;
324
325
  return [];
325
326
  }
326
- const lines = buildMainListLines(rows, renderWidth(width), deps.truncate, themeFgFromTheme(theme), {
327
+ const lines = ["", ...buildMainListLines(rows, renderWidth(width), deps.truncate, themeFgFromTheme(theme), {
327
328
  selectedId: state().mainListFocused ? state().mainListSelectedId : undefined,
328
329
  focused: state().mainListFocused === true,
329
- });
330
+ })];
330
331
  state().lastMainListLines = lines;
331
332
  return lines;
332
333
  },
@@ -1269,7 +1270,8 @@ export function wrapLogText(text: string, width: number): string[] {
1269
1270
  continue;
1270
1271
  }
1271
1272
  const sourceIndent = source.match(/^[ \t]*/)?.[0] ?? "";
1272
- const indent = sourceIndent.replace(/\t/g, " ");
1273
+ // Leave room for a full-width grapheme even on deeply indented log lines.
1274
+ const indent = sourceIndent.replace(/\t/g, " ").slice(0, max - 2);
1273
1275
  let remaining = source.slice(sourceIndent.length);
1274
1276
  const contentWidth = Math.max(1, max - visibleWidth(indent));
1275
1277
  if (!remaining) {
@@ -1302,33 +1304,25 @@ function lastDelimiterBreak(value: string): number {
1302
1304
  return last;
1303
1305
  }
1304
1306
 
1307
+ const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
1308
+
1305
1309
  function splitVisiblePrefix(value: string, width: number): [string, string] {
1306
1310
  const max = Math.max(0, Math.floor(width));
1307
1311
  let visible = 0;
1308
- let index = 0;
1309
- while (index < value.length && visible < max) {
1310
- if (value[index] === "\u001b" || value[index] === "\u009b") {
1311
- const match = value.slice(index).match(ANSI_RE);
1312
- if (match?.index === 0) {
1313
- index += match[0].length;
1314
- continue;
1315
- }
1316
- }
1317
- if (value[index] === "<") {
1318
- const close = value.indexOf(">", index);
1319
- if (close !== -1) {
1320
- const tag = value.slice(index, close + 1);
1321
- if (/^<\/?[a-zA-Z][\w-]*>$/.test(tag) || tag === "</>") {
1322
- index = close + 1;
1323
- continue;
1324
- }
1325
- }
1312
+ let end = 0;
1313
+ for (const { segment, index } of GRAPHEME_SEGMENTER.segment(value)) {
1314
+ if (index < end) continue;
1315
+ const formatting = value.slice(index).match(FORMATTING_PREFIX_RE);
1316
+ if (formatting) {
1317
+ end = index + formatting[0].length;
1318
+ continue;
1326
1319
  }
1327
- const codePoint = value.codePointAt(index)!;
1328
- index += codePoint > 0xFFFF ? 2 : 1;
1329
- visible += 1;
1320
+ const cells = piVisibleWidth(segment);
1321
+ if (visible + cells > max) break;
1322
+ visible += cells;
1323
+ end = index + segment.length;
1330
1324
  }
1331
- return [value.slice(0, index), value.slice(index)];
1325
+ return [value.slice(0, end), value.slice(end)];
1332
1326
  }
1333
1327
 
1334
1328
  function cycleLogTailRows(current: number): number {
@@ -1387,44 +1381,19 @@ function safeTruncate(line: string, width: number, truncate: (s: string, width:
1387
1381
 
1388
1382
  const ANSI_RE = new RegExp("[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))", "g");
1389
1383
 
1384
+ const FORMATTING_PREFIX_RE = new RegExp(`^(?:${ANSI_RE.source}|<\\/?[a-zA-Z][\\w-]*>|</>)`);
1385
+
1390
1386
  function visibleWidth(value: string): number {
1391
- return String(value ?? "")
1387
+ const cleaned = String(value ?? "")
1392
1388
  .replace(ANSI_RE, "")
1393
1389
  .replace(/<\/?[a-zA-Z][\w-]*>/g, "")
1394
- .replace(/<\/>/g, "")
1395
- .length;
1390
+ .replace(/<\/>/g, "");
1391
+ return piVisibleWidth(cleaned);
1396
1392
  }
1397
1393
 
1398
1394
  function truncateVisible(value: string, width: number): string {
1399
1395
  const str = String(value ?? "");
1400
1396
  const max = Math.max(0, Math.floor(width || 0));
1401
1397
  if (visibleWidth(str) <= max) return str;
1402
- let out = "";
1403
- let vis = 0;
1404
- let i = 0;
1405
- while (i < str.length && vis < max) {
1406
- if (str[i] === "\u001b" || str[i] === "\u009b") {
1407
- const match = str.slice(i).match(ANSI_RE);
1408
- if (match && match.index === 0) {
1409
- out += match[0];
1410
- i += match[0].length;
1411
- continue;
1412
- }
1413
- }
1414
- if (str[i] === "<") {
1415
- const close = str.indexOf(">", i);
1416
- if (close !== -1) {
1417
- const tag = str.slice(i, close + 1);
1418
- if (/^<\/?[a-zA-Z][\w-]*>$/.test(tag) || tag === "</>") {
1419
- out += tag;
1420
- i = close + 1;
1421
- continue;
1422
- }
1423
- }
1424
- }
1425
- out += str[i];
1426
- vis += 1;
1427
- i += 1;
1428
- }
1429
- return out;
1398
+ return piTruncateToWidth(str, max);
1430
1399
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-plan",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "Structured execution plans with persistent progress for Pi.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -162,10 +162,13 @@ export default function planExtension(pi: ExtensionAPI): void {
162
162
  if (displayMode === "hidden") return [];
163
163
  if (displayedWorkflowOwner) {
164
164
  if (displayedWorkflowOwner !== "rush-issues") return [];
165
- return rushPlan ? renderRushPlan(rushPlan, width) : [];
165
+ const fg = typeof theme?.fg === "function"
166
+ ? (color: string, value: string) => theme.fg(color as never, value)
167
+ : undefined;
168
+ return rushPlan ? ["", ...renderRushPlan(rushPlan, width, false, fg)] : [];
166
169
  }
167
170
  if (!currentPlan) return [];
168
- return renderCompactPlan(currentPlan, width, theme as never);
171
+ return ["", ...renderCompactPlan(currentPlan, width, theme as never)];
169
172
  },
170
173
  invalidate() {},
171
174
  dispose() {
@@ -189,8 +192,11 @@ export default function planExtension(pi: ExtensionAPI): void {
189
192
  ctx.ui.notify(renderRushPlan(plan, 160, true).join("\n"), "info");
190
193
  return;
191
194
  }
192
- await ctx.ui.custom<void>((tui, _theme, _keys, done) => {
193
- const component = createRushPlanComponent(plan, () => done());
195
+ await ctx.ui.custom<void>((tui, theme, _keys, done) => {
196
+ const fg = typeof theme?.fg === "function"
197
+ ? (color: string, value: string) => theme.fg(color as never, value)
198
+ : undefined;
199
+ const component = createRushPlanComponent(plan, () => done(), fg);
194
200
  return { render: (width) => component.render(width), handleInput(data) {
195
201
  component.handleInput?.(data);
196
202
  tui.requestRender();
@@ -98,9 +98,15 @@ export function workflowBinding(entries: Iterable<{ type: string; customType?: s
98
98
  return binding;
99
99
  }
100
100
 
101
- export function renderRushPlan(plan: RushPlan, width: number, full = false): string[] {
101
+ export function renderRushPlan(
102
+ plan: RushPlan,
103
+ width: number,
104
+ full = false,
105
+ fg?: (color: string, value: string) => string,
106
+ ): string[] {
102
107
  const completed = plan.issues.filter((unit) => unit.status === "succeeded").length;
103
- const lines = [`rush-issues rev ${plan.planRevision} ${completed}/${plan.issues.length} units${plan.spec?.title ? ` ${plan.spec.title}` : ""}`];
108
+ const summary = `rev ${plan.planRevision} ${completed}/${plan.issues.length} units${plan.spec?.title ? ` ${plan.spec.title}` : ""}`;
109
+ const lines = [fg ? `${fg("warning", "rush-issues")} ${fg("dim", summary)}` : `rush-issues ${summary}`];
104
110
  const fleet = ["explore", "combine", "canary", "review", "cicd"]
105
111
  .map((stage) => `${stage}: ${stage === "canary" && !plan.warehouseCanaryRequired ? "n/a" : plan.fleet[stage]?.status ?? "pending"}`);
106
112
  lines.push(`fleet ${fleet.join(" ")}`);
@@ -115,9 +121,13 @@ export function renderRushPlan(plan: RushPlan, width: number, full = false): str
115
121
  return lines.map((line) => truncateToWidth(line, Math.max(1, width)));
116
122
  }
117
123
 
118
- export function createRushPlanComponent(plan: RushPlan, onClose: () => void): Component {
124
+ export function createRushPlanComponent(
125
+ plan: RushPlan,
126
+ onClose: () => void,
127
+ fg?: (color: string, value: string) => string,
128
+ ): Component {
119
129
  return {
120
- render: (width) => renderRushPlan(plan, width, true),
130
+ render: (width) => renderRushPlan(plan, width, true, fg),
121
131
  handleInput(data) {
122
132
  if (matchesKey(data, "escape") || matchesKey(data, "left") || matchesKey(data, "ctrl+c")) onClose();
123
133
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-subagents",
3
- "version": "0.1.30",
3
+ "version": "0.1.31",
4
4
  "description": "Pi extension for detached, sandboxed subagent runs that keep the foreground session free.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth as piTruncateToWidth, visibleWidth as piVisibleWidth } from "@earendil-works/pi-tui";
2
3
  import type { Component } from "@earendil-works/pi-tui";
3
4
  import { createRenderScheduler, type RenderScheduler } from "./shared-render-scheduler.ts";
4
5
 
@@ -280,10 +281,10 @@ function refreshMainListWidget(): void {
280
281
  }) : undefined;
281
282
  const changed = nextSignature !== s.lastMainListSignature;
282
283
  s.lastMainListSignature = nextSignature;
283
- s.lastMainListLines = rows.length ? buildMainListLines(rows, MAIN_LIST_FALLBACK_WIDTH, deps.truncate, themeFg(ctx), {
284
+ s.lastMainListLines = rows.length ? ["", ...buildMainListLines(rows, MAIN_LIST_FALLBACK_WIDTH, deps.truncate, themeFg(ctx), {
284
285
  selectedId: s.mainListFocused ? s.mainListSelectedId : undefined,
285
286
  focused: s.mainListFocused === true,
286
- }) : undefined;
287
+ })] : undefined;
287
288
  if (!rows.length) {
288
289
  if (!s.mainListWidgetInstalled) return;
289
290
  try { (ctx.ui as any).setWidget?.(MAIN_LIST_WIDGET_KEY, undefined); } catch { /* ignore */ }
@@ -323,10 +324,10 @@ function createMainListWidget(tui: { requestRender?(): void }, theme: unknown, d
323
324
  state().lastMainListLines = undefined;
324
325
  return [];
325
326
  }
326
- const lines = buildMainListLines(rows, renderWidth(width), deps.truncate, themeFgFromTheme(theme), {
327
+ const lines = ["", ...buildMainListLines(rows, renderWidth(width), deps.truncate, themeFgFromTheme(theme), {
327
328
  selectedId: state().mainListFocused ? state().mainListSelectedId : undefined,
328
329
  focused: state().mainListFocused === true,
329
- });
330
+ })];
330
331
  state().lastMainListLines = lines;
331
332
  return lines;
332
333
  },
@@ -1269,7 +1270,8 @@ export function wrapLogText(text: string, width: number): string[] {
1269
1270
  continue;
1270
1271
  }
1271
1272
  const sourceIndent = source.match(/^[ \t]*/)?.[0] ?? "";
1272
- const indent = sourceIndent.replace(/\t/g, " ");
1273
+ // Leave room for a full-width grapheme even on deeply indented log lines.
1274
+ const indent = sourceIndent.replace(/\t/g, " ").slice(0, max - 2);
1273
1275
  let remaining = source.slice(sourceIndent.length);
1274
1276
  const contentWidth = Math.max(1, max - visibleWidth(indent));
1275
1277
  if (!remaining) {
@@ -1302,33 +1304,25 @@ function lastDelimiterBreak(value: string): number {
1302
1304
  return last;
1303
1305
  }
1304
1306
 
1307
+ const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
1308
+
1305
1309
  function splitVisiblePrefix(value: string, width: number): [string, string] {
1306
1310
  const max = Math.max(0, Math.floor(width));
1307
1311
  let visible = 0;
1308
- let index = 0;
1309
- while (index < value.length && visible < max) {
1310
- if (value[index] === "\u001b" || value[index] === "\u009b") {
1311
- const match = value.slice(index).match(ANSI_RE);
1312
- if (match?.index === 0) {
1313
- index += match[0].length;
1314
- continue;
1315
- }
1316
- }
1317
- if (value[index] === "<") {
1318
- const close = value.indexOf(">", index);
1319
- if (close !== -1) {
1320
- const tag = value.slice(index, close + 1);
1321
- if (/^<\/?[a-zA-Z][\w-]*>$/.test(tag) || tag === "</>") {
1322
- index = close + 1;
1323
- continue;
1324
- }
1325
- }
1312
+ let end = 0;
1313
+ for (const { segment, index } of GRAPHEME_SEGMENTER.segment(value)) {
1314
+ if (index < end) continue;
1315
+ const formatting = value.slice(index).match(FORMATTING_PREFIX_RE);
1316
+ if (formatting) {
1317
+ end = index + formatting[0].length;
1318
+ continue;
1326
1319
  }
1327
- const codePoint = value.codePointAt(index)!;
1328
- index += codePoint > 0xFFFF ? 2 : 1;
1329
- visible += 1;
1320
+ const cells = piVisibleWidth(segment);
1321
+ if (visible + cells > max) break;
1322
+ visible += cells;
1323
+ end = index + segment.length;
1330
1324
  }
1331
- return [value.slice(0, index), value.slice(index)];
1325
+ return [value.slice(0, end), value.slice(end)];
1332
1326
  }
1333
1327
 
1334
1328
  function cycleLogTailRows(current: number): number {
@@ -1387,44 +1381,19 @@ function safeTruncate(line: string, width: number, truncate: (s: string, width:
1387
1381
 
1388
1382
  const ANSI_RE = new RegExp("[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))", "g");
1389
1383
 
1384
+ const FORMATTING_PREFIX_RE = new RegExp(`^(?:${ANSI_RE.source}|<\\/?[a-zA-Z][\\w-]*>|</>)`);
1385
+
1390
1386
  function visibleWidth(value: string): number {
1391
- return String(value ?? "")
1387
+ const cleaned = String(value ?? "")
1392
1388
  .replace(ANSI_RE, "")
1393
1389
  .replace(/<\/?[a-zA-Z][\w-]*>/g, "")
1394
- .replace(/<\/>/g, "")
1395
- .length;
1390
+ .replace(/<\/>/g, "");
1391
+ return piVisibleWidth(cleaned);
1396
1392
  }
1397
1393
 
1398
1394
  function truncateVisible(value: string, width: number): string {
1399
1395
  const str = String(value ?? "");
1400
1396
  const max = Math.max(0, Math.floor(width || 0));
1401
1397
  if (visibleWidth(str) <= max) return str;
1402
- let out = "";
1403
- let vis = 0;
1404
- let i = 0;
1405
- while (i < str.length && vis < max) {
1406
- if (str[i] === "\u001b" || str[i] === "\u009b") {
1407
- const match = str.slice(i).match(ANSI_RE);
1408
- if (match && match.index === 0) {
1409
- out += match[0];
1410
- i += match[0].length;
1411
- continue;
1412
- }
1413
- }
1414
- if (str[i] === "<") {
1415
- const close = str.indexOf(">", i);
1416
- if (close !== -1) {
1417
- const tag = str.slice(i, close + 1);
1418
- if (/^<\/?[a-zA-Z][\w-]*>$/.test(tag) || tag === "</>") {
1419
- out += tag;
1420
- i = close + 1;
1421
- continue;
1422
- }
1423
- }
1424
- }
1425
- out += str[i];
1426
- vis += 1;
1427
- i += 1;
1428
- }
1429
- return out;
1398
+ return piTruncateToWidth(str, max);
1430
1399
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-harness",
3
- "version": "0.3.16",
3
+ "version": "0.3.17",
4
4
  "description": "Pi extension bundle for a write sandbox, subagents, background tasks, SSH, goals, and structured plans.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -50,12 +50,12 @@
50
50
  "test": "node --test test/*.test.mjs"
51
51
  },
52
52
  "dependencies": {
53
- "pi-better-background-tasks": "0.2.14",
53
+ "pi-better-background-tasks": "0.2.15",
54
54
  "pi-better-goal": "0.4.0",
55
- "pi-better-plan": "0.3.2",
55
+ "pi-better-plan": "0.3.3",
56
56
  "pi-better-sandbox": "0.3.0",
57
57
  "pi-better-ssh": "0.1.1",
58
- "pi-better-subagents": "0.1.30",
58
+ "pi-better-subagents": "0.1.31",
59
59
  "yaml": "^2.9.1"
60
60
  },
61
61
  "bundledDependencies": [