pi-plans 0.1.2 → 0.3.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 +98 -19
- package/index.ts +147 -66
- package/package.json +7 -1
- package/references/pi-planning-workflow.md +21 -3
- package/references/state-and-config.md +35 -3
- package/scripts/validate.ts +4 -0
- package/src/autocomplete.ts +163 -0
- package/src/code-graph/commands.ts +437 -0
- package/src/code-graph/discovery.ts +118 -0
- package/src/code-graph/git.ts +108 -0
- package/src/code-graph/identity.ts +59 -0
- package/src/code-graph/indexer.ts +281 -0
- package/src/code-graph/materialize.ts +166 -0
- package/src/code-graph/mode.ts +28 -0
- package/src/code-graph/mutations.ts +160 -0
- package/src/code-graph/parser.ts +51 -0
- package/src/code-graph/parsers/javascript.ts +35 -0
- package/src/code-graph/parsers/python.ts +160 -0
- package/src/code-graph/parsers/tree-sitter.ts +316 -0
- package/src/code-graph/paths.ts +85 -0
- package/src/code-graph/prompts.ts +18 -0
- package/src/code-graph/resolver.ts +69 -0
- package/src/code-graph/runtime.ts +158 -0
- package/src/code-graph/schema.ts +135 -0
- package/src/code-graph/screening.ts +82 -0
- package/src/code-graph/store.ts +278 -0
- package/src/code-graph/summary.ts +435 -0
- package/src/code-graph/types.ts +163 -0
- package/src/compaction.ts +1256 -0
- package/src/config-command.ts +326 -0
- package/src/exec.ts +519 -625
- package/src/plan.ts +37 -0
- package/src/query-hook.ts +82 -0
- package/src/refine-prompts.ts +50 -0
- package/src/refine-ui-helpers.ts +142 -0
- package/src/refine-ui-state.ts +144 -0
- package/src/refine-ui.ts +430 -0
- package/src/state.ts +24 -29
- package/src/subagent.ts +299 -70
- package/tests/ask-choice.test.ts +263 -0
- package/tests/autocomplete.test.ts +147 -0
- package/tests/code-graph-apply.test.ts +185 -0
- package/tests/code-graph-commands.test.ts +211 -0
- package/tests/code-graph-db.test.ts +166 -0
- package/tests/code-graph-discovery.test.ts +38 -0
- package/tests/code-graph-git.test.ts +94 -0
- package/tests/code-graph-index.test.ts +175 -0
- package/tests/code-graph-loop.e2e.test.ts +159 -0
- package/tests/code-graph-mutations.test.ts +117 -0
- package/tests/code-graph-parser.test.ts +85 -0
- package/tests/code-graph-rollback.test.ts +100 -0
- package/tests/code-graph-summary-batching.test.ts +518 -0
- package/tests/code-graph-summary.test.ts +148 -0
- package/tests/compaction.test.ts +388 -0
- package/tests/config-command.test.ts +255 -0
- package/tests/exec.test.ts +751 -422
- package/tests/execute-plan.test.ts +65 -0
- package/tests/fixtures/code-graph/sample.js +36 -0
- package/tests/fixtures/code-graph/sample.py +20 -0
- package/tests/fixtures/code-graph/sample.ts +15 -0
- package/tests/graph-aware-file-tools.test.ts +411 -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-prompts.test.ts +67 -2
- package/tests/refine-ui.test.ts +392 -0
- package/tests/state.test.ts +12 -15
- package/tests/subagent.test.ts +120 -0
- package/tools/ask-choice.ts +180 -11
- package/tools/code-graph.ts +254 -0
- package/tools/execute-plan.ts +7 -39
- package/tools/graph-aware-file-tools.ts +392 -0
- package/tools/plans.ts +84 -18
- package/tools/refine.ts +180 -80
- package/src/execution-panel.ts +0 -633
- package/tests/execution-panel.test.ts +0 -234
package/src/plan.ts
CHANGED
|
@@ -35,6 +35,43 @@ export function scanDoneMarkers(text: string): string[] {
|
|
|
35
35
|
return [...text.matchAll(/\[DONE:(VC-\d+)\]/g)].map((match) => match[1]);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
export interface CurrentIMarker {
|
|
39
|
+
id: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Extract current implementation-item anchors from an assistant message. */
|
|
43
|
+
export function scanCurrentIMarkers(text: string): CurrentIMarker[] {
|
|
44
|
+
return [...text.matchAll(/\[(I-\d+):current\]/g)].map((match) => ({ id: match[1] }));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const scanCurrentMarkers = scanCurrentIMarkers;
|
|
48
|
+
|
|
49
|
+
/** Resolve the last known current-I marker, ignoring unknown ids. */
|
|
50
|
+
export function resolveCurrentI(
|
|
51
|
+
implItems: ImplItem[],
|
|
52
|
+
markers: CurrentIMarker[] | string[],
|
|
53
|
+
fallback?: string,
|
|
54
|
+
): string | undefined {
|
|
55
|
+
const known = new Set(implItems.map((item) => item.id));
|
|
56
|
+
let current: string | undefined;
|
|
57
|
+
for (const marker of markers) {
|
|
58
|
+
const id = typeof marker === "string" ? marker : marker.id;
|
|
59
|
+
if (known.has(id)) current = id;
|
|
60
|
+
}
|
|
61
|
+
return current ?? fallback;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Deterministic frontier used by snapshots written before currentI existed. */
|
|
65
|
+
export function inferCurrentI(
|
|
66
|
+
implItems: ImplItem[] | undefined,
|
|
67
|
+
items: CheckItem[],
|
|
68
|
+
implStatus: Record<string, ImplMarkerState> | undefined,
|
|
69
|
+
): string | undefined {
|
|
70
|
+
if (!implItems?.length) return undefined;
|
|
71
|
+
const statuses = resolveImplStatuses(implItems, items, implStatus);
|
|
72
|
+
return implItems.find((item) => statuses[item.id] !== "vc-passed")?.id ?? implItems.at(-1)?.id;
|
|
73
|
+
}
|
|
74
|
+
|
|
38
75
|
export type ImplMarkerState = "implemented" | "validating";
|
|
39
76
|
|
|
40
77
|
export interface ImplMarker {
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext, InputEvent } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export const QUERY_INTERVIEW_MESSAGE_CUSTOM_TYPE = "pi-plans-query-interview";
|
|
4
|
+
export const QUERY_INTERVIEW_MESSAGE =
|
|
5
|
+
"Before implementing a new user request, if any requirement, constraint, expected behavior, or acceptance criterion is unclear, interview the user with focused clarification questions and confirm the answers before making changes. If the request is clear, proceed directly with the implementation.";
|
|
6
|
+
|
|
7
|
+
interface QueryHookState {
|
|
8
|
+
pendingQueries: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type QueryHookSession = { __piPlansQueryHook?: QueryHookState };
|
|
12
|
+
|
|
13
|
+
function sessionCarrier(ctx: ExtensionContext): QueryHookSession | undefined {
|
|
14
|
+
return ctx.sessionManager as unknown as QueryHookSession | undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function ensureState(ctx: ExtensionContext): QueryHookState | undefined {
|
|
18
|
+
const session = sessionCarrier(ctx);
|
|
19
|
+
if (!session) return undefined;
|
|
20
|
+
return (session.__piPlansQueryHook ??= { pendingQueries: 0 });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isOrdinaryExternalQuery(
|
|
24
|
+
input: Pick<InputEvent, "text" | "source" | "streamingBehavior">,
|
|
25
|
+
): boolean {
|
|
26
|
+
const text = input.text.trim();
|
|
27
|
+
return (
|
|
28
|
+
(input.source === "interactive" || input.source === "rpc")
|
|
29
|
+
&& input.streamingBehavior === undefined
|
|
30
|
+
&& text.length > 0
|
|
31
|
+
&& !text.startsWith("/")
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function recordOrdinaryQuery(
|
|
36
|
+
ctx: ExtensionContext,
|
|
37
|
+
input: Pick<InputEvent, "text" | "source" | "streamingBehavior">,
|
|
38
|
+
): boolean {
|
|
39
|
+
if (!isOrdinaryExternalQuery(input)) return false;
|
|
40
|
+
const state = ensureState(ctx);
|
|
41
|
+
if (!state) return false;
|
|
42
|
+
state.pendingQueries += 1;
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function consumeOrdinaryQuery(ctx: ExtensionContext): boolean {
|
|
47
|
+
const session = sessionCarrier(ctx);
|
|
48
|
+
const state = session?.__piPlansQueryHook;
|
|
49
|
+
if (!state || state.pendingQueries <= 0) return false;
|
|
50
|
+
state.pendingQueries -= 1;
|
|
51
|
+
if (state.pendingQueries === 0) delete session.__piPlansQueryHook;
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function resetOrdinaryQueryState(ctx: ExtensionContext): void {
|
|
56
|
+
const session = sessionCarrier(ctx);
|
|
57
|
+
if (session) delete session.__piPlansQueryHook;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function registerQueryInterviewHooks(
|
|
61
|
+
pi: ExtensionAPI,
|
|
62
|
+
suppressForWorkflow: (ctx: ExtensionContext) => boolean,
|
|
63
|
+
): void {
|
|
64
|
+
pi.on("input", async (event, ctx) => {
|
|
65
|
+
recordOrdinaryQuery(ctx, event);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
pi.on("before_agent_start", async (_event, ctx) => {
|
|
69
|
+
if (!consumeOrdinaryQuery(ctx) || suppressForWorkflow(ctx)) return;
|
|
70
|
+
return {
|
|
71
|
+
message: {
|
|
72
|
+
customType: QUERY_INTERVIEW_MESSAGE_CUSTOM_TYPE,
|
|
73
|
+
content: QUERY_INTERVIEW_MESSAGE,
|
|
74
|
+
display: false,
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
80
|
+
resetOrdinaryQueryState(ctx);
|
|
81
|
+
});
|
|
82
|
+
}
|
package/src/refine-prompts.ts
CHANGED
|
@@ -30,6 +30,23 @@ Authority boundary: read-only analysis only. Do not edit, write, delete, commit,
|
|
|
30
30
|
Evidence: inspect the repository with read, grep, find, and ls before judging the plan.${lensLine}${focusLine}${contextLine}`;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/** Shared header for the post-execution implementation review: the
|
|
34
|
+
* accepted plan is the contract, the IMPLEMENTATION in the worktree is
|
|
35
|
+
* under review. Findings must anchor to the plan's goals/acceptance
|
|
36
|
+
* criteria and explicitly assess delivery maturity. */
|
|
37
|
+
function buildImplementationSharedHeader(role: "reviewer" | "criticizer", opts: RefinePromptInput): string {
|
|
38
|
+
const lensLine = role === "reviewer" && opts.lens ? `\nReview lens: ${opts.lens}.` : "";
|
|
39
|
+
const focusLine = opts.focus ? `\n\nSpecific concerns from the main agent: ${opts.focus}` : "";
|
|
40
|
+
const contextLine = opts.context ? `\n\nContext: ${opts.context}` : "";
|
|
41
|
+
return `Goal: ${role === "reviewer" ? "review the implemented result in the worktree against the plan" : "stress-test the implemented result's assumptions"}.
|
|
42
|
+
|
|
43
|
+
Target: ${opts.planPath} (the accepted plan; the IMPLEMENTATION in the worktree is under review)
|
|
44
|
+
|
|
45
|
+
Authority boundary: read-only analysis only. Do not edit, write, delete, commit, push, or spawn subagents.
|
|
46
|
+
|
|
47
|
+
Evidence: inspect the repository with read, grep, find, and ls before judging the implementation. Judge the implementation against the plan's goals, verifier checklist, and acceptance criteria. Explicitly assess delivery maturity: did the executor ship a minimal MVP only, or refine for long-term growth (no stopgaps, long-term architectural decisions, missing tests, technical debt, production readiness)? Calibrate severity accordingly. Out-of-scope improvement ideas are low severity by default and must not be forced into findings.${lensLine}${focusLine}${contextLine}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
33
50
|
export function reviewerLanes(count: number): ReviewerLane[] {
|
|
34
51
|
if (count === 3) return [...REVIEWER_LENSES];
|
|
35
52
|
if (count === 2) return [...REVIEWER_LENSES.slice(0, 2)];
|
|
@@ -68,3 +85,36 @@ Plan file: ${opts.planPath}
|
|
|
68
85
|
${opts.planText}
|
|
69
86
|
---8<--- END PLAN CONTENT ---8<---`;
|
|
70
87
|
}
|
|
88
|
+
|
|
89
|
+
export function buildImplementationReviewerTask(opts: RefinePromptInput): string {
|
|
90
|
+
return `${buildImplementationSharedHeader("reviewer", opts)}
|
|
91
|
+
|
|
92
|
+
Success criteria: return evidence-backed findings or explicitly say the implementation holds up.
|
|
93
|
+
|
|
94
|
+
Output: Markdown, highest severity first. For each finding use this shape:
|
|
95
|
+
- \`F-###\` — severity: high | medium | low; affected plan IDs (e.g. R-001, I-003) or files; evidence: repo path/command that proves it; impact; recommended fix; suggested disposition (accept | reject | needs-discussion).
|
|
96
|
+
|
|
97
|
+
Surface at most five high-priority findings; list lower-severity findings after them. If the implementation holds up, say so explicitly and list what you checked.
|
|
98
|
+
|
|
99
|
+
Plan file: ${opts.planPath}
|
|
100
|
+
|
|
101
|
+
---8<--- PLAN CONTENT ---8<---
|
|
102
|
+
${opts.planText}
|
|
103
|
+
---8<--- END PLAN CONTENT ---8<---`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function buildImplementationCriticizerTask(opts: RefinePromptInput): string {
|
|
107
|
+
return `${buildImplementationSharedHeader("criticizer", opts)}
|
|
108
|
+
|
|
109
|
+
Success criteria: return concrete, answerable questions only; never rewrite the plan or the implementation.
|
|
110
|
+
|
|
111
|
+
Output: Markdown in exactly this shape:
|
|
112
|
+
1. A summary of your core criticism in at most three sentences, highlighting the single most important point.
|
|
113
|
+
2. Then at most five adaptive questions, numbered, each with one line of why it matters. Questions must be answerable by a user with repo access — never rhetorical. Stop earlier if the implementation genuinely holds.
|
|
114
|
+
|
|
115
|
+
Plan file: ${opts.planPath}
|
|
116
|
+
|
|
117
|
+
---8<--- PLAN CONTENT ---8<---
|
|
118
|
+
${opts.planText}
|
|
119
|
+
---8<--- END PLAN CONTENT ---8<---`;
|
|
120
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight, dependency-free replacements for the pi-tui text utilities used
|
|
3
|
+
* by the refine overlay. Keeping these local lets the overlay render correctly
|
|
4
|
+
* in contexts where the pi-tui module is not on the resolver path (notably the
|
|
5
|
+
* standalone `node:test` runs in this repository, since `@earendil-works/pi-tui`
|
|
6
|
+
* is a peer of `@earendil-works/pi-coding-agent` rather than a direct
|
|
7
|
+
* dependency of pi-plans).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const ELLIPSIS = "…";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* ECMA-48 CSI sequence: ESC [ parameter bytes (0x30-0x3F), intermediate bytes
|
|
14
|
+
* (0x20-0x2F), final byte (0x40-0x7E). Matched atomically so styling payloads
|
|
15
|
+
* never leak into width math and are never split mid-sequence.
|
|
16
|
+
*/
|
|
17
|
+
const CSI_PATTERN = /\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]/g;
|
|
18
|
+
|
|
19
|
+
interface AnsiPart {
|
|
20
|
+
kind: "csi" | "text";
|
|
21
|
+
value: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function splitAnsi(text: string): AnsiPart[] {
|
|
25
|
+
const parts: AnsiPart[] = [];
|
|
26
|
+
let last = 0;
|
|
27
|
+
for (const match of text.matchAll(CSI_PATTERN)) {
|
|
28
|
+
const start = match.index ?? 0;
|
|
29
|
+
if (start > last) parts.push({ kind: "text", value: text.slice(last, start) });
|
|
30
|
+
parts.push({ kind: "csi", value: match[0] });
|
|
31
|
+
last = start + match[0].length;
|
|
32
|
+
}
|
|
33
|
+
if (last < text.length) parts.push({ kind: "text", value: text.slice(last) });
|
|
34
|
+
return parts;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function graphemeWidth(text: string): number {
|
|
38
|
+
let width = 0;
|
|
39
|
+
for (const segment of new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(text)) {
|
|
40
|
+
const cp = segment.segment.codePointAt(0) ?? 0;
|
|
41
|
+
if (cp < 0x20) continue;
|
|
42
|
+
if (cp === 0x09) { width += 3; continue; }
|
|
43
|
+
if (cp >= 0x1100 && cp <= 0x115f) { width += 2; continue; }
|
|
44
|
+
if (cp >= 0x2e80 && cp <= 0x9fff) { width += 2; continue; }
|
|
45
|
+
if (cp >= 0xac00 && cp <= 0xd7a3) { width += 2; continue; }
|
|
46
|
+
if (cp >= 0xff00 && cp <= 0xff60) { width += 2; continue; }
|
|
47
|
+
if (cp >= 0x1f300 && cp <= 0x1faff) { width += 2; continue; }
|
|
48
|
+
width += 1;
|
|
49
|
+
}
|
|
50
|
+
return width;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function visibleWidth(text: string): number {
|
|
54
|
+
if (!text) return 0;
|
|
55
|
+
let width = 0;
|
|
56
|
+
for (const part of splitAnsi(text)) {
|
|
57
|
+
if (part.kind === "csi") continue; // escape sequences render at zero width
|
|
58
|
+
width += graphemeWidth(part.value);
|
|
59
|
+
}
|
|
60
|
+
return width;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function truncateToWidth(text: string, maxWidth: number, ellipsis = ELLIPSIS): string {
|
|
64
|
+
const measured = visibleWidth(text);
|
|
65
|
+
if (measured <= maxWidth) return text;
|
|
66
|
+
const ellipsisWidth = ellipsis ? visibleWidth(ellipsis) : 0;
|
|
67
|
+
const target = Math.max(0, maxWidth - ellipsisWidth);
|
|
68
|
+
let result = "";
|
|
69
|
+
let used = 0;
|
|
70
|
+
let exhausted = false;
|
|
71
|
+
for (const part of splitAnsi(text)) {
|
|
72
|
+
if (part.kind === "csi") {
|
|
73
|
+
if (!exhausted) result += part.value; // keep styling runs intact; never split them
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
for (const segment of new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(part.value)) {
|
|
77
|
+
const w = visibleWidth(segment.segment);
|
|
78
|
+
if (used + w > target) {
|
|
79
|
+
exhausted = true;
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
result += segment.segment;
|
|
83
|
+
used += w;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// A kept open SGR (its reset lived past the cut) must not bleed into the
|
|
87
|
+
// ellipsis or anything rendered after this string.
|
|
88
|
+
if (exhausted && result.includes("\x1b[")) result += "\x1b[0m";
|
|
89
|
+
return ellipsis ? `${result}${ellipsis}` : result;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function wrapTextWithAnsi(text: string, maxWidth: number): string[] {
|
|
93
|
+
if (maxWidth <= 0) return [text];
|
|
94
|
+
const normalized = text.replace(/\r\n/g, "\n");
|
|
95
|
+
const words = normalized.split(/(\s+)/);
|
|
96
|
+
const lines: string[] = [];
|
|
97
|
+
let current = "";
|
|
98
|
+
let currentWidth = 0;
|
|
99
|
+
for (const word of words) {
|
|
100
|
+
if (word === "") continue;
|
|
101
|
+
const wordWidth = visibleWidth(word);
|
|
102
|
+
if (wordWidth > maxWidth) {
|
|
103
|
+
// Hard split a single overlong word (ANSI-aware: CSI runs are zero-width and atomic).
|
|
104
|
+
if (current) { lines.push(current); current = ""; currentWidth = 0; }
|
|
105
|
+
let buffer = "";
|
|
106
|
+
let bufferWidth = 0;
|
|
107
|
+
for (const part of splitAnsi(word)) {
|
|
108
|
+
if (part.kind === "csi") {
|
|
109
|
+
buffer += part.value;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
for (const segment of new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(part.value)) {
|
|
113
|
+
const w = visibleWidth(segment.segment);
|
|
114
|
+
if (bufferWidth + w > maxWidth) {
|
|
115
|
+
lines.push(buffer);
|
|
116
|
+
buffer = segment.segment;
|
|
117
|
+
bufferWidth = w;
|
|
118
|
+
} else {
|
|
119
|
+
buffer += segment.segment;
|
|
120
|
+
bufferWidth += w;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (buffer) { lines.push(buffer); buffer = ""; bufferWidth = 0; }
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (currentWidth + wordWidth > maxWidth) {
|
|
128
|
+
lines.push(current.trimEnd());
|
|
129
|
+
current = word.trimStart();
|
|
130
|
+
currentWidth = visibleWidth(current);
|
|
131
|
+
} else {
|
|
132
|
+
current += word;
|
|
133
|
+
currentWidth += wordWidth;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (current.trim().length > 0) lines.push(current.trimEnd());
|
|
137
|
+
return lines;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function matchesEscape(data: string): boolean {
|
|
141
|
+
return data === "\x1b" || data === "\x1b\x1b" || /^(\x1b\[\??\d*[A-Za-z])|(\x1bO[A-Za-z])$/.test(data);
|
|
142
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { SubagentProgressEvent, SubagentResult } from "./subagent.ts";
|
|
2
|
+
|
|
3
|
+
export type RefineOverlayRole = "reviewer" | "criticizer";
|
|
4
|
+
export type RefineLaneStatus = "queued" | "running" | "complete" | "failed" | "cancelled";
|
|
5
|
+
export type RefineTranscriptEntryType = "assistant-text" | "thinking" | "tool-call" | "tool-result" | "diagnostic";
|
|
6
|
+
|
|
7
|
+
export interface RefineTranscriptEntry {
|
|
8
|
+
id: string;
|
|
9
|
+
type: RefineTranscriptEntryType;
|
|
10
|
+
text: string;
|
|
11
|
+
streaming: boolean;
|
|
12
|
+
toolCallId?: string;
|
|
13
|
+
toolName?: string;
|
|
14
|
+
isError?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface RefineLaneState {
|
|
18
|
+
id: string;
|
|
19
|
+
label: string;
|
|
20
|
+
status: RefineLaneStatus;
|
|
21
|
+
phase: string;
|
|
22
|
+
detail: string;
|
|
23
|
+
transcript: RefineTranscriptEntry[];
|
|
24
|
+
currentTurnIndex: number;
|
|
25
|
+
scrollOffset: number;
|
|
26
|
+
followTranscript: boolean;
|
|
27
|
+
viewportHeight: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function statusLabel(status: RefineLaneStatus): string {
|
|
31
|
+
switch (status) {
|
|
32
|
+
case "queued":
|
|
33
|
+
return "queued";
|
|
34
|
+
case "running":
|
|
35
|
+
return "running";
|
|
36
|
+
case "complete":
|
|
37
|
+
return "done";
|
|
38
|
+
case "failed":
|
|
39
|
+
return "failed";
|
|
40
|
+
case "cancelled":
|
|
41
|
+
return "cancelled";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function transcriptId(lane: RefineLaneState, event: Extract<SubagentProgressEvent, { type: "transcript" }>): string {
|
|
46
|
+
return `${lane.currentTurnIndex}:${event.key}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function phaseForEntry(event: Extract<SubagentProgressEvent, { type: "transcript" }>): string {
|
|
50
|
+
switch (event.entryType) {
|
|
51
|
+
case "assistant-text":
|
|
52
|
+
return "responding";
|
|
53
|
+
case "thinking":
|
|
54
|
+
return "thinking";
|
|
55
|
+
case "tool-call":
|
|
56
|
+
return event.toolName ? `tool: ${event.toolName}` : "tool call";
|
|
57
|
+
case "tool-result":
|
|
58
|
+
return event.toolName ? `result: ${event.toolName}` : "tool result";
|
|
59
|
+
case "diagnostic":
|
|
60
|
+
return "diagnostic";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function applyTranscriptEvent(lane: RefineLaneState, event: Extract<SubagentProgressEvent, { type: "transcript" }>): void {
|
|
65
|
+
const id = transcriptId(lane, event);
|
|
66
|
+
let entry = lane.transcript.find((candidate) => candidate.id === id);
|
|
67
|
+
if (!entry && event.entryType === "tool-call" && event.toolCallId) {
|
|
68
|
+
entry = lane.transcript.find((candidate) => candidate.type === "tool-call" && candidate.toolCallId === event.toolCallId);
|
|
69
|
+
}
|
|
70
|
+
if (!entry) {
|
|
71
|
+
entry = { id, type: event.entryType, text: "", streaming: event.streaming };
|
|
72
|
+
lane.transcript.push(entry);
|
|
73
|
+
}
|
|
74
|
+
if (event.update === "append") entry.text += event.text;
|
|
75
|
+
else entry.text = event.text;
|
|
76
|
+
entry.streaming = event.streaming;
|
|
77
|
+
if (event.toolCallId !== undefined) entry.toolCallId = event.toolCallId;
|
|
78
|
+
if (event.toolName !== undefined) entry.toolName = event.toolName;
|
|
79
|
+
if (event.isError !== undefined) entry.isError = event.isError;
|
|
80
|
+
lane.detail = entry.text;
|
|
81
|
+
lane.status = "running";
|
|
82
|
+
lane.phase = phaseForEntry(event);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function applyDiagnostic(lane: RefineLaneState, text: string): void {
|
|
86
|
+
if (!text) return;
|
|
87
|
+
const id = `${lane.currentTurnIndex}:diagnostic`;
|
|
88
|
+
const existing = lane.transcript.find((entry) => entry.id === id);
|
|
89
|
+
if (existing) existing.text += text;
|
|
90
|
+
else lane.transcript.push({ id, type: "diagnostic", text, streaming: true });
|
|
91
|
+
lane.detail = text;
|
|
92
|
+
lane.phase = "diagnostic";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function applyRefineProgress(lane: RefineLaneState, event: SubagentProgressEvent): void {
|
|
96
|
+
if (lane.status === "complete" || lane.status === "failed" || lane.status === "cancelled") return;
|
|
97
|
+
|
|
98
|
+
switch (event.type) {
|
|
99
|
+
case "process":
|
|
100
|
+
lane.phase = event.phase === "started" ? "starting" : "exiting";
|
|
101
|
+
return;
|
|
102
|
+
case "turn":
|
|
103
|
+
if (event.phase === "start") {
|
|
104
|
+
lane.status = "running";
|
|
105
|
+
lane.phase = "thinking";
|
|
106
|
+
lane.currentTurnIndex = event.turnIndex ?? lane.currentTurnIndex + 1;
|
|
107
|
+
} else {
|
|
108
|
+
lane.phase = "waiting";
|
|
109
|
+
}
|
|
110
|
+
return;
|
|
111
|
+
case "transcript":
|
|
112
|
+
applyTranscriptEvent(lane, event);
|
|
113
|
+
return;
|
|
114
|
+
case "stderr":
|
|
115
|
+
applyDiagnostic(lane, event.text);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function applyRefineResult(lane: RefineLaneState, result: SubagentResult): void {
|
|
121
|
+
const finalText = result.output || result.errorMessage || result.stderr || "";
|
|
122
|
+
const finalEntry = [...lane.transcript].reverse().find((entry) => entry.type === "assistant-text");
|
|
123
|
+
if (finalText) {
|
|
124
|
+
if (finalEntry) {
|
|
125
|
+
finalEntry.text = finalText;
|
|
126
|
+
finalEntry.streaming = false;
|
|
127
|
+
} else {
|
|
128
|
+
lane.transcript.push({ id: `${lane.currentTurnIndex}:result`, type: "assistant-text", text: finalText, streaming: false });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
lane.detail = finalText;
|
|
132
|
+
if (result.ok) {
|
|
133
|
+
lane.status = "complete";
|
|
134
|
+
lane.phase = "complete";
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (result.cancelled) {
|
|
138
|
+
lane.status = "cancelled";
|
|
139
|
+
lane.phase = "cancelled";
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
lane.status = "failed";
|
|
143
|
+
lane.phase = result.timedOut ? "timed out" : "failed";
|
|
144
|
+
}
|