pi-plans 0.1.1 → 0.2.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.
@@ -1,499 +0,0 @@
1
- import * as fs from "node:fs";
2
- import { spawnSync } from "node:child_process";
3
- import * as path from "node:path";
4
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
- import type { CheckItem } from "./plan.ts";
6
-
7
- /** SGR runs plus OSC hyperlinks: escaped bytes, no display columns. */
8
- const ANSI_PATTERN = /\x1b\[[0-9;]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
9
-
10
- /** Code points rendered as two terminal columns (East-Asian Wide/Fullwidth). */
11
- function isWideCodePoint(codePoint: number): boolean {
12
- return (
13
- (codePoint >= 0x1100 && codePoint <= 0x115f) || // Hangul Jamo
14
- (codePoint >= 0x2e80 && codePoint <= 0x303e) || // CJK radicals/symbols
15
- (codePoint >= 0x3041 && codePoint <= 0x33ff) || // Hiragana…CJK compatibility
16
- (codePoint >= 0x3400 && codePoint <= 0x4dbf) || // CJK Ext A
17
- (codePoint >= 0x4e00 && codePoint <= 0x9fff) || // CJK Unified
18
- (codePoint >= 0xa000 && codePoint <= 0xa4cf) || // Yi
19
- (codePoint >= 0xa960 && codePoint <= 0xa97f) || // Hangul Jamo Ext-A
20
- (codePoint >= 0xac00 && codePoint <= 0xd7a3) || // Hangul syllables
21
- (codePoint >= 0xf900 && codePoint <= 0xfaff) || // CJK compat ideographs
22
- (codePoint >= 0xfe10 && codePoint <= 0xfe19) || // vertical forms
23
- (codePoint >= 0xfe30 && codePoint <= 0xfe6f) || // CJK compat forms
24
- (codePoint >= 0xff00 && codePoint <= 0xff60) || // fullwidth forms
25
- (codePoint >= 0xffe0 && codePoint <= 0xffe6) || // fullwidth signs
26
- (codePoint >= 0x1f300 && codePoint <= 0x1faff) || // emoji pictographs
27
- (codePoint >= 0x20000 && codePoint <= 0x3fffd) // CJK Ext B…plan 3
28
- );
29
- }
30
-
31
- /** Zero-advance code points: combining marks, selectors, joiners, controls. */
32
- function isZeroWidthCodePoint(codePoint: number): boolean {
33
- return (
34
- codePoint < 0x20 ||
35
- (codePoint >= 0x7f && codePoint <= 0x9f) ||
36
- (codePoint >= 0x0300 && codePoint <= 0x036f) || // combining diacritics
37
- (codePoint >= 0x20d0 && codePoint <= 0x20f0) || // combining symbols
38
- (codePoint >= 0xfe00 && codePoint <= 0xfe0f) || // variation selectors
39
- (codePoint >= 0xe0100 && codePoint <= 0xe01ef) || // variation selectors ext
40
- codePoint === 0x200b || codePoint === 0x200d || codePoint === 0xfeff
41
- );
42
- }
43
-
44
- function codePointColumns(codePoint: number): number {
45
- if (isZeroWidthCodePoint(codePoint)) return 0;
46
- if (isWideCodePoint(codePoint)) return 2;
47
- return 1;
48
- }
49
-
50
- /** Displayed terminal columns of `text`, ignoring escape sequences. */
51
- export function visibleWidth(text: string): number {
52
- let total = 0;
53
- for (const ch of text.replace(ANSI_PATTERN, "")) {
54
- total += codePointColumns(ch.codePointAt(0) ?? 0);
55
- }
56
- return total;
57
- }
58
-
59
- /**
60
- * Truncate styled text to fit within `width` terminal columns.
61
- *
62
- * Reserves one safety column against host/TUI measurement differences so a
63
- * truncated line can never re-trigger the renderer's width assertion.
64
- */
65
- export function truncateAnsi(text: string, width: number): string {
66
- if (width <= 0) return "";
67
- const cap = width - 1;
68
- if (cap < 1) return visibleWidth(text) <= width ? text : "";
69
- const total = visibleWidth(text);
70
- if (total <= cap) return text;
71
- const budget = cap - 1; // keep room for the trailing ellipsis
72
- let out = "";
73
- let used = 0;
74
- let i = 0;
75
- while (i < text.length) {
76
- if (text[i] === "\x1b") {
77
- const rest = text.slice(i);
78
- const sgr = rest.match(/^\x1b\[[0-9;]*[A-Za-z]/);
79
- if (sgr) {
80
- out += sgr[0];
81
- i += sgr[0].length;
82
- continue;
83
- }
84
- const osc = rest.match(/^\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/);
85
- if (osc) {
86
- i += osc[0].length;
87
- continue;
88
- }
89
- }
90
- const codePoint = text.codePointAt(i);
91
- if (codePoint === undefined) break;
92
- const ch = String.fromCodePoint(codePoint);
93
- const columns = codePointColumns(codePoint);
94
- if (used + columns > budget) break;
95
- used += columns;
96
- out += ch;
97
- i += ch.length;
98
- }
99
- return `${out}…\x1b[0m`;
100
- }
101
-
102
- export interface RepoSnapshot {
103
- added: number;
104
- removed: number;
105
- files: number;
106
- }
107
-
108
- export interface ItemDiffSummary extends RepoSnapshot {
109
- paths: string[];
110
- shared?: boolean;
111
- }
112
-
113
- export interface ExecutionPanelItemState {
114
- summary?: ItemDiffSummary;
115
- }
116
-
117
- export interface ExecutionPanelState {
118
- expanded: boolean;
119
- baseline: RepoSnapshot | null;
120
- lastSnapshot: RepoSnapshot | null;
121
- touchedPaths: string[];
122
- itemSummaries: Record<string, ExecutionPanelItemState>;
123
- }
124
-
125
- export interface ExecutionPanelExecutionLike {
126
- planPath: string;
127
- items: CheckItem[];
128
- panel?: ExecutionPanelState;
129
- }
130
-
131
- interface ThemeLike {
132
- fg(color: string, text: string): string;
133
- strikethrough(text: string): string;
134
- }
135
-
136
- interface WidgetLike {
137
- render(width: number): string[];
138
- invalidate(): void;
139
- }
140
-
141
- const WIDGET_ID = "pi-plans-execution";
142
- const PANEL_STATE_ENTRY = "pi-plans-exec-panel";
143
-
144
- function runGit(cwd: string, args: string[]): string {
145
- const result = spawnSync("git", args, { cwd, encoding: "utf8" });
146
- if (result.error) {
147
- return "";
148
- }
149
- return String(result.stdout ?? "").trim();
150
- }
151
-
152
- function normalizePath(cwd: string, raw: string): string {
153
- const trimmed = raw.trim().replace(/[\u0000]+/g, "");
154
- if (!trimmed) return "";
155
- if (trimmed.startsWith("-") || trimmed === "." || trimmed === "..") return "";
156
- const absolute = path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
157
- const relative = path.relative(cwd, absolute);
158
- const safe = relative.startsWith("..") ? path.basename(absolute) : relative;
159
- return safe.split(path.sep).join("/");
160
- }
161
-
162
- function extractPathsFromBash(command: string, cwd: string): string[] {
163
- const values = new Set<string>();
164
- for (const token of command.split(/\s+/)) {
165
- const cleaned = token.replace(/^["'`(<[{]+|["'`)>}\],;]+$/g, "");
166
- if (!cleaned || cleaned.startsWith("-") || cleaned === "." || cleaned === "..") continue;
167
- const looksLikePath =
168
- cleaned.includes("/") ||
169
- cleaned.startsWith(".") ||
170
- cleaned.startsWith("~") ||
171
- /^[A-Za-z0-9._-]+\.[A-Za-z0-9._-]+$/.test(cleaned);
172
- if (!looksLikePath) continue;
173
- const normalized = normalizePath(cwd, cleaned);
174
- if (normalized) values.add(normalized);
175
- }
176
- return [...values];
177
- }
178
-
179
- function parseNumstat(output: string, workdir: string): RepoSnapshot {
180
- let added = 0;
181
- let removed = 0;
182
- let files = 0;
183
- for (const line of output.split("\n")) {
184
- const trimmed = line.trim();
185
- if (!trimmed) continue;
186
- const parts = line.split("\t");
187
- if (parts.length < 3) continue;
188
- const add = Number(parts[0] === "-" ? 0 : parts[0]);
189
- const del = Number(parts[1] === "-" ? 0 : parts[1]);
190
- const filePath = normalizePath(workdir, parts.slice(2).join("\t"));
191
- if (!filePath) continue;
192
- added += Number.isFinite(add) ? add : 0;
193
- removed += Number.isFinite(del) ? del : 0;
194
- files += 1;
195
- }
196
- return { added, removed, files };
197
- }
198
-
199
- export function captureRepoSnapshot(workdir: string): RepoSnapshot {
200
- const tracked = parseNumstat(runGit(workdir, ["diff", "--numstat", "--find-renames=0", "HEAD", "--", "."]), workdir);
201
- const untracked = runGit(workdir, ["ls-files", "-o", "--exclude-standard"]);
202
- let untrackedAdded = 0;
203
- let untrackedFiles = 0;
204
- if (untracked) {
205
- for (const raw of untracked.split("\n")) {
206
- const filePath = normalizePath(workdir, raw);
207
- if (!filePath) continue;
208
- const stats = parseNumstat(runGit(workdir, ["diff", "--numstat", "--no-index", "/dev/null", filePath]), workdir);
209
- untrackedAdded += stats.added;
210
- untrackedFiles += stats.files || 1;
211
- }
212
- }
213
- return {
214
- added: tracked.added + untrackedAdded,
215
- removed: tracked.removed,
216
- files: tracked.files + untrackedFiles,
217
- };
218
- }
219
-
220
- export function subtractSnapshots(previous: RepoSnapshot, current: RepoSnapshot): RepoSnapshot {
221
- return {
222
- added: current.added - previous.added,
223
- removed: current.removed - previous.removed,
224
- files: current.files - previous.files,
225
- };
226
- }
227
-
228
- export function createExecutionPanelState(): ExecutionPanelState {
229
- return {
230
- expanded: false,
231
- baseline: null,
232
- lastSnapshot: null,
233
- touchedPaths: [],
234
- itemSummaries: {},
235
- };
236
- }
237
-
238
- export function cloneExecutionPanelState(state: ExecutionPanelState): ExecutionPanelState {
239
- return {
240
- expanded: state.expanded,
241
- baseline: state.baseline ? { ...state.baseline } : null,
242
- lastSnapshot: state.lastSnapshot ? { ...state.lastSnapshot } : null,
243
- touchedPaths: [...state.touchedPaths],
244
- itemSummaries: Object.fromEntries(
245
- Object.entries(state.itemSummaries).map(([id, item]) => [
246
- id,
247
- item.summary
248
- ? {
249
- summary: {
250
- added: item.summary.added,
251
- removed: item.summary.removed,
252
- files: item.summary.files,
253
- paths: [...item.summary.paths],
254
- shared: item.summary.shared,
255
- },
256
- }
257
- : {},
258
- ]),
259
- ),
260
- };
261
- }
262
-
263
- export function ensurePanelState(execution: ExecutionPanelExecutionLike): ExecutionPanelState {
264
- if (!execution.panel) {
265
- execution.panel = createExecutionPanelState();
266
- }
267
- return execution.panel;
268
- }
269
-
270
- export function attachPanelBaseline(execution: ExecutionPanelExecutionLike, workdir: string): ExecutionPanelState {
271
- const panel = ensurePanelState(execution);
272
- const snapshot = captureRepoSnapshot(workdir);
273
- panel.baseline = snapshot;
274
- panel.lastSnapshot = snapshot;
275
- panel.touchedPaths = [];
276
- panel.itemSummaries = {};
277
- return panel;
278
- }
279
-
280
- export function restorePanelState(raw: unknown): ExecutionPanelState | null {
281
- if (!raw || typeof raw !== "object") return null;
282
- const candidate = raw as Partial<ExecutionPanelState>;
283
- return {
284
- expanded: Boolean(candidate.expanded),
285
- baseline: candidate.baseline && typeof candidate.baseline.added === "number" ? { ...candidate.baseline } : null,
286
- lastSnapshot:
287
- candidate.lastSnapshot && typeof candidate.lastSnapshot.added === "number"
288
- ? { ...candidate.lastSnapshot }
289
- : null,
290
- touchedPaths: Array.isArray(candidate.touchedPaths)
291
- ? candidate.touchedPaths.map((item) => String(item)).filter(Boolean)
292
- : [],
293
- itemSummaries:
294
- candidate.itemSummaries && typeof candidate.itemSummaries === "object"
295
- ? Object.fromEntries(
296
- Object.entries(candidate.itemSummaries).map(([id, item]) => {
297
- const summary = (item as ExecutionPanelItemState | undefined)?.summary;
298
- return [
299
- id,
300
- summary
301
- ? {
302
- summary: {
303
- added: summary.added ?? 0,
304
- removed: summary.removed ?? 0,
305
- files: summary.files ?? 0,
306
- paths: Array.isArray(summary.paths) ? summary.paths.map((pathValue) => String(pathValue)) : [],
307
- shared: summary.shared,
308
- },
309
- }
310
- : {},
311
- ];
312
- }),
313
- )
314
- : {},
315
- };
316
- }
317
-
318
- export function snapshotPanelState(execution: ExecutionPanelExecutionLike): ExecutionPanelState {
319
- return cloneExecutionPanelState(ensurePanelState(execution));
320
- }
321
-
322
- export function setExpanded(execution: ExecutionPanelExecutionLike, expanded: boolean): void {
323
- ensurePanelState(execution).expanded = expanded;
324
- }
325
-
326
- export function toggleExpanded(execution: ExecutionPanelExecutionLike): boolean {
327
- const panel = ensurePanelState(execution);
328
- panel.expanded = !panel.expanded;
329
- return panel.expanded;
330
- }
331
-
332
- export function recordTouchedPaths(execution: ExecutionPanelExecutionLike, paths: string[]): void {
333
- if (!paths.length) return;
334
- const panel = ensurePanelState(execution);
335
- const merged = new Set(panel.touchedPaths);
336
- for (const raw of paths) {
337
- const normalized = raw.trim().replace(/[\\/]+/g, "/").replace(/[\u0000]+/g, "");
338
- if (normalized && normalized !== "." && normalized !== "..") merged.add(normalized);
339
- }
340
- panel.touchedPaths = [...merged];
341
- }
342
-
343
- export function completeCompletedItems(
344
- execution: ExecutionPanelExecutionLike,
345
- workdir: string,
346
- completedIds: string[],
347
- ): ItemDiffSummary | null {
348
- if (!completedIds.length) return null;
349
- const panel = ensurePanelState(execution);
350
- const current = captureRepoSnapshot(workdir);
351
- const previous = panel.lastSnapshot ?? panel.baseline ?? current;
352
- const delta = subtractSnapshots(previous, current);
353
- const summary: ItemDiffSummary = {
354
- added: delta.added,
355
- removed: delta.removed,
356
- files: delta.files,
357
- paths: [...new Set(panel.touchedPaths)],
358
- shared: completedIds.length > 1,
359
- };
360
- for (const id of completedIds) {
361
- panel.itemSummaries[id] = { summary };
362
- }
363
- panel.lastSnapshot = current;
364
- panel.touchedPaths = [];
365
- return summary;
366
- }
367
-
368
- export function clearPanelState(execution: ExecutionPanelExecutionLike): void {
369
- execution.panel = createExecutionPanelState();
370
- }
371
-
372
- function formatSummaryLine(summary: ItemDiffSummary, theme: ThemeLike, width: number): string {
373
- const plus = theme.fg("success", `+${Math.max(summary.added, 0)}`);
374
- const minus = theme.fg("error", `-${Math.max(summary.removed, 0)}`);
375
- const files = theme.fg("muted", `${Math.max(summary.files, 0)} file${Math.max(summary.files, 0) === 1 ? "" : "s"}`);
376
- const marker = summary.shared ? theme.fg("dim", " shared") : "";
377
- return truncateAnsi(` ${plus} ${minus} ${files}${marker}`, width);
378
- }
379
-
380
- function formatPathLine(paths: string[], theme: ThemeLike, width: number): string | null {
381
- if (!paths.length) return null;
382
- return truncateAnsi(` ${theme.fg("dim", paths.join(", "))}`, width);
383
- }
384
-
385
- function renderItemLines(item: CheckItem, summary: ItemDiffSummary | undefined, theme: ThemeLike, width: number): string[] {
386
- const done = item.done;
387
- const checkbox = done ? theme.fg("success", "☑ ") : theme.fg("muted", "☐ ");
388
- const text = done ? theme.strikethrough(item.text) : item.text;
389
- const lines = [truncateAnsi(`${checkbox}${text}`, width)];
390
- if (summary) {
391
- lines.push(formatSummaryLine(summary, theme, width));
392
- const pathLine = formatPathLine(summary.paths, theme, width);
393
- if (pathLine) lines.push(pathLine);
394
- }
395
- return lines;
396
- }
397
-
398
- function renderPanelLines(execution: ExecutionPanelExecutionLike, theme: ThemeLike, width: number): string[] {
399
- // Expanded detail view only: the count/hint live in the bottom status bar,
400
- // so the panel below the editor never repeats them.
401
- const panel = ensurePanelState(execution);
402
- if (!panel.expanded) return [""];
403
- const lines: string[] = [];
404
- for (const item of execution.items) {
405
- const summary = panel.itemSummaries[item.id]?.summary;
406
- lines.push(...renderItemLines(item, summary, theme, width));
407
- }
408
- return lines.length ? lines : [""];
409
- }
410
-
411
- interface WidgetThemeSource {
412
- theme: ThemeLike | null;
413
- }
414
-
415
- let renderCacheWidth: number | null = null;
416
- let renderCacheLines: string[] | null = null;
417
-
418
- function invalidateRenderCache(): void {
419
- renderCacheWidth = null;
420
- renderCacheLines = null;
421
- }
422
-
423
- class ExecutionPanelWidget implements WidgetLike {
424
- private readonly source: WidgetThemeSource;
425
-
426
- constructor(source: WidgetThemeSource) {
427
- this.source = source;
428
- }
429
-
430
- invalidate(): void {
431
- invalidateRenderCache();
432
- }
433
-
434
- render(width: number): string[] {
435
- if (renderCacheWidth === width && renderCacheLines) return renderCacheLines;
436
- const execution = panelRef.current;
437
- const theme = this.source.theme ?? ({ fg: (_c: string, t: string) => t, strikethrough: (t: string) => t } as ThemeLike);
438
- renderCacheLines = execution ? renderPanelLines(execution, theme, width) : [""];
439
- renderCacheWidth = width;
440
- return renderCacheLines;
441
- }
442
- }
443
-
444
- // One live widget instance per session: refreshing invalidates its cache
445
- // instead of tearing down and re-registering the whole widget (which forced
446
- // a full TUI relayout mid-stream). Registration is tied to the host `ctx.ui`
447
- // instance so replacement hosts (extension reload, tests) register afresh.
448
- let panelRef: { current: ExecutionPanelExecutionLike | null } = { current: null };
449
- const themeSource: WidgetThemeSource = { theme: null };
450
- let registeredUi: unknown = null;
451
-
452
- export function refreshExecutionPanel(ctx: ExtensionContext, execution: ExecutionPanelExecutionLike | null): void {
453
- if (!execution || !execution.items.length) {
454
- clearExecutionPanel(ctx);
455
- return;
456
- }
457
- if (!execution.panel?.expanded) {
458
- // Collapsed: the bottom status bar carries the count; no panel widget.
459
- clearExecutionPanel(ctx);
460
- return;
461
- }
462
- panelRef.current = execution;
463
- invalidateRenderCache(); // next render always reflects the latest state
464
- if (registeredUi === ctx.ui) {
465
- // Same host, same widget slot: nothing to re-register.
466
- return;
467
- }
468
- registeredUi = ctx.ui;
469
- ctx.ui.setWidget(
470
- WIDGET_ID,
471
- (_tui, theme) => {
472
- themeSource.theme = theme as ThemeLike;
473
- return new ExecutionPanelWidget(themeSource);
474
- },
475
- { placement: "belowEditor" },
476
- );
477
- }
478
-
479
- export function clearExecutionPanel(ctx: ExtensionContext): void {
480
- panelRef.current = null;
481
- registeredUi = null;
482
- invalidateRenderCache();
483
- ctx.ui.setWidget(WIDGET_ID, undefined);
484
- }
485
-
486
- export function executionPanelEntryData(execution: ExecutionPanelExecutionLike): unknown {
487
- const panel = execution.panel ?? createExecutionPanelState();
488
- return {
489
- expanded: panel.expanded,
490
- baseline: panel.baseline,
491
- lastSnapshot: panel.lastSnapshot,
492
- touchedPaths: [...panel.touchedPaths],
493
- itemSummaries: panel.itemSummaries,
494
- };
495
- }
496
-
497
- export function executionPanelFromEntryData(data: unknown): ExecutionPanelState | null {
498
- return restorePanelState(data);
499
- }
@@ -1,205 +0,0 @@
1
- import * as assert from "node:assert/strict";
2
- import { spawnSync } from "node:child_process";
3
- import * as fs from "node:fs";
4
- import * as os from "node:os";
5
- import * as path from "node:path";
6
- import { after, before, describe, it } from "node:test";
7
- import {
8
- captureRepoSnapshot,
9
- completeCompletedItems,
10
- createExecutionPanelState,
11
- refreshExecutionPanel,
12
- restorePanelState,
13
- subtractSnapshots,
14
- toggleExpanded,
15
- truncateAnsi,
16
- visibleWidth,
17
- } from "../src/execution-panel.ts";
18
-
19
- function initGitRepo(dir: string): void {
20
- fs.mkdirSync(dir, { recursive: true });
21
- const git = (args: string[]): void => {
22
- const result = spawnSync("git", args, { cwd: dir, stdio: "ignore" });
23
- if (result.status !== 0) {
24
- throw new Error(`git ${args.join(" ")} failed`);
25
- }
26
- };
27
- git(["init"]);
28
- git(["config", "user.name", "Pi Plans Test"]);
29
- git(["config", "user.email", "test@example.com"]);
30
- fs.writeFileSync(path.join(dir, "tracked.txt"), "line 1\n");
31
- git(["add", "tracked.txt"]);
32
- git(["commit", "-m", "baseline"]);
33
- }
34
-
35
- describe("execution panel helpers", () => {
36
- let tmpRoot: string;
37
- let repo: string;
38
-
39
- before(() => {
40
- tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-panel-"));
41
- repo = path.join(tmpRoot, "repo");
42
- initGitRepo(repo);
43
- });
44
-
45
- after(() => {
46
- fs.rmSync(tmpRoot, { recursive: true, force: true });
47
- });
48
-
49
- it("captures diff snapshots and restores panel state", () => {
50
- const baseline = captureRepoSnapshot(repo);
51
- fs.appendFileSync(path.join(repo, "tracked.txt"), "line 2\n");
52
- fs.writeFileSync(path.join(repo, "new.txt"), "new file\n");
53
- const current = captureRepoSnapshot(repo);
54
- const delta = subtractSnapshots(baseline, current);
55
- assert.ok(delta.added >= 1);
56
- assert.ok(delta.files >= 1);
57
-
58
- const panel = createExecutionPanelState();
59
- panel.expanded = true;
60
- panel.baseline = baseline;
61
- panel.lastSnapshot = current;
62
- panel.touchedPaths = ["tracked.txt", "new.txt"];
63
- panel.itemSummaries = {
64
- "VC-001": {
65
- summary: {
66
- added: 1,
67
- removed: 0,
68
- files: 2,
69
- paths: ["tracked.txt", "new.txt"],
70
- },
71
- },
72
- };
73
- const restored = restorePanelState(panel);
74
- assert.ok(restored);
75
- assert.equal(restored?.expanded, true);
76
- assert.equal(restored?.baseline?.added, baseline.added);
77
- assert.equal(restored?.itemSummaries["VC-001"]?.summary?.files, 2);
78
- assert.deepEqual(restored?.touchedPaths, ["tracked.txt", "new.txt"]);
79
- });
80
-
81
- it("records completed-item summaries with touched paths", () => {
82
- fs.writeFileSync(path.join(repo, "tracked.txt"), "line 1\n");
83
- fs.rmSync(path.join(repo, "new.txt"), { force: true });
84
- const baseline = captureRepoSnapshot(repo);
85
- fs.appendFileSync(path.join(repo, "tracked.txt"), "line 2\n");
86
- fs.writeFileSync(path.join(repo, "new.txt"), "new file\n");
87
-
88
- const execution = {
89
- planPath: path.join(repo, "PLAN_v1.md"),
90
- items: [{ id: "VC-001", text: "`VC-001` demo item", done: true }],
91
- panel: createExecutionPanelState(),
92
- };
93
- execution.panel.baseline = baseline;
94
- execution.panel.lastSnapshot = baseline;
95
- execution.panel.touchedPaths = ["tracked.txt", "new.txt"];
96
-
97
- const summary = completeCompletedItems(execution as any, repo, ["VC-001"]);
98
- assert.ok(summary);
99
- assert.ok((summary?.added ?? 0) >= 1);
100
- assert.ok((summary?.files ?? 0) >= 1);
101
- assert.deepEqual(execution.panel.itemSummaries["VC-001"]?.summary?.paths, ["tracked.txt", "new.txt"]);
102
- });
103
-
104
- it("toggles expanded state", () => {
105
- const execution = { planPath: path.join(repo, "PLAN_v1.md"), items: [] as any[], panel: createExecutionPanelState() };
106
- assert.equal(toggleExpanded(execution), true);
107
- assert.equal(toggleExpanded(execution), false);
108
- });
109
-
110
- it("reuses one registered widget factory across refreshes", () => {
111
- const execution: any = {
112
- planPath: "/tmp/PLAN_v1.md",
113
- items: [{ id: "VC-001", text: "item", done: false }],
114
- panel: createExecutionPanelState(),
115
- };
116
- const factories: unknown[] = [];
117
- const theme = { fg: (_color: string, text: string) => `\u001b[38;5;2m${text}\u001b[39m`, strikethrough: (text: string) => text };
118
- const ctx = { ui: { setWidget: (_key: string, factory: any) => void factories.push(factory) } } as any;
119
-
120
- // Collapsed execution renders no widget at all — the bottom status bar
121
- // owns the count (each clear call pushes `undefined`).
122
- refreshExecutionPanel(ctx, execution);
123
- refreshExecutionPanel(ctx, execution); // stays cleared while collapsed
124
- assert.equal(factories.length, 2);
125
- assert.equal(factories[0], undefined);
126
- assert.equal(factories[1], undefined);
127
-
128
- // Expanding registers the detail widget exactly once.
129
- toggleExpanded(execution);
130
- refreshExecutionPanel(ctx, execution);
131
- assert.equal(factories.length, 3);
132
- const widget = (factories[2] as any)({}, theme);
133
- assert.ok(widget.render(80).length >= 1, "expanded render should list items");
134
-
135
- refreshExecutionPanel(ctx, execution); // same host: reuse, no re-register
136
- assert.equal(factories.length, 3);
137
- assert.ok(widget.render(80).length >= 1, "invalidate did not pick up latest state");
138
-
139
- // Clearing releases the slot so a future run registers afresh.
140
- refreshExecutionPanel(ctx, null);
141
- refreshExecutionPanel(ctx, execution);
142
- assert.equal(factories.length, 5); // #4 was the explicit clear (undefined)
143
- });
144
- });
145
-
146
- describe("execution panel width safety", () => {
147
- const styledTheme = {
148
- fg: (_color: string, text: string) => `\u001b[38;5;2m${text}\u001b[39m`,
149
- strikethrough: (text: string) => text,
150
- };
151
-
152
- it("measures CJK code points as double width and zero-width runs as none", () => {
153
- assert.equal(visibleWidth("新的"), 4);
154
- assert.equal(visibleWidth("菜单配置"), 8);
155
- assert.equal(visibleWidth("\uFF46\uFF55\uFF4C\uFF4C"), 8); // fullwidth “full”
156
- assert.equal(visibleWidth("e\u0301"), 1); // combining acute
157
- assert.equal(visibleWidth("\u2764\uFE0F"), 1); // variation selector
158
- assert.equal(visibleWidth("a\u001b[31mbc\u001b[0m"), 3); // ANSI ignored
159
- });
160
-
161
- it("truncates styled CJK lines within the requested width", () => {
162
- const cjk = "新的 Nemotron AIME 配置和批量入口能验证 seed vector、model pin、arm set 和 500 budget;" +
163
- "evidence: pr… 后续还有很长的中文描述需要被安全截断。".repeat(6);
164
- for (const width of [20, 40, 138]) {
165
- const line = truncateAnsi(styledTheme.fg("accent", cjk), width);
166
- assert.ok(
167
- visibleWidth(line) <= width,
168
- `width ${width}: rendered ${visibleWidth(line)} columns`,
169
- );
170
- }
171
- });
172
-
173
- it("renders every expanded checklist line inside the terminal width", () => {
174
- const execution: any = {
175
- planPath: "/tmp/PLAN_v1.md",
176
- items: [
177
- {
178
- id: "VC-001",
179
- // Mirrors the crash-log line that rendered at 155 columns in a 138-column terminal.
180
- text: "`VC-001` covers `I-001` 和 `I-003`;pass condition: 新的 Nemotron AIME 配置和批量入口能验证 seed vector、model pin、arm set 和 500 budget;evidence: pr…",
181
- done: false,
182
- },
183
- { id: "VC-002", text: "`VC-002` plain ascii item that is also far too long to fit one line ".repeat(4), done: false },
184
- ],
185
- panel: createExecutionPanelState(),
186
- };
187
- toggleExpanded(execution);
188
-
189
- let factory: ((tui: unknown, theme: unknown) => any) | undefined;
190
- refreshExecutionPanel({ ui: { setWidget: (_key: string, f: any) => void (factory = f) } } as any, execution);
191
- const widget = factory!({}, styledTheme);
192
-
193
- let sawEllipsis = false;
194
- for (const width of [20, 40, 80, 138]) {
195
- for (const line of widget.render(width)) {
196
- if (line.endsWith("…") || line.includes("…\u001b[0m")) sawEllipsis = true;
197
- assert.ok(
198
- visibleWidth(line) <= width,
199
- `width ${width}: rendered ${visibleWidth(line)} columns: ${JSON.stringify(line.slice(0, 60))}`,
200
- );
201
- }
202
- }
203
- assert.ok(sawEllipsis, "expected at least one truncated line with an ellipsis");
204
- });
205
- });