pi-better-harness 0.3.16 → 0.3.18

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
  }
@@ -27,6 +27,12 @@ When an explicitly invoked skill declares `workflow-role: coordinator` in its me
27
27
 
28
28
  For `rush-issues`, call `sync_workflow_plan` with the absolute `.resolve-issues/rush/<run-id>/task-plan.json` path and its persisted `planRevision` after each checkpoint. This binds the active run to the session and displays its fleet stages and actual units in the widget, `/plan`, and `get_plan`. The view reads the file without writing to it; revision mismatches are rejected. The run binding survives Pi session resume, and ownership release restores the prior generic checklist. Other workflow-owned skills retain their own planning and have no generic plan view.
29
29
 
30
+ ## Plan Display
31
+
32
+ Native and workflow-synced plans share a `plan` heading, completion counts, aligned identifiers and titles, and status colors. Completed rows use `✓`, active rows `●`, pending rows `○`, and blocked rows `!`. Active and blocked rows also carry text labels; failed workflow rows use `×` and `failed` rather than an active indicator.
33
+
34
+ Workflow identity, revision, and fleet progress sit on a secondary line that wraps on narrow terminals. `/plan` uses the same styling and shows workflow stage, raw status, worker, dependencies, and notes beneath each issue. Use ↑/↓ to move the selection and Escape or ← to return. The passive widget never captures editor arrow keys.
35
+
30
36
  ## Install
31
37
 
32
38
  ```sh
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-plan",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
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();
@@ -0,0 +1,112 @@
1
+ import type { Component } from "@earendil-works/pi-tui";
2
+ import { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
+
4
+ export interface PlanRenderTheme {
5
+ fg(color: string, value: string): string;
6
+ }
7
+
8
+ export type DisplayStatus = "completed" | "in_progress" | "pending" | "blocked" | "failed" | "skipped" | "unknown";
9
+ export interface PlanRow {
10
+ label: string;
11
+ title: string;
12
+ status: DisplayStatus;
13
+ statusLabel?: string;
14
+ details?: string[];
15
+ }
16
+ export interface PlanPresentation {
17
+ rows: PlanRow[];
18
+ metadata?: { text: string; color: string }[][];
19
+ }
20
+
21
+ export const plainPlanTheme: PlanRenderTheme = { fg: (_color, value) => value };
22
+
23
+ export function statusStyle(status: DisplayStatus): { glyph: string; color: string; label: string } {
24
+ switch (status) {
25
+ case "completed": return { glyph: "✓", color: "success", label: "" };
26
+ case "in_progress": return { glyph: "●", color: "accent", label: "active" };
27
+ case "blocked": return { glyph: "!", color: "warning", label: "blocked" };
28
+ case "failed": return { glyph: "×", color: "error", label: "failed" };
29
+ case "skipped": return { glyph: "—", color: "dim", label: "skipped" };
30
+ case "unknown": return { glyph: "?", color: "warning", label: "unknown" };
31
+ case "pending": return { glyph: "○", color: "dim", label: "" };
32
+ }
33
+ }
34
+
35
+ // Keep each piece of external text on one terminal row before measuring it.
36
+ export function planText(value: string): string {
37
+ return value.replace(/[\r\n\t]/g, " ");
38
+ }
39
+
40
+ export function renderPlanPresentation(
41
+ plan: PlanPresentation, width: number, theme: PlanRenderTheme,
42
+ full = false, selectedIndex = -1,
43
+ ): string[] {
44
+ const size = Math.max(1, Math.floor(width));
45
+ const count = (status: DisplayStatus) => plan.rows.filter((row) => row.status === status).length;
46
+ const summary = [`${count("completed")}/${plan.rows.length} complete`];
47
+ for (const [status, label] of [
48
+ ["in_progress", "in progress"], ["blocked", "blocked"], ["failed", "failed"],
49
+ ["skipped", "skipped"], ["unknown", "unknown"],
50
+ ] as const) {
51
+ if (count(status)) summary.push(`${count(status)} ${label}`);
52
+ }
53
+ if (count("pending") && summary.length === 1) summary.push(`${count("pending")} pending`);
54
+ const lines = [theme.fg("warning", "plan") + theme.fg("dim", ` ${summary.join(" · ")}`)];
55
+ for (const metadata of plan.metadata ?? []) {
56
+ const content = metadata.map((part) => theme.fg(part.color, planText(part.text))).join("");
57
+ for (const line of wrapTextWithAnsi(content, Math.max(1, size - 2))) lines.push(` ${line}`);
58
+ }
59
+ lines.push("");
60
+
61
+ const labelWidth = Math.min(12, Math.max(4, ...plan.rows.map((row) => visibleWidth(planText(row.label)))));
62
+ const statusWidth = Math.min(24, Math.max(0, ...plan.rows.map((row) =>
63
+ visibleWidth(planText(row.statusLabel ?? statusStyle(row.status).label)))));
64
+ for (const [index, row] of plan.rows.entries()) {
65
+ const style = statusStyle(row.status);
66
+ const label = truncateToWidth(planText(row.label), labelWidth);
67
+ const prefix = (index === selectedIndex ? theme.fg("accent", "› ") : " ") +
68
+ theme.fg(style.color, style.glyph) + " " + theme.fg("dim", label) +
69
+ " ".repeat(Math.max(0, labelWidth - visibleWidth(label)) + 2);
70
+ const titleColor = row.status === "blocked" ? "warning" : row.status === "failed" ? "error" :
71
+ row.status === "in_progress" ? "text" : "muted";
72
+ const badge = planText(row.statusLabel ?? style.label);
73
+ const available = size - visibleWidth(prefix);
74
+ // At narrow widths use an inline status so it survives title truncation.
75
+ if (badge && available < statusWidth + 14) {
76
+ lines.push(prefix + theme.fg(style.color, `${badge} · `) + theme.fg(titleColor, planText(row.title)));
77
+ } else {
78
+ const titleWidth = Math.max(0, available - (statusWidth ? statusWidth + 2 : 0));
79
+ const title = truncateToWidth(planText(row.title), titleWidth);
80
+ lines.push(prefix + theme.fg(titleColor, title) + (badge
81
+ ? " ".repeat(Math.max(0, titleWidth - visibleWidth(title)) + 2) + theme.fg(style.color, badge)
82
+ : ""));
83
+ }
84
+ if (full) {
85
+ for (const detail of row.details ?? []) {
86
+ const indent = Math.min(visibleWidth(prefix), Math.max(0, size - 4));
87
+ for (const line of wrapTextWithAnsi(planText(detail), Math.max(1, size - indent))) {
88
+ lines.push(" ".repeat(indent) + theme.fg("dim", line));
89
+ }
90
+ }
91
+ }
92
+ }
93
+ if (full) lines.push("", theme.fg("dim", "↑↓ navigate · esc / ← back"));
94
+ return lines.map((line) => truncateToWidth(line, size));
95
+ }
96
+
97
+ export function createPlanPresentationComponent(
98
+ plan: PlanPresentation, theme: PlanRenderTheme, onClose: () => void,
99
+ ): Component {
100
+ let selected = plan.rows.findIndex((row) => row.status === "in_progress");
101
+ if (selected < 0) selected = plan.rows.findIndex((row) => row.status !== "completed");
102
+ if (selected < 0) selected = Math.max(0, plan.rows.length - 1);
103
+ return {
104
+ render: (width) => renderPlanPresentation(plan, width, theme, true, selected),
105
+ handleInput(data) {
106
+ if (matchesKey(data, "up")) selected = Math.max(0, selected - 1);
107
+ else if (matchesKey(data, "down")) selected = Math.min(Math.max(0, plan.rows.length - 1), selected + 1);
108
+ else if (matchesKey(data, "escape") || matchesKey(data, "left") || matchesKey(data, "ctrl+c")) onClose();
109
+ },
110
+ invalidate() {},
111
+ };
112
+ }
@@ -1,94 +1,28 @@
1
1
  import type { Component } from "@earendil-works/pi-tui";
2
- import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
2
+ import {
3
+ createPlanPresentationComponent, renderPlanPresentation,
4
+ type PlanPresentation, type PlanRenderTheme,
5
+ } from "./plan-presentation.js";
6
+ import type { PlanSnapshot } from "./types.js";
3
7
 
4
- import { planProgress } from "./plan-state.js";
5
- import type { PlanSnapshot, PlanStep } from "./types.js";
8
+ export type { PlanRenderTheme } from "./plan-presentation.js";
6
9
 
7
- export interface PlanRenderTheme {
8
- fg(color: string, value: string): string;
9
- }
10
-
11
- export function renderCompactPlan(
12
- plan: PlanSnapshot,
13
- width: number,
14
- theme: PlanRenderTheme,
15
- ): string[] {
16
- const progress = planProgress(plan);
17
- const stateLabel = progress.blocked > 0
18
- ? `${progress.blocked} blocked`
19
- : progress.state === "complete"
20
- ? "complete"
21
- : progress.state.replace("_", " ");
22
- const lines = [
23
- theme.fg(progress.blocked > 0 ? "warning" : "accent", `plan ${progress.completed}/${progress.total} steps`) +
24
- theme.fg("dim", ` · ${stateLabel}`),
25
- ];
26
-
27
- for (let index = 0; index < plan.steps.length; index += 1) {
28
- const item = plan.steps[index]!;
29
- lines.push(` ${stepGlyph(item, theme)} ${index + 1} ${stepText(item, theme)}`);
30
- }
31
- lines.push("");
32
- return lines.map((line) => truncateToWidth(line, Math.max(1, width)));
33
- }
34
-
35
- export function renderFullPlan(plan: PlanSnapshot, width: number, theme: PlanRenderTheme, selectedIndex = -1): string[] {
36
- const progress = planProgress(plan);
37
- const heading = `Practical Plan · ${progress.completed}/${progress.total} completed`;
38
- const lines = [theme.fg("accent", rule(heading, width)), ""];
39
- for (let index = 0; index < plan.steps.length; index += 1) {
40
- const item = plan.steps[index]!;
41
- const prefix = index === selectedIndex ? theme.fg("accent", "› ") : " ";
42
- lines.push(`${prefix}${stepGlyph(item, theme)} ${String(index + 1).padStart(2, " ")} ${stepText(item, theme)}`);
43
- }
44
- lines.push("", theme.fg("dim", rule("", width)));
45
- return lines.map((line) => truncateToWidth(line, Math.max(1, width)));
46
- }
47
-
48
- export function createFullPlanComponent(
49
- plan: PlanSnapshot,
50
- theme: PlanRenderTheme,
51
- onClose: () => void,
52
- ): Component {
53
- let selected = planProgress(plan).activeIndex ?? firstIncompleteIndex(plan);
10
+ function presentPlan(plan: PlanSnapshot): PlanPresentation {
54
11
  return {
55
- render: (width) => renderFullPlan(plan, width, theme, selected),
56
- handleInput(data) {
57
- if (matchesKey(data, "up")) selected = clampIndex(selected - 1, plan.steps.length);
58
- else if (matchesKey(data, "down")) selected = clampIndex(selected + 1, plan.steps.length);
59
- else if (matchesKey(data, "escape") || matchesKey(data, "left") || matchesKey(data, "ctrl+c")) onClose();
60
- },
61
- invalidate() {},
12
+ rows: plan.steps.map((item, index) => ({
13
+ label: String(index + 1), title: item.step, status: item.status,
14
+ })),
62
15
  };
63
16
  }
64
17
 
65
- function firstIncompleteIndex(plan: PlanSnapshot): number {
66
- const index = plan.steps.findIndex((item) => item.status !== "completed");
67
- return index >= 0 ? index : Math.max(0, plan.steps.length - 1);
68
- }
69
-
70
- function clampIndex(index: number, total: number): number {
71
- return Math.min(Math.max(0, index), Math.max(0, total - 1));
18
+ export function renderCompactPlan(plan: PlanSnapshot, width: number, theme: PlanRenderTheme): string[] {
19
+ return renderPlanPresentation(presentPlan(plan), width, theme);
72
20
  }
73
21
 
74
- function stepGlyph(item: PlanStep, theme: PlanRenderTheme): string {
75
- switch (item.status) {
76
- case "completed": return theme.fg("success", "✓");
77
- case "in_progress": return theme.fg("accent", "●");
78
- case "blocked": return theme.fg("warning", "!");
79
- case "pending": return theme.fg("dim", "○");
80
- }
22
+ export function renderFullPlan(plan: PlanSnapshot, width: number, theme: PlanRenderTheme, selectedIndex = -1): string[] {
23
+ return renderPlanPresentation(presentPlan(plan), width, theme, true, selectedIndex);
81
24
  }
82
25
 
83
- function stepText(item: PlanStep, theme: PlanRenderTheme): string {
84
- if (item.status === "completed" || item.status === "pending") return theme.fg("muted", item.step);
85
- if (item.status === "blocked") return theme.fg("warning", `BLOCKED · ${item.step}`);
86
- return item.step;
26
+ export function createFullPlanComponent(plan: PlanSnapshot, theme: PlanRenderTheme, onClose: () => void): Component {
27
+ return createPlanPresentationComponent(presentPlan(plan), theme, onClose);
87
28
  }
88
-
89
- function rule(label: string, width: number): string {
90
- const size = Math.max(1, Math.floor(width));
91
- if (!label) return "━".repeat(size);
92
- const prefix = `━━ ${label} `;
93
- return prefix + "━".repeat(Math.max(0, size - prefix.length));
94
- }
@@ -1,7 +1,10 @@
1
1
  import { readFileSync, realpathSync, statSync } from "node:fs";
2
2
  import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
3
3
  import type { Component } from "@earendil-works/pi-tui";
4
- import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
4
+ import {
5
+ createPlanPresentationComponent, plainPlanTheme, planText, renderPlanPresentation, statusStyle,
6
+ type DisplayStatus, type PlanPresentation,
7
+ } from "./plan-presentation.js";
5
8
 
6
9
  export const WORKFLOW_PLAN_ENTRY = "pi-better-workflow-plan";
7
10
 
@@ -98,29 +101,58 @@ export function workflowBinding(entries: Iterable<{ type: string; customType?: s
98
101
  return binding;
99
102
  }
100
103
 
101
- export function renderRushPlan(plan: RushPlan, width: number, full = false): string[] {
102
- 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}` : ""}`];
104
- const fleet = ["explore", "combine", "canary", "review", "cicd"]
105
- .map((stage) => `${stage}: ${stage === "canary" && !plan.warehouseCanaryRequired ? "n/a" : plan.fleet[stage]?.status ?? "pending"}`);
106
- lines.push(`fleet ${fleet.join(" ")}`);
107
- for (const unit of plan.issues) {
108
- const marker = unit.status === "succeeded" ? "✓" : unit.status === "blocked" ? "!" : unit.status === "pending" ? "○" : "●";
109
- lines.push(`${marker} #${unit.id} ${unit.title} [${unit.stage} · ${unit.status}${unit.worker !== undefined ? ` · worker ${unit.worker}` : ""}]`);
110
- if (full) {
111
- if (unit.dependsOn.length) lines.push(` after: ${unit.dependsOn.map((id) => `#${id}`).join(", ")}`);
112
- if (unit.note) lines.push(` ${unit.note}`);
113
- }
104
+ function workflowStatus(status: string): DisplayStatus {
105
+ switch (status) {
106
+ case "succeeded": case "completed": return "completed";
107
+ case "in-flight": case "in_progress": case "running": case "diagnosing": return "in_progress";
108
+ case "pending": return "pending";
109
+ case "blocked": return "blocked";
110
+ case "failed": return "failed";
111
+ case "skipped": case "not-applicable": case "cancelled": return "skipped";
112
+ default: return "unknown";
114
113
  }
115
- return lines.map((line) => truncateToWidth(line, Math.max(1, width)));
116
114
  }
117
115
 
118
- export function createRushPlanComponent(plan: RushPlan, onClose: () => void): Component {
116
+ function presentRushPlan(plan: RushPlan): PlanPresentation {
117
+ const fleet = ["explore", "combine", "canary", "review", "cicd"].map((stage) => {
118
+ const status = stage === "canary" && !plan.warehouseCanaryRequired
119
+ ? "not-applicable" : plan.fleet[stage]?.status ?? "pending";
120
+ const display = workflowStatus(status);
121
+ const style = statusStyle(display);
122
+ return { color: style.color, text: ` ${stage} ${style.glyph}${display === "unknown" ? ` ${planText(status)}` : ""}` };
123
+ });
124
+ const metadata = [[{ color: "dim", text: `rush-issues · rev ${plan.planRevision} ·` }, ...fleet]];
125
+ if (plan.spec?.title) metadata.push([{ color: "dim", text: plan.spec.title }]);
119
126
  return {
120
- render: (width) => renderRushPlan(plan, width, true),
121
- handleInput(data) {
122
- if (matchesKey(data, "escape") || matchesKey(data, "left") || matchesKey(data, "ctrl+c")) onClose();
123
- },
124
- invalidate() {},
127
+ metadata,
128
+ rows: plan.issues.map((unit) => {
129
+ const status = workflowStatus(unit.status);
130
+ const details = [`${unit.stage} · ${unit.status}${unit.worker !== undefined ? ` · worker ${unit.worker}` : ""}`];
131
+ if (unit.dependsOn.length) details.push(`after: ${unit.dependsOn.map((id) => `#${id}`).join(", ")}`);
132
+ if (unit.note) details.push(unit.note);
133
+ return {
134
+ label: `#${unit.id}`, title: unit.title, status, details,
135
+ ...(status === "unknown" || unit.status === "cancelled" ? { statusLabel: unit.status } : {}),
136
+ };
137
+ }),
125
138
  };
139
+ }
140
+
141
+ export function renderRushPlan(
142
+ plan: RushPlan,
143
+ width: number,
144
+ full = false,
145
+ fg?: (color: string, value: string) => string,
146
+ ): string[] {
147
+ const theme = fg ? { fg } : plainPlanTheme;
148
+ return renderPlanPresentation(presentRushPlan(plan), width, theme, full);
149
+ }
150
+
151
+ export function createRushPlanComponent(
152
+ plan: RushPlan,
153
+ onClose: () => void,
154
+ fg?: (color: string, value: string) => string,
155
+ ): Component {
156
+ const theme = fg ? { fg } : plainPlanTheme;
157
+ return createPlanPresentationComponent(presentRushPlan(plan), theme, onClose);
126
158
  }
@@ -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.18",
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.4",
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": [