pi-plans 0.1.2 → 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.
- package/README.md +43 -17
- package/index.ts +34 -59
- package/package.json +1 -1
- package/references/pi-planning-workflow.md +6 -3
- package/references/state-and-config.md +1 -1
- package/src/autocomplete.ts +163 -0
- package/src/compaction.ts +502 -0
- package/src/exec.ts +557 -333
- package/src/plan.ts +37 -0
- package/src/query-hook.ts +82 -0
- package/src/refine-ui-helpers.ts +89 -0
- package/src/refine-ui-state.ts +78 -0
- package/src/refine-ui.ts +322 -0
- package/src/state.ts +9 -27
- package/src/subagent.ts +196 -69
- package/tests/autocomplete.test.ts +142 -0
- package/tests/compaction.test.ts +74 -0
- package/tests/exec.test.ts +153 -248
- package/tests/execute-plan.test.ts +65 -0
- package/tests/plan.test.ts +11 -1
- package/tests/plans.test.ts +6 -5
- package/tests/query-hook.test.ts +82 -0
- package/tests/refine-ui.test.ts +127 -0
- package/tests/state.test.ts +12 -15
- package/tests/subagent.test.ts +114 -0
- package/tools/ask-choice.ts +22 -0
- package/tools/execute-plan.ts +7 -39
- package/tools/plans.ts +1 -18
- package/tools/refine.ts +125 -71
- package/src/execution-panel.ts +0 -633
- package/tests/execution-panel.test.ts +0 -234
package/src/execution-panel.ts
DELETED
|
@@ -1,633 +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 {
|
|
6
|
-
extractCoverage,
|
|
7
|
-
resolveImplStatuses,
|
|
8
|
-
shortImplDescription,
|
|
9
|
-
type CheckItem,
|
|
10
|
-
type ImplDisplayState,
|
|
11
|
-
type ImplItem,
|
|
12
|
-
type ImplMarkerState,
|
|
13
|
-
} from "./plan.ts";
|
|
14
|
-
|
|
15
|
-
/** SGR runs plus OSC hyperlinks: escaped bytes, no display columns. */
|
|
16
|
-
const ANSI_PATTERN = /\x1b\[[0-9;]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
|
|
17
|
-
|
|
18
|
-
/** Code points rendered as two terminal columns (East-Asian Wide/Fullwidth). */
|
|
19
|
-
function isWideCodePoint(codePoint: number): boolean {
|
|
20
|
-
return (
|
|
21
|
-
(codePoint >= 0x1100 && codePoint <= 0x115f) || // Hangul Jamo
|
|
22
|
-
(codePoint >= 0x2e80 && codePoint <= 0x303e) || // CJK radicals/symbols
|
|
23
|
-
(codePoint >= 0x3041 && codePoint <= 0x33ff) || // Hiragana…CJK compatibility
|
|
24
|
-
(codePoint >= 0x3400 && codePoint <= 0x4dbf) || // CJK Ext A
|
|
25
|
-
(codePoint >= 0x4e00 && codePoint <= 0x9fff) || // CJK Unified
|
|
26
|
-
(codePoint >= 0xa000 && codePoint <= 0xa4cf) || // Yi
|
|
27
|
-
(codePoint >= 0xa960 && codePoint <= 0xa97f) || // Hangul Jamo Ext-A
|
|
28
|
-
(codePoint >= 0xac00 && codePoint <= 0xd7a3) || // Hangul syllables
|
|
29
|
-
(codePoint >= 0xf900 && codePoint <= 0xfaff) || // CJK compat ideographs
|
|
30
|
-
(codePoint >= 0xfe10 && codePoint <= 0xfe19) || // vertical forms
|
|
31
|
-
(codePoint >= 0xfe30 && codePoint <= 0xfe6f) || // CJK compat forms
|
|
32
|
-
(codePoint >= 0xff00 && codePoint <= 0xff60) || // fullwidth forms
|
|
33
|
-
(codePoint >= 0xffe0 && codePoint <= 0xffe6) || // fullwidth signs
|
|
34
|
-
(codePoint >= 0x1f300 && codePoint <= 0x1faff) || // emoji pictographs
|
|
35
|
-
(codePoint >= 0x20000 && codePoint <= 0x3fffd) // CJK Ext B…plan 3
|
|
36
|
-
);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** Zero-advance code points: combining marks, selectors, joiners, controls. */
|
|
40
|
-
function isZeroWidthCodePoint(codePoint: number): boolean {
|
|
41
|
-
return (
|
|
42
|
-
codePoint < 0x20 ||
|
|
43
|
-
(codePoint >= 0x7f && codePoint <= 0x9f) ||
|
|
44
|
-
(codePoint >= 0x0300 && codePoint <= 0x036f) || // combining diacritics
|
|
45
|
-
(codePoint >= 0x20d0 && codePoint <= 0x20f0) || // combining symbols
|
|
46
|
-
(codePoint >= 0xfe00 && codePoint <= 0xfe0f) || // variation selectors
|
|
47
|
-
(codePoint >= 0xe0100 && codePoint <= 0xe01ef) || // variation selectors ext
|
|
48
|
-
codePoint === 0x200b || codePoint === 0x200d || codePoint === 0xfeff
|
|
49
|
-
);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function codePointColumns(codePoint: number): number {
|
|
53
|
-
if (isZeroWidthCodePoint(codePoint)) return 0;
|
|
54
|
-
if (isWideCodePoint(codePoint)) return 2;
|
|
55
|
-
return 1;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** Displayed terminal columns of `text`, ignoring escape sequences. */
|
|
59
|
-
export function visibleWidth(text: string): number {
|
|
60
|
-
let total = 0;
|
|
61
|
-
for (const ch of text.replace(ANSI_PATTERN, "")) {
|
|
62
|
-
total += codePointColumns(ch.codePointAt(0) ?? 0);
|
|
63
|
-
}
|
|
64
|
-
return total;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Truncate styled text to fit within `width` terminal columns.
|
|
69
|
-
*
|
|
70
|
-
* Reserves one safety column against host/TUI measurement differences so a
|
|
71
|
-
* truncated line can never re-trigger the renderer's width assertion.
|
|
72
|
-
*/
|
|
73
|
-
export function truncateAnsi(text: string, width: number): string {
|
|
74
|
-
if (width <= 0) return "";
|
|
75
|
-
const cap = width - 1;
|
|
76
|
-
if (cap < 1) return visibleWidth(text) <= width ? text : "";
|
|
77
|
-
const total = visibleWidth(text);
|
|
78
|
-
if (total <= cap) return text;
|
|
79
|
-
const budget = cap - 1; // keep room for the trailing ellipsis
|
|
80
|
-
let out = "";
|
|
81
|
-
let used = 0;
|
|
82
|
-
let i = 0;
|
|
83
|
-
while (i < text.length) {
|
|
84
|
-
if (text[i] === "\x1b") {
|
|
85
|
-
const rest = text.slice(i);
|
|
86
|
-
const sgr = rest.match(/^\x1b\[[0-9;]*[A-Za-z]/);
|
|
87
|
-
if (sgr) {
|
|
88
|
-
out += sgr[0];
|
|
89
|
-
i += sgr[0].length;
|
|
90
|
-
continue;
|
|
91
|
-
}
|
|
92
|
-
const osc = rest.match(/^\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/);
|
|
93
|
-
if (osc) {
|
|
94
|
-
i += osc[0].length;
|
|
95
|
-
continue;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
const codePoint = text.codePointAt(i);
|
|
99
|
-
if (codePoint === undefined) break;
|
|
100
|
-
const ch = String.fromCodePoint(codePoint);
|
|
101
|
-
const columns = codePointColumns(codePoint);
|
|
102
|
-
if (used + columns > budget) break;
|
|
103
|
-
used += columns;
|
|
104
|
-
out += ch;
|
|
105
|
-
i += ch.length;
|
|
106
|
-
}
|
|
107
|
-
return `${out}…\x1b[0m`;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export interface RepoSnapshot {
|
|
111
|
-
added: number;
|
|
112
|
-
removed: number;
|
|
113
|
-
files: number;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export interface ItemDiffSummary extends RepoSnapshot {
|
|
117
|
-
paths: string[];
|
|
118
|
-
shared?: boolean;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
export interface ExecutionPanelItemState {
|
|
122
|
-
summary?: ItemDiffSummary;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
export interface ExecutionPanelState {
|
|
126
|
-
expanded: boolean;
|
|
127
|
-
baseline: RepoSnapshot | null;
|
|
128
|
-
lastSnapshot: RepoSnapshot | null;
|
|
129
|
-
touchedPaths: string[];
|
|
130
|
-
itemSummaries: Record<string, ExecutionPanelItemState>;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
export interface ExecutionPanelExecutionLike {
|
|
134
|
-
planPath: string;
|
|
135
|
-
items: CheckItem[];
|
|
136
|
-
implItems?: ImplItem[];
|
|
137
|
-
implStatus?: Record<string, ImplMarkerState>;
|
|
138
|
-
panel?: ExecutionPanelState;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
interface ThemeLike {
|
|
142
|
-
fg(color: string, text: string): string;
|
|
143
|
-
strikethrough(text: string): string;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
interface WidgetLike {
|
|
147
|
-
render(width: number): string[];
|
|
148
|
-
invalidate(): void;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
const WIDGET_ID = "pi-plans-execution";
|
|
152
|
-
const PANEL_STATE_ENTRY = "pi-plans-exec-panel";
|
|
153
|
-
|
|
154
|
-
function runGit(cwd: string, args: string[]): string {
|
|
155
|
-
const result = spawnSync("git", args, { cwd, encoding: "utf8" });
|
|
156
|
-
if (result.error) {
|
|
157
|
-
return "";
|
|
158
|
-
}
|
|
159
|
-
return String(result.stdout ?? "").trim();
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function normalizePath(cwd: string, raw: string): string {
|
|
163
|
-
const trimmed = raw.trim().replace(/[\u0000]+/g, "");
|
|
164
|
-
if (!trimmed) return "";
|
|
165
|
-
if (trimmed.startsWith("-") || trimmed === "." || trimmed === "..") return "";
|
|
166
|
-
const absolute = path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
|
167
|
-
const relative = path.relative(cwd, absolute);
|
|
168
|
-
const safe = relative.startsWith("..") ? path.basename(absolute) : relative;
|
|
169
|
-
return safe.split(path.sep).join("/");
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function extractPathsFromBash(command: string, cwd: string): string[] {
|
|
173
|
-
const values = new Set<string>();
|
|
174
|
-
for (const token of command.split(/\s+/)) {
|
|
175
|
-
const cleaned = token.replace(/^["'`(<[{]+|["'`)>}\],;]+$/g, "");
|
|
176
|
-
if (!cleaned || cleaned.startsWith("-") || cleaned === "." || cleaned === "..") continue;
|
|
177
|
-
const looksLikePath =
|
|
178
|
-
cleaned.includes("/") ||
|
|
179
|
-
cleaned.startsWith(".") ||
|
|
180
|
-
cleaned.startsWith("~") ||
|
|
181
|
-
/^[A-Za-z0-9._-]+\.[A-Za-z0-9._-]+$/.test(cleaned);
|
|
182
|
-
if (!looksLikePath) continue;
|
|
183
|
-
const normalized = normalizePath(cwd, cleaned);
|
|
184
|
-
if (normalized) values.add(normalized);
|
|
185
|
-
}
|
|
186
|
-
return [...values];
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function parseNumstat(output: string, workdir: string): RepoSnapshot {
|
|
190
|
-
let added = 0;
|
|
191
|
-
let removed = 0;
|
|
192
|
-
let files = 0;
|
|
193
|
-
for (const line of output.split("\n")) {
|
|
194
|
-
const trimmed = line.trim();
|
|
195
|
-
if (!trimmed) continue;
|
|
196
|
-
const parts = line.split("\t");
|
|
197
|
-
if (parts.length < 3) continue;
|
|
198
|
-
const add = Number(parts[0] === "-" ? 0 : parts[0]);
|
|
199
|
-
const del = Number(parts[1] === "-" ? 0 : parts[1]);
|
|
200
|
-
const filePath = normalizePath(workdir, parts.slice(2).join("\t"));
|
|
201
|
-
if (!filePath) continue;
|
|
202
|
-
added += Number.isFinite(add) ? add : 0;
|
|
203
|
-
removed += Number.isFinite(del) ? del : 0;
|
|
204
|
-
files += 1;
|
|
205
|
-
}
|
|
206
|
-
return { added, removed, files };
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
export function captureRepoSnapshot(workdir: string): RepoSnapshot {
|
|
210
|
-
const tracked = parseNumstat(runGit(workdir, ["diff", "--numstat", "--find-renames=0", "HEAD", "--", "."]), workdir);
|
|
211
|
-
const untracked = runGit(workdir, ["ls-files", "-o", "--exclude-standard"]);
|
|
212
|
-
let untrackedAdded = 0;
|
|
213
|
-
let untrackedFiles = 0;
|
|
214
|
-
if (untracked) {
|
|
215
|
-
for (const raw of untracked.split("\n")) {
|
|
216
|
-
const filePath = normalizePath(workdir, raw);
|
|
217
|
-
if (!filePath) continue;
|
|
218
|
-
const stats = parseNumstat(runGit(workdir, ["diff", "--numstat", "--no-index", "/dev/null", filePath]), workdir);
|
|
219
|
-
untrackedAdded += stats.added;
|
|
220
|
-
untrackedFiles += stats.files || 1;
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
return {
|
|
224
|
-
added: tracked.added + untrackedAdded,
|
|
225
|
-
removed: tracked.removed,
|
|
226
|
-
files: tracked.files + untrackedFiles,
|
|
227
|
-
};
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
export function subtractSnapshots(previous: RepoSnapshot, current: RepoSnapshot): RepoSnapshot {
|
|
231
|
-
return {
|
|
232
|
-
added: current.added - previous.added,
|
|
233
|
-
removed: current.removed - previous.removed,
|
|
234
|
-
files: current.files - previous.files,
|
|
235
|
-
};
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
export function createExecutionPanelState(): ExecutionPanelState {
|
|
239
|
-
return {
|
|
240
|
-
expanded: false,
|
|
241
|
-
baseline: null,
|
|
242
|
-
lastSnapshot: null,
|
|
243
|
-
touchedPaths: [],
|
|
244
|
-
itemSummaries: {},
|
|
245
|
-
};
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
export function cloneExecutionPanelState(state: ExecutionPanelState): ExecutionPanelState {
|
|
249
|
-
return {
|
|
250
|
-
expanded: state.expanded,
|
|
251
|
-
baseline: state.baseline ? { ...state.baseline } : null,
|
|
252
|
-
lastSnapshot: state.lastSnapshot ? { ...state.lastSnapshot } : null,
|
|
253
|
-
touchedPaths: [...state.touchedPaths],
|
|
254
|
-
itemSummaries: Object.fromEntries(
|
|
255
|
-
Object.entries(state.itemSummaries).map(([id, item]) => [
|
|
256
|
-
id,
|
|
257
|
-
item.summary
|
|
258
|
-
? {
|
|
259
|
-
summary: {
|
|
260
|
-
added: item.summary.added,
|
|
261
|
-
removed: item.summary.removed,
|
|
262
|
-
files: item.summary.files,
|
|
263
|
-
paths: [...item.summary.paths],
|
|
264
|
-
shared: item.summary.shared,
|
|
265
|
-
},
|
|
266
|
-
}
|
|
267
|
-
: {},
|
|
268
|
-
]),
|
|
269
|
-
),
|
|
270
|
-
};
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
export function ensurePanelState(execution: ExecutionPanelExecutionLike): ExecutionPanelState {
|
|
274
|
-
if (!execution.panel) {
|
|
275
|
-
execution.panel = createExecutionPanelState();
|
|
276
|
-
}
|
|
277
|
-
return execution.panel;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
export function attachPanelBaseline(execution: ExecutionPanelExecutionLike, workdir: string): ExecutionPanelState {
|
|
281
|
-
const panel = ensurePanelState(execution);
|
|
282
|
-
const snapshot = captureRepoSnapshot(workdir);
|
|
283
|
-
panel.baseline = snapshot;
|
|
284
|
-
panel.lastSnapshot = snapshot;
|
|
285
|
-
panel.touchedPaths = [];
|
|
286
|
-
panel.itemSummaries = {};
|
|
287
|
-
return panel;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
export function restorePanelState(raw: unknown): ExecutionPanelState | null {
|
|
291
|
-
if (!raw || typeof raw !== "object") return null;
|
|
292
|
-
const candidate = raw as Partial<ExecutionPanelState>;
|
|
293
|
-
return {
|
|
294
|
-
expanded: Boolean(candidate.expanded),
|
|
295
|
-
baseline: candidate.baseline && typeof candidate.baseline.added === "number" ? { ...candidate.baseline } : null,
|
|
296
|
-
lastSnapshot:
|
|
297
|
-
candidate.lastSnapshot && typeof candidate.lastSnapshot.added === "number"
|
|
298
|
-
? { ...candidate.lastSnapshot }
|
|
299
|
-
: null,
|
|
300
|
-
touchedPaths: Array.isArray(candidate.touchedPaths)
|
|
301
|
-
? candidate.touchedPaths.map((item) => String(item)).filter(Boolean)
|
|
302
|
-
: [],
|
|
303
|
-
itemSummaries:
|
|
304
|
-
candidate.itemSummaries && typeof candidate.itemSummaries === "object"
|
|
305
|
-
? Object.fromEntries(
|
|
306
|
-
Object.entries(candidate.itemSummaries).map(([id, item]) => {
|
|
307
|
-
const summary = (item as ExecutionPanelItemState | undefined)?.summary;
|
|
308
|
-
return [
|
|
309
|
-
id,
|
|
310
|
-
summary
|
|
311
|
-
? {
|
|
312
|
-
summary: {
|
|
313
|
-
added: summary.added ?? 0,
|
|
314
|
-
removed: summary.removed ?? 0,
|
|
315
|
-
files: summary.files ?? 0,
|
|
316
|
-
paths: Array.isArray(summary.paths) ? summary.paths.map((pathValue) => String(pathValue)) : [],
|
|
317
|
-
shared: summary.shared,
|
|
318
|
-
},
|
|
319
|
-
}
|
|
320
|
-
: {},
|
|
321
|
-
];
|
|
322
|
-
}),
|
|
323
|
-
)
|
|
324
|
-
: {},
|
|
325
|
-
};
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
export function snapshotPanelState(execution: ExecutionPanelExecutionLike): ExecutionPanelState {
|
|
329
|
-
return cloneExecutionPanelState(ensurePanelState(execution));
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
export function setExpanded(execution: ExecutionPanelExecutionLike, expanded: boolean): void {
|
|
333
|
-
ensurePanelState(execution).expanded = expanded;
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
export function toggleExpanded(execution: ExecutionPanelExecutionLike): boolean {
|
|
337
|
-
const panel = ensurePanelState(execution);
|
|
338
|
-
panel.expanded = !panel.expanded;
|
|
339
|
-
return panel.expanded;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
export function recordTouchedPaths(execution: ExecutionPanelExecutionLike, paths: string[]): void {
|
|
343
|
-
if (!paths.length) return;
|
|
344
|
-
const panel = ensurePanelState(execution);
|
|
345
|
-
const merged = new Set(panel.touchedPaths);
|
|
346
|
-
for (const raw of paths) {
|
|
347
|
-
const normalized = raw.trim().replace(/[\\/]+/g, "/").replace(/[\u0000]+/g, "");
|
|
348
|
-
if (normalized && normalized !== "." && normalized !== "..") merged.add(normalized);
|
|
349
|
-
}
|
|
350
|
-
panel.touchedPaths = [...merged];
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
export function completeCompletedItems(
|
|
354
|
-
execution: ExecutionPanelExecutionLike,
|
|
355
|
-
workdir: string,
|
|
356
|
-
completedIds: string[],
|
|
357
|
-
): ItemDiffSummary | null {
|
|
358
|
-
if (!completedIds.length) return null;
|
|
359
|
-
const panel = ensurePanelState(execution);
|
|
360
|
-
const current = captureRepoSnapshot(workdir);
|
|
361
|
-
const previous = panel.lastSnapshot ?? panel.baseline ?? current;
|
|
362
|
-
const delta = subtractSnapshots(previous, current);
|
|
363
|
-
const summary: ItemDiffSummary = {
|
|
364
|
-
added: delta.added,
|
|
365
|
-
removed: delta.removed,
|
|
366
|
-
files: delta.files,
|
|
367
|
-
paths: [...new Set(panel.touchedPaths)],
|
|
368
|
-
shared: completedIds.length > 1,
|
|
369
|
-
};
|
|
370
|
-
for (const id of completedIds) {
|
|
371
|
-
panel.itemSummaries[id] = { summary };
|
|
372
|
-
}
|
|
373
|
-
panel.lastSnapshot = current;
|
|
374
|
-
panel.touchedPaths = [];
|
|
375
|
-
return summary;
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
export function clearPanelState(execution: ExecutionPanelExecutionLike): void {
|
|
379
|
-
execution.panel = createExecutionPanelState();
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
function formatSummaryLine(summary: ItemDiffSummary, theme: ThemeLike, width: number): string {
|
|
383
|
-
const plus = theme.fg("success", `+${Math.max(summary.added, 0)}`);
|
|
384
|
-
const minus = theme.fg("error", `-${Math.max(summary.removed, 0)}`);
|
|
385
|
-
const files = theme.fg("muted", `${Math.max(summary.files, 0)} file${Math.max(summary.files, 0) === 1 ? "" : "s"}`);
|
|
386
|
-
const marker = summary.shared ? theme.fg("dim", " shared") : "";
|
|
387
|
-
return truncateAnsi(` ${plus} ${minus} ${files}${marker}`, width);
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
function formatPathLine(paths: string[], theme: ThemeLike, width: number): string | null {
|
|
391
|
-
if (!paths.length) return null;
|
|
392
|
-
return truncateAnsi(` ${theme.fg("dim", paths.join(", "))}`, width);
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
function renderItemLines(item: CheckItem, summary: ItemDiffSummary | undefined, theme: ThemeLike, width: number): string[] {
|
|
396
|
-
const done = item.done;
|
|
397
|
-
const checkbox = done ? theme.fg("success", "☑ ") : theme.fg("muted", "☐ ");
|
|
398
|
-
const text = done ? theme.strikethrough(item.text) : item.text;
|
|
399
|
-
const lines = [truncateAnsi(`${checkbox}${text}`, width)];
|
|
400
|
-
if (summary) {
|
|
401
|
-
lines.push(formatSummaryLine(summary, theme, width));
|
|
402
|
-
const pathLine = formatPathLine(summary.paths, theme, width);
|
|
403
|
-
if (pathLine) lines.push(pathLine);
|
|
404
|
-
}
|
|
405
|
-
return lines;
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
function formatElapsed(startedAt: string): string {
|
|
409
|
-
const total = Math.max(0, Math.floor((Date.now() - Date.parse(startedAt)) / 1000));
|
|
410
|
-
const h = String(Math.floor(total / 3600)).padStart(2, "0");
|
|
411
|
-
const m = String(Math.floor((total % 3600) / 60)).padStart(2, "0");
|
|
412
|
-
const sec = String(total % 60).padStart(2, "0");
|
|
413
|
-
return `${h}:${m}:${sec}`;
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
function formatToks(tokens: number): string {
|
|
417
|
-
const n = Math.max(0, Math.round(tokens));
|
|
418
|
-
return n < 1000 ? String(n) : `${(n / 1000).toFixed(1)}k`;
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
/**
|
|
422
|
-
* Single source for the ⌛ progress count (I-based with zero-coverage
|
|
423
|
-
* exclusion, VC fallback) — shared by the footer line and the panel header.
|
|
424
|
-
*/
|
|
425
|
-
export function computeExecutionProgress(execution: ExecutionPanelExecutionLike & {
|
|
426
|
-
usage?: { inToks: number; outToks: number };
|
|
427
|
-
implStatus?: Record<string, ImplMarkerState>;
|
|
428
|
-
}): { done: number; total: number } {
|
|
429
|
-
const implItems = execution.implItems ?? [];
|
|
430
|
-
if (implItems.length) {
|
|
431
|
-
const statuses = resolveImplStatuses(implItems, execution.items, execution.implStatus);
|
|
432
|
-
const counted = implItems.filter((impl) =>
|
|
433
|
-
execution.items.some((item) => extractCoverage(item.text).includes(impl.id)),
|
|
434
|
-
);
|
|
435
|
-
if (counted.length > 0) {
|
|
436
|
-
return {
|
|
437
|
-
done: counted.filter((impl) => statuses[impl.id] === "vc-passed").length,
|
|
438
|
-
total: counted.length,
|
|
439
|
-
};
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
return {
|
|
443
|
-
done: execution.items.filter((item) => item.done).length,
|
|
444
|
-
total: execution.items.length,
|
|
445
|
-
};
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
/**
|
|
449
|
-
* The `⌛ plans x/y: spent ...` line. Plain text — callers apply theme color.
|
|
450
|
-
* Shared by the footer line (collapsed) and the panel header (expanded).
|
|
451
|
-
*/
|
|
452
|
-
export function formatExecutionStatusLine(
|
|
453
|
-
execution: ExecutionPanelExecutionLike & {
|
|
454
|
-
startedAt: string;
|
|
455
|
-
usage: { inToks: number; outToks: number };
|
|
456
|
-
implStatus?: Record<string, ImplMarkerState>;
|
|
457
|
-
},
|
|
458
|
-
): string {
|
|
459
|
-
const progress = computeExecutionProgress(execution);
|
|
460
|
-
return `⌛ plans ${progress.done}/${progress.total}: spent ${formatElapsed(execution.startedAt)} · ${formatToks(execution.usage.inToks)} in-toks · ${formatToks(execution.usage.outToks)} out-toks`;
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
function renderPanelLines(execution: ExecutionPanelExecutionLike, theme: ThemeLike, width: number): string[] {
|
|
464
|
-
// Expanded detail view only: the collapsed count lives in the footer, and
|
|
465
|
-
// the expanded header re-renders the same line above the item list.
|
|
466
|
-
const panel = ensurePanelState(execution);
|
|
467
|
-
if (!panel.expanded) return [""];
|
|
468
|
-
const lines: string[] = [];
|
|
469
|
-
const header = formatExecutionStatusLine(
|
|
470
|
-
execution as ExecutionPanelExecutionLike & {
|
|
471
|
-
startedAt: string;
|
|
472
|
-
usage: { inToks: number; outToks: number };
|
|
473
|
-
},
|
|
474
|
-
);
|
|
475
|
-
lines.push(truncateAnsi(theme.fg("accent", header), width));
|
|
476
|
-
if (execution.implItems?.length) {
|
|
477
|
-
lines.push(...renderImplGroupedLines(execution, theme, width));
|
|
478
|
-
} else {
|
|
479
|
-
// Legacy fallback: plans without a parsable Implementation Items section.
|
|
480
|
-
for (const item of execution.items) {
|
|
481
|
-
const summary = panel.itemSummaries[item.id]?.summary;
|
|
482
|
-
lines.push(...renderItemLines(item, summary, theme, width));
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
return lines.length ? lines : [""];
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
const IMPL_LABELS: Record<ImplDisplayState, string> = {
|
|
489
|
-
pending: "[Pending]",
|
|
490
|
-
implementing: "[Implementing]",
|
|
491
|
-
implemented: "[Implemented]",
|
|
492
|
-
validating: "[Validating]",
|
|
493
|
-
"vc-passed": "[VC passed]",
|
|
494
|
-
};
|
|
495
|
-
|
|
496
|
-
const IMPL_COLORS: Record<ImplDisplayState, string> = {
|
|
497
|
-
pending: "muted",
|
|
498
|
-
implementing: "accent",
|
|
499
|
-
implemented: "accent",
|
|
500
|
-
validating: "warning",
|
|
501
|
-
"vc-passed": "success",
|
|
502
|
-
};
|
|
503
|
-
|
|
504
|
-
function renderImplGroupedLines(execution: ExecutionPanelExecutionLike, theme: ThemeLike, width: number): string[] {
|
|
505
|
-
const implItems = execution.implItems ?? [];
|
|
506
|
-
const statuses = resolveImplStatuses(implItems, execution.items, execution.implStatus);
|
|
507
|
-
const lines: string[] = [];
|
|
508
|
-
|
|
509
|
-
for (const impl of implItems) {
|
|
510
|
-
const status = statuses[impl.id] ?? "pending";
|
|
511
|
-
let line = `${IMPL_LABELS[status]} ${impl.id}: ${shortImplDescription(impl.text)}`;
|
|
512
|
-
if (status === "vc-passed") {
|
|
513
|
-
// Final state: strike through the entire line.
|
|
514
|
-
line = theme.strikethrough(theme.fg(IMPL_COLORS[status], line));
|
|
515
|
-
} else {
|
|
516
|
-
line = theme.fg(IMPL_COLORS[status], line);
|
|
517
|
-
}
|
|
518
|
-
lines.push(truncateAnsi(line, width));
|
|
519
|
-
}
|
|
520
|
-
return lines;
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
interface WidgetThemeSource {
|
|
524
|
-
theme: ThemeLike | null;
|
|
525
|
-
requestRender: (() => void) | null;
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
let renderCacheWidth: number | null = null;
|
|
529
|
-
let renderCacheLines: string[] | null = null;
|
|
530
|
-
|
|
531
|
-
function invalidateRenderCache(): void {
|
|
532
|
-
renderCacheWidth = null;
|
|
533
|
-
renderCacheLines = null;
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
class ExecutionPanelWidget implements WidgetLike {
|
|
537
|
-
private readonly source: WidgetThemeSource;
|
|
538
|
-
|
|
539
|
-
constructor(source: WidgetThemeSource) {
|
|
540
|
-
this.source = source;
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
invalidate(): void {
|
|
544
|
-
invalidateRenderCache();
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
render(width: number): string[] {
|
|
548
|
-
if (renderCacheWidth === width && renderCacheLines) return renderCacheLines;
|
|
549
|
-
const execution = panelRef.current;
|
|
550
|
-
const theme = this.source.theme ?? ({ fg: (_c: string, t: string) => t, strikethrough: (t: string) => t } as ThemeLike);
|
|
551
|
-
renderCacheLines = execution ? renderPanelLines(execution, theme, width) : [""];
|
|
552
|
-
renderCacheWidth = width;
|
|
553
|
-
return renderCacheLines;
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
// One live widget instance per session: refreshing invalidates its cache
|
|
558
|
-
// instead of tearing down and re-registering the whole widget (which forced
|
|
559
|
-
// a full TUI relayout mid-stream). Registration is tied to the host `ctx.ui`
|
|
560
|
-
// instance so replacement hosts (extension reload, tests) register afresh.
|
|
561
|
-
let panelRef: { current: ExecutionPanelExecutionLike | null } = { current: null };
|
|
562
|
-
const themeSource: WidgetThemeSource = { theme: null, requestRender: null };
|
|
563
|
-
let registeredUi: unknown = null;
|
|
564
|
-
|
|
565
|
-
export function refreshExecutionPanel(ctx: ExtensionContext, execution: ExecutionPanelExecutionLike | null): void {
|
|
566
|
-
if (!execution || !execution.items.length) {
|
|
567
|
-
clearExecutionPanel(ctx);
|
|
568
|
-
return;
|
|
569
|
-
}
|
|
570
|
-
if (!execution.panel?.expanded) {
|
|
571
|
-
// Collapsed: footer carries the ⌛ line; no panel widget is needed. Clear
|
|
572
|
-
// only when a live expanded widget exists, so repeated progress refreshes
|
|
573
|
-
// do not re-register or tear down the widget slot.
|
|
574
|
-
const hadWidget = panelRef.current !== null || registeredUi !== null;
|
|
575
|
-
panelRef.current = null;
|
|
576
|
-
registeredUi = null;
|
|
577
|
-
themeSource.requestRender = null;
|
|
578
|
-
invalidateRenderCache();
|
|
579
|
-
if (hadWidget) ctx.ui.setWidget(WIDGET_ID, undefined);
|
|
580
|
-
ctx.ui.setStatus("pi-plans", ctx.ui.theme.fg("accent", formatExecutionStatusLine(
|
|
581
|
-
execution as ExecutionPanelExecutionLike & {
|
|
582
|
-
startedAt: string;
|
|
583
|
-
usage: { inToks: number; outToks: number };
|
|
584
|
-
},
|
|
585
|
-
)));
|
|
586
|
-
return;
|
|
587
|
-
}
|
|
588
|
-
panelRef.current = execution;
|
|
589
|
-
invalidateRenderCache(); // next render always reflects the latest state
|
|
590
|
-
const sameHost = registeredUi === ctx.ui;
|
|
591
|
-
if (!sameHost) {
|
|
592
|
-
registeredUi = ctx.ui;
|
|
593
|
-
themeSource.requestRender = null;
|
|
594
|
-
ctx.ui.setWidget(
|
|
595
|
-
WIDGET_ID,
|
|
596
|
-
(tui, theme) => {
|
|
597
|
-
themeSource.theme = theme as ThemeLike;
|
|
598
|
-
const render = (tui as { requestRender?: unknown }).requestRender;
|
|
599
|
-
themeSource.requestRender = typeof render === "function" ? () => (render as () => void)() : null;
|
|
600
|
-
return new ExecutionPanelWidget(themeSource);
|
|
601
|
-
},
|
|
602
|
-
{ placement: "belowEditor" },
|
|
603
|
-
);
|
|
604
|
-
}
|
|
605
|
-
// Expanded: the panel renders the ⌛ header itself. Clear the footer copy
|
|
606
|
-
// only when the view is first mounted or the host changes, then request a
|
|
607
|
-
// render for in-place progress updates.
|
|
608
|
-
if (!sameHost) ctx.ui.setStatus("pi-plans", undefined);
|
|
609
|
-
themeSource.requestRender?.();
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
export function clearExecutionPanel(ctx: ExtensionContext): void {
|
|
613
|
-
panelRef.current = null;
|
|
614
|
-
registeredUi = null;
|
|
615
|
-
themeSource.requestRender = null;
|
|
616
|
-
invalidateRenderCache();
|
|
617
|
-
ctx.ui.setWidget(WIDGET_ID, undefined);
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
export function executionPanelEntryData(execution: ExecutionPanelExecutionLike): unknown {
|
|
621
|
-
const panel = execution.panel ?? createExecutionPanelState();
|
|
622
|
-
return {
|
|
623
|
-
expanded: panel.expanded,
|
|
624
|
-
baseline: panel.baseline,
|
|
625
|
-
lastSnapshot: panel.lastSnapshot,
|
|
626
|
-
touchedPaths: [...panel.touchedPaths],
|
|
627
|
-
itemSummaries: panel.itemSummaries,
|
|
628
|
-
};
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
export function executionPanelFromEntryData(data: unknown): ExecutionPanelState | null {
|
|
632
|
-
return restorePanelState(data);
|
|
633
|
-
}
|