little-coder 1.8.4 → 1.9.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/.pi/extensions/branding/branding.test.ts +42 -0
- package/.pi/extensions/branding/index.ts +56 -10
- package/.pi/extensions/extra-tools/glob.ts +3 -3
- package/.pi/extensions/extra-tools/index.ts +1 -1
- package/.pi/extensions/plan-mode/index.ts +377 -0
- package/.pi/extensions/plan-mode/plan-mode.test.ts +49 -0
- package/.pi/extensions/plan-mode/status.ts +79 -0
- package/.pi/extensions/prompt-history/index.ts +154 -0
- package/.pi/extensions/prompt-history/prompt-history.test.ts +72 -0
- package/.pi/extensions/read-guard-edit/index.ts +89 -0
- package/.pi/extensions/read-guard-edit/read-guard-edit.test.ts +100 -0
- package/.pi/extensions/skill-inject/index.ts +3 -0
- package/.pi/extensions/skill-inject/selector.test.ts +2 -2
- package/.pi/extensions/subagent/index.ts +201 -0
- package/.pi/extensions/subagent/live-spawn.test.ts +47 -0
- package/.pi/extensions/subagent/spawn.test.ts +97 -0
- package/.pi/extensions/subagent/spawn.ts +373 -0
- package/.pi/extensions/subagent/tracker.ts +139 -0
- package/AGENTS.md +5 -0
- package/CHANGELOG.md +23 -0
- package/README.md +17 -3
- package/bin/little-coder.mjs +56 -5
- package/package.json +2 -2
- package/skills/tools/dispatch.md +38 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { deriveSessionName } from "./index.ts";
|
|
3
|
+
|
|
4
|
+
describe("deriveSessionName", () => {
|
|
5
|
+
it("uses at most the first 4 words, with an ellipsis when there are more", () => {
|
|
6
|
+
expect(deriveSessionName("add a dark mode toggle to settings")).toBe("add a dark mode…");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("keeps prompts of 4 words or fewer whole (no ellipsis)", () => {
|
|
10
|
+
expect(deriveSessionName("add dark mode")).toBe("add dark mode");
|
|
11
|
+
expect(deriveSessionName("one two three four")).toBe("one two three four");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("never slices a word mid-way", () => {
|
|
15
|
+
const name = deriveSessionName(
|
|
16
|
+
"implement comprehensive authentication authorization subsystem now please",
|
|
17
|
+
)!;
|
|
18
|
+
// every space-separated token is a complete word from the input
|
|
19
|
+
for (const w of name.replace(/…$/, "").split(" ")) {
|
|
20
|
+
expect("implement comprehensive authentication authorization subsystem now please").toContain(w);
|
|
21
|
+
}
|
|
22
|
+
expect(name.endsWith("…")).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("takes only the first line", () => {
|
|
26
|
+
expect(deriveSessionName("fix the bug\nmore details here")).toBe("fix the bug");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("collapses surrounding whitespace", () => {
|
|
30
|
+
expect(deriveSessionName(" refactor the parser ")).toBe("refactor the parser");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("ignores slash-commands and bash lines", () => {
|
|
34
|
+
expect(deriveSessionName("/resume")).toBeUndefined();
|
|
35
|
+
expect(deriveSessionName("!ls -la")).toBeUndefined();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("returns undefined for empty input", () => {
|
|
39
|
+
expect(deriveSessionName(" ")).toBeUndefined();
|
|
40
|
+
expect(deriveSessionName("")).toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -70,8 +70,28 @@ function buildHeader(theme: Theme): string[] {
|
|
|
70
70
|
return ["", logo, tagline, "", hints, ""];
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
// Derive a short, human session name from the first user prompt. Returns
|
|
74
|
+
// undefined when there's nothing worth naming (empty, or a command/bash line).
|
|
75
|
+
// Kept pure + exported so the slug rules are unit-testable.
|
|
76
|
+
export function deriveSessionName(text: string): string | undefined {
|
|
77
|
+
const trimmed = text.trim();
|
|
78
|
+
// Slash-commands and `!`-bash aren't tasks — don't name the session after them.
|
|
79
|
+
if (!trimmed || trimmed.startsWith("/") || trimmed.startsWith("!")) return undefined;
|
|
80
|
+
// First line only, first 4 words — cut on word boundaries so it never slices
|
|
81
|
+
// a word mid-way. A "…" is appended only if there were more words.
|
|
82
|
+
const firstLine = trimmed.split(/\r?\n/, 1)[0];
|
|
83
|
+
const allWords = firstLine.split(/\s+/).filter(Boolean);
|
|
84
|
+
if (allWords.length === 0) return undefined;
|
|
85
|
+
const words = allWords.slice(0, 4);
|
|
86
|
+
return allWords.length > words.length ? `${words.join(" ")}…` : words.join(" ");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Title shows the session's name once it has one, else the cwd basename — so a
|
|
90
|
+
// `/resume`d or `/name`d session is identifiable in the terminal tab, and
|
|
91
|
+
// switching sessions updates the tab (session_start re-asserts on resume).
|
|
92
|
+
function setTitle(setter: (t: string) => void, cwd: string, sessionName?: string): void {
|
|
93
|
+
const label = sessionName && sessionName.length > 0 ? sessionName : basename(cwd);
|
|
94
|
+
setter(`little-coder · ${label}`);
|
|
75
95
|
}
|
|
76
96
|
|
|
77
97
|
export default function (pi: ExtensionAPI) {
|
|
@@ -82,6 +102,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
82
102
|
// points (interactive-mode.js:1179, 1346, 3971), so re-setting on every
|
|
83
103
|
// turn keeps our "little-coder - <cwd>" winning for the duration of a
|
|
84
104
|
// session.
|
|
105
|
+
const reassertTitle = (ctx: { hasUI: boolean; cwd: string; ui: { setTitle: (t: string) => void } }) => {
|
|
106
|
+
if (!ctx.hasUI) return;
|
|
107
|
+
setTitle(ctx.ui.setTitle.bind(ctx.ui), ctx.cwd, safeGetSessionName(pi));
|
|
108
|
+
};
|
|
109
|
+
|
|
85
110
|
pi.on("session_start", async (_event, ctx) => {
|
|
86
111
|
if (!ctx.hasUI) return;
|
|
87
112
|
|
|
@@ -92,16 +117,37 @@ export default function (pi: ExtensionAPI) {
|
|
|
92
117
|
invalidate() {},
|
|
93
118
|
}));
|
|
94
119
|
|
|
95
|
-
|
|
120
|
+
reassertTitle(ctx);
|
|
96
121
|
});
|
|
97
122
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
123
|
+
// Auto-name an as-yet-unnamed session after the user's first real prompt, so
|
|
124
|
+
// it's identifiable in `/resume` and the tab title without anyone running
|
|
125
|
+
// `/name`. Only genuine interactive typing names a session — never the
|
|
126
|
+
// benchmark RPC path or programmatic follow-ups (thinking-budget nudges,
|
|
127
|
+
// plan-mode synthesis). `/name` still overrides at any time.
|
|
128
|
+
pi.on("input", async (event, ctx) => {
|
|
129
|
+
if ((event as any).source !== "interactive") return;
|
|
130
|
+
if (safeGetSessionName(pi)) return; // already named (auto or via /name)
|
|
131
|
+
const name = deriveSessionName(String((event as any).text ?? ""));
|
|
132
|
+
if (!name) return;
|
|
133
|
+
try {
|
|
134
|
+
pi.setSessionName(name);
|
|
135
|
+
} catch {
|
|
136
|
+
// older SDK without setSessionName — title still falls back to cwd
|
|
137
|
+
}
|
|
138
|
+
reassertTitle(ctx);
|
|
101
139
|
});
|
|
102
140
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
141
|
+
// Pi calls updateTerminalTitle() at turn boundaries (interactive-mode.js),
|
|
142
|
+
// which would clobber ours back to "π - <cwd>"; re-assert at the same points.
|
|
143
|
+
pi.on("turn_start", async (_event, ctx) => reassertTitle(ctx));
|
|
144
|
+
pi.on("turn_end", async (_event, ctx) => reassertTitle(ctx));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function safeGetSessionName(pi: ExtensionAPI): string | undefined {
|
|
148
|
+
try {
|
|
149
|
+
return typeof pi.getSessionName === "function" ? pi.getSessionName() : undefined;
|
|
150
|
+
} catch {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
107
153
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { glob as fsGlob } from "node:fs/promises";
|
|
2
2
|
|
|
3
|
-
// Bounded file globbing. The naive `for await (…glob…) { if (len>=
|
|
3
|
+
// Bounded file globbing. The naive `for await (…glob…) { if (len>=100) break }`
|
|
4
4
|
// only caps MATCHES — it does nothing about the WALK. Run from a huge root
|
|
5
5
|
// (e.g. a home directory with macOS Library / caches / node_modules), fs.glob
|
|
6
6
|
// recursively descends everything, and its internal traversal state grows until
|
|
7
|
-
// the Node process OOMs (heap, not the model's context) — long before
|
|
7
|
+
// the Node process OOMs (heap, not the model's context) — long before 100
|
|
8
8
|
// matches are found if matches are sparse. fs.glob exposes no signal/abort and
|
|
9
9
|
// no depth/scan cap, so we bound it through the one hook it does call for every
|
|
10
10
|
// entry: `exclude`. We use it to (a) prune heavy/irrelevant directories so they
|
|
@@ -45,7 +45,7 @@ export interface GlobOutcome {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
export const DEFAULT_MAX_SCAN = 200_000;
|
|
48
|
-
export const DEFAULT_MAX_MATCHES =
|
|
48
|
+
export const DEFAULT_MAX_MATCHES = 100;
|
|
49
49
|
|
|
50
50
|
export async function globFiles(pattern: string, opts: GlobOptions): Promise<GlobOutcome> {
|
|
51
51
|
const maxScan = opts.maxScan ?? DEFAULT_MAX_SCAN;
|
|
@@ -10,7 +10,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
10
10
|
name: "glob",
|
|
11
11
|
label: "Glob",
|
|
12
12
|
description:
|
|
13
|
-
"Find files matching a glob pattern. Returns a sorted list of matching paths (up to
|
|
13
|
+
"Find files matching a glob pattern. Returns a sorted list of matching paths (up to 100). " +
|
|
14
14
|
"Common dependency/build/cache dirs (node_modules, .git, dist, …) are skipped, and the walk " +
|
|
15
15
|
"is bounded — for a focused search, pass a `path` rather than globbing a whole home directory.",
|
|
16
16
|
parameters: Type.Object({
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
runSubCoder,
|
|
4
|
+
runSubCodersConcurrent,
|
|
5
|
+
truncateReport,
|
|
6
|
+
type SubCoderItem,
|
|
7
|
+
type SubCoderResult,
|
|
8
|
+
} from "../subagent/spawn.ts";
|
|
9
|
+
import { SubCoderTracker } from "../subagent/tracker.ts";
|
|
10
|
+
import { currentModelId } from "../subagent/index.ts";
|
|
11
|
+
import { PlanStatus } from "./status.ts";
|
|
12
|
+
|
|
13
|
+
// Plan Mode — a Claude-Code-style "research, ask, then plan" flow.
|
|
14
|
+
//
|
|
15
|
+
// shift+tab toggles plan mode (an indicator appears below the input). While it
|
|
16
|
+
// is on, submitting a prompt does NOT run a normal coding turn; instead the
|
|
17
|
+
// extension orchestrates:
|
|
18
|
+
// 1. decompose the request into 1-4 exploration tasks (a reasoning sub-coder),
|
|
19
|
+
// 2. dispatch those as read-only explorer sub-coders (isolated context; only
|
|
20
|
+
// their concise reports survive — their transcripts never enter this window),
|
|
21
|
+
// 3. generate 1-3 clarifying questions with suggested answers (a sub-coder),
|
|
22
|
+
// 4. ask them via the UI (with a free-text "Other" option),
|
|
23
|
+
// 5. synthesize the reports + answers into a written plan in the main window,
|
|
24
|
+
// 6. exit plan mode.
|
|
25
|
+
//
|
|
26
|
+
// An extension can't call inference directly, so every reasoning step is a
|
|
27
|
+
// child little-coder (spawned via ../subagent/spawn.ts), and the final plan is
|
|
28
|
+
// injected as a normal turn via pi.sendUserMessage so it lands in the chat.
|
|
29
|
+
//
|
|
30
|
+
// shift+tab is normally pi's thinking-level cycle; extension shortcuts take
|
|
31
|
+
// precedence (custom-editor.js checks them first), so we shadow it and move the
|
|
32
|
+
// thinking-level cycle to alt+t to keep it reachable.
|
|
33
|
+
|
|
34
|
+
const honey = (s: string) => `\x1b[38;2;225;90;31m${s}\x1b[39m`;
|
|
35
|
+
const gray = (s: string) => `\x1b[90m${s}\x1b[39m`;
|
|
36
|
+
const INDICATOR_KEY = "plan-mode";
|
|
37
|
+
|
|
38
|
+
let planModeOn = false;
|
|
39
|
+
let orchestrating = false;
|
|
40
|
+
// True only while the synthesis turn runs — blocks edits/writes so plan mode
|
|
41
|
+
// produces a plan, not changes.
|
|
42
|
+
let planGuardActive = false;
|
|
43
|
+
let currentAbort: AbortController | null = null;
|
|
44
|
+
// Set just before the synthesis turn; consumed by before_agent_start to inject
|
|
45
|
+
// the planning instructions + research into the system prompt (kept out of the
|
|
46
|
+
// visible chat). Null at all other times.
|
|
47
|
+
let pendingSynthesis: { digest: string; answers: string } | null = null;
|
|
48
|
+
// True while the plan-writing turn is in flight; on its agent_end we prompt the
|
|
49
|
+
// user to approve & implement.
|
|
50
|
+
let synthesisActive = false;
|
|
51
|
+
|
|
52
|
+
function indicatorLines(): string[] {
|
|
53
|
+
return [`${honey("◆")} ${honey("PLAN MODE")} ${gray("(shift+tab to exit)")}`];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function setIndicator(ctx: any, on: boolean): void {
|
|
57
|
+
if (!ctx?.hasUI) return;
|
|
58
|
+
ctx.ui.setWidget(INDICATOR_KEY, on ? indicatorLines() : undefined, { placement: "belowEditor" });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Pull the first balanced JSON array out of a model reply (small models love to
|
|
62
|
+
// wrap JSON in prose / fences). Returns [] on failure so callers can fall back.
|
|
63
|
+
export function extractJsonArray(text: string): any[] {
|
|
64
|
+
const start = text.indexOf("[");
|
|
65
|
+
const end = text.lastIndexOf("]");
|
|
66
|
+
if (start < 0 || end <= start) return [];
|
|
67
|
+
try {
|
|
68
|
+
const v = JSON.parse(text.slice(start, end + 1));
|
|
69
|
+
return Array.isArray(v) ? v : [];
|
|
70
|
+
} catch {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function reason(task: string, cwd: string, model: string | undefined, signal: AbortSignal): Promise<string> {
|
|
76
|
+
const r = await runSubCoder({ id: "r", label: "planner", task, cwd, model, signal });
|
|
77
|
+
return r.report;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface ExploreTask {
|
|
81
|
+
label: string;
|
|
82
|
+
task: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function decomposeTargets(
|
|
86
|
+
prompt: string,
|
|
87
|
+
cwd: string,
|
|
88
|
+
model: string | undefined,
|
|
89
|
+
signal: AbortSignal,
|
|
90
|
+
): Promise<ExploreTask[]> {
|
|
91
|
+
const text = await reason(
|
|
92
|
+
`You are PLANNING, not executing — do not write or change anything. Given this user request, ` +
|
|
93
|
+
`list the 1-4 most useful independent areas to investigate before an implementation plan can be written. ` +
|
|
94
|
+
`Output ONLY a JSON array of objects {"label": "<3-4 word name>", "task": "<a specific research instruction ` +
|
|
95
|
+
`for an agent that can read this repo and browse online>"}. No prose.\n\nUser request:\n${prompt}`,
|
|
96
|
+
cwd,
|
|
97
|
+
model,
|
|
98
|
+
signal,
|
|
99
|
+
);
|
|
100
|
+
const parsed = extractJsonArray(text)
|
|
101
|
+
.filter((t) => t && typeof t.task === "string")
|
|
102
|
+
.slice(0, 4)
|
|
103
|
+
.map((t, i) => ({ label: String(t.label || `area ${i + 1}`).slice(0, 24), task: String(t.task) }));
|
|
104
|
+
if (parsed.length > 0) return parsed;
|
|
105
|
+
// Fallback: a single broad exploration of the request itself.
|
|
106
|
+
return [{ label: "explore", task: `Investigate this repository to inform: ${prompt}` }];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
interface Question {
|
|
110
|
+
q: string;
|
|
111
|
+
options: string[];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function generateQuestions(
|
|
115
|
+
prompt: string,
|
|
116
|
+
digest: string,
|
|
117
|
+
cwd: string,
|
|
118
|
+
model: string | undefined,
|
|
119
|
+
signal: AbortSignal,
|
|
120
|
+
): Promise<Question[]> {
|
|
121
|
+
const text = await reason(
|
|
122
|
+
`Based on the user's request and the research findings below, propose 1-3 clarifying questions whose ` +
|
|
123
|
+
`answers would change the implementation plan. For each, give 1-3 short suggested answers. Output ONLY a ` +
|
|
124
|
+
`JSON array of {"q": "<question>", "options": ["<short answer>", ...]}. No prose.\n\n` +
|
|
125
|
+
`User request:\n${prompt}\n\nResearch findings:\n${digest}`,
|
|
126
|
+
cwd,
|
|
127
|
+
model,
|
|
128
|
+
signal,
|
|
129
|
+
);
|
|
130
|
+
return extractJsonArray(text)
|
|
131
|
+
.filter((q) => q && typeof q.q === "string")
|
|
132
|
+
.slice(0, 3)
|
|
133
|
+
.map((q) => ({
|
|
134
|
+
q: String(q.q),
|
|
135
|
+
options: (Array.isArray(q.options) ? q.options : []).map((o: any) => String(o)).filter(Boolean).slice(0, 3),
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function digestReports(results: SubCoderResult[]): string {
|
|
140
|
+
return results
|
|
141
|
+
.map((r) => `### ${r.label}\n${r.exitCode === 0 ? truncateReport(r.report) : `(failed: ${r.errorMessage || "no output"})`}`)
|
|
142
|
+
.join("\n\n");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const OTHER_SENTINEL = "✎ Other (type my own answer)";
|
|
146
|
+
|
|
147
|
+
async function askQuestions(ctx: any, questions: Question[]): Promise<string> {
|
|
148
|
+
const answered: string[] = [];
|
|
149
|
+
for (const q of questions) {
|
|
150
|
+
const options = [...q.options, OTHER_SENTINEL].filter(Boolean);
|
|
151
|
+
let choice: string | undefined;
|
|
152
|
+
try {
|
|
153
|
+
choice = await ctx.ui.select(q.q, options);
|
|
154
|
+
} catch {
|
|
155
|
+
choice = undefined;
|
|
156
|
+
}
|
|
157
|
+
if (choice === undefined) {
|
|
158
|
+
answered.push(`Q: ${q.q}\nA: (skipped)`);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (choice === OTHER_SENTINEL) {
|
|
162
|
+
let typed: string | undefined;
|
|
163
|
+
try {
|
|
164
|
+
typed = await ctx.ui.input(q.q, "Type your answer");
|
|
165
|
+
} catch {
|
|
166
|
+
typed = undefined;
|
|
167
|
+
}
|
|
168
|
+
answered.push(`Q: ${q.q}\nA: ${typed?.trim() || "(no answer)"}`);
|
|
169
|
+
} else {
|
|
170
|
+
answered.push(`Q: ${q.q}\nA: ${choice}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return answered.join("\n\n");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function orchestrate(pi: ExtensionAPI, ctx: any, prompt: string): Promise<void> {
|
|
177
|
+
orchestrating = true;
|
|
178
|
+
const abort = new AbortController();
|
|
179
|
+
currentAbort = abort;
|
|
180
|
+
// One continuous timer for the whole plan-mode process — every phase widget
|
|
181
|
+
// counts from t0, so the user sees total elapsed throughout (not just the
|
|
182
|
+
// per-sub-coder timers).
|
|
183
|
+
const t0 = Date.now();
|
|
184
|
+
const tracker = new SubCoderTracker(ctx, { key: "plan-explorers", totalSince: t0 });
|
|
185
|
+
const status = new PlanStatus(ctx);
|
|
186
|
+
const model = currentModelId(ctx);
|
|
187
|
+
|
|
188
|
+
// ESC (or Ctrl+C) cancels the plan: there's no agent turn running during the
|
|
189
|
+
// research/question phases, so pi's built-in interrupt has nothing to abort —
|
|
190
|
+
// we intercept the raw key ourselves and trip the AbortController.
|
|
191
|
+
let escUnsub: (() => void) | null =
|
|
192
|
+
ctx.ui?.onTerminalInput?.((data: string) => {
|
|
193
|
+
if (data === "\x1b" || data === "\x03") {
|
|
194
|
+
abort.abort();
|
|
195
|
+
return { consume: true };
|
|
196
|
+
}
|
|
197
|
+
return undefined;
|
|
198
|
+
}) ?? null;
|
|
199
|
+
const dropEsc = () => {
|
|
200
|
+
try {
|
|
201
|
+
escUnsub?.();
|
|
202
|
+
} catch {
|
|
203
|
+
/* ignore */
|
|
204
|
+
}
|
|
205
|
+
escUnsub = null;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
// The "submit a request" hint is done — plan mode is now working. Swap it for
|
|
209
|
+
// the animated status line.
|
|
210
|
+
setIndicator(ctx, false);
|
|
211
|
+
try {
|
|
212
|
+
status.start("deciding what to explore…", t0);
|
|
213
|
+
const targets = await decomposeTargets(prompt, ctx.cwd, model, abort.signal);
|
|
214
|
+
if (abort.signal.aborted) return;
|
|
215
|
+
|
|
216
|
+
const items: SubCoderItem[] = targets.map((t, i) => ({
|
|
217
|
+
id: String(i + 1),
|
|
218
|
+
label: t.label,
|
|
219
|
+
task: t.task,
|
|
220
|
+
cwd: ctx.cwd,
|
|
221
|
+
}));
|
|
222
|
+
// Hand the visual off to the tracker for the research phase — running both
|
|
223
|
+
// animated aboveEditor widgets at once made the panel flicker.
|
|
224
|
+
status.stop();
|
|
225
|
+
tracker.begin(items.map((it) => ({ id: it.id, label: it.label })));
|
|
226
|
+
const results = await runSubCodersConcurrent(items, {
|
|
227
|
+
model,
|
|
228
|
+
signal: abort.signal,
|
|
229
|
+
onUpdate: (all) => tracker.update(all),
|
|
230
|
+
});
|
|
231
|
+
tracker.end();
|
|
232
|
+
if (abort.signal.aborted) return;
|
|
233
|
+
|
|
234
|
+
const digest = digestReports(results);
|
|
235
|
+
status.start("preparing clarifying questions…", t0);
|
|
236
|
+
const questions = await generateQuestions(prompt, digest, ctx.cwd, model, abort.signal);
|
|
237
|
+
if (abort.signal.aborted) return;
|
|
238
|
+
|
|
239
|
+
// Questions are ready: stop the animation and stop intercepting ESC so the
|
|
240
|
+
// dialogs (and the synthesis turn after) handle their own keys.
|
|
241
|
+
status.stop();
|
|
242
|
+
dropEsc();
|
|
243
|
+
const answers = questions.length > 0 ? await askQuestions(ctx, questions) : "(no clarifying questions)";
|
|
244
|
+
if (abort.signal.aborted) return;
|
|
245
|
+
|
|
246
|
+
// Hand the synthesis to the main agent so the plan appears in the chat. The
|
|
247
|
+
// user-visible message is their ORIGINAL request; the planning instructions
|
|
248
|
+
// + research digest + answers are injected into this turn's system prompt
|
|
249
|
+
// (see the before_agent_start handler) so they never show in the chat.
|
|
250
|
+
// Edits/writes are blocked during this turn — plan mode produces a plan.
|
|
251
|
+
planGuardActive = true;
|
|
252
|
+
synthesisActive = true;
|
|
253
|
+
pendingSynthesis = { digest, answers };
|
|
254
|
+
ctx.ui?.notify?.("plan mode: writing the plan…", "info");
|
|
255
|
+
pi.sendUserMessage(prompt);
|
|
256
|
+
} catch (e) {
|
|
257
|
+
ctx.ui?.notify?.(`plan mode failed: ${(e as Error)?.message ?? e}`, "error");
|
|
258
|
+
} finally {
|
|
259
|
+
if (abort.signal.aborted) ctx.ui?.notify?.("plan mode cancelled", "info");
|
|
260
|
+
dropEsc();
|
|
261
|
+
status.stop();
|
|
262
|
+
tracker.end();
|
|
263
|
+
orchestrating = false;
|
|
264
|
+
currentAbort = null;
|
|
265
|
+
// One request per plan-mode activation — drop back to normal mode.
|
|
266
|
+
planModeOn = false;
|
|
267
|
+
setIndicator(ctx, false);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export default function (pi: ExtensionAPI) {
|
|
272
|
+
// shift+tab toggles plan mode (shadows pi's thinking-level cycle).
|
|
273
|
+
pi.registerShortcut("shift+tab", {
|
|
274
|
+
description: "Toggle plan mode",
|
|
275
|
+
handler: (ctx: any) => {
|
|
276
|
+
if (orchestrating) return; // mid-plan: ignore toggles
|
|
277
|
+
planModeOn = !planModeOn;
|
|
278
|
+
setIndicator(ctx, planModeOn);
|
|
279
|
+
ctx.ui?.notify?.(planModeOn ? "plan mode on" : "plan mode off", "info");
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// The thinking-level cycle keeps working on alt+t: the launcher rebinds pi's
|
|
284
|
+
// built-in `app.thinking.cycle` from shift+tab to alt+t so shift+tab is free
|
|
285
|
+
// for plan mode (see bin/little-coder.mjs §8b). No extension shortcut needed.
|
|
286
|
+
|
|
287
|
+
// Intercept a submitted prompt while plan mode is on and run the orchestration
|
|
288
|
+
// instead of a normal coding turn.
|
|
289
|
+
pi.on("input", async (event, ctx) => {
|
|
290
|
+
if (!planModeOn) return;
|
|
291
|
+
if ((event as any).source !== "interactive") return;
|
|
292
|
+
const text = String((event as any).text ?? "").trim();
|
|
293
|
+
// Let commands and bash through untouched even in plan mode.
|
|
294
|
+
if (!text || text.startsWith("/") || text.startsWith("!")) return;
|
|
295
|
+
if (orchestrating) {
|
|
296
|
+
(ctx as any).ui?.notify?.("a plan is already in progress…", "warning");
|
|
297
|
+
return { action: "handled" as const };
|
|
298
|
+
}
|
|
299
|
+
// Fire-and-forget: returning {handled} suppresses the normal turn; the
|
|
300
|
+
// orchestration (dialogs, sub-coders, final synthesis) runs after.
|
|
301
|
+
void orchestrate(pi, ctx, text);
|
|
302
|
+
return { action: "handled" as const };
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// Inject the planning instructions + research into the synthesis turn's
|
|
306
|
+
// system prompt, so the chat shows only the user's original request and the
|
|
307
|
+
// model's plan — never the verbose internal instructions.
|
|
308
|
+
pi.on("before_agent_start", async (event) => {
|
|
309
|
+
if (!pendingSynthesis) return;
|
|
310
|
+
const { digest, answers } = pendingSynthesis;
|
|
311
|
+
pendingSynthesis = null;
|
|
312
|
+
const block =
|
|
313
|
+
`\n\n## Plan Mode\n` +
|
|
314
|
+
`The user's message is a request to PLAN, not to implement. Write a concrete, ` +
|
|
315
|
+
`well-structured implementation plan as your reply, using the research findings ` +
|
|
316
|
+
`and the user's answers below. Output the plan as text only — do NOT edit or ` +
|
|
317
|
+
`create files.\n\n` +
|
|
318
|
+
`### Research findings\n${digest}\n\n` +
|
|
319
|
+
`### User's answers to clarifying questions\n${answers}`;
|
|
320
|
+
return { systemPrompt: ((event as any).systemPrompt ?? "") + block };
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// While synthesizing the plan, block any attempt to edit/write files.
|
|
324
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
325
|
+
if (!planGuardActive) return;
|
|
326
|
+
const name = String((event as any).toolName ?? "").toLowerCase();
|
|
327
|
+
if (name !== "edit" && name !== "write") return;
|
|
328
|
+
(ctx as any).ui?.notify?.("harness intervention: plan mode — emit the plan as text, not file changes.", "info");
|
|
329
|
+
return {
|
|
330
|
+
block: true,
|
|
331
|
+
reason: "Plan mode is active: produce the implementation plan as text in your reply. Do NOT edit or create files.",
|
|
332
|
+
};
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// When a turn ends: if it was the plan-synthesis turn, the plan is now on
|
|
336
|
+
// screen — ask the user to approve before implementing. On approval we hand
|
|
337
|
+
// an "implement it" message to the agent with the edit/write guard lifted.
|
|
338
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
339
|
+
planGuardActive = false;
|
|
340
|
+
if (!synthesisActive) return;
|
|
341
|
+
synthesisActive = false;
|
|
342
|
+
let choice: string | undefined;
|
|
343
|
+
try {
|
|
344
|
+
choice = await (ctx as any).ui?.select?.("Plan ready — implement it?", [
|
|
345
|
+
"Approve & implement",
|
|
346
|
+
"Keep planning (don't implement)",
|
|
347
|
+
]);
|
|
348
|
+
} catch {
|
|
349
|
+
choice = undefined;
|
|
350
|
+
}
|
|
351
|
+
if (choice === "Approve & implement") {
|
|
352
|
+
// deliverAs: pi is still settling the just-ended synthesis turn (this
|
|
353
|
+
// agent_end handler is itself part of that processing), so an immediate
|
|
354
|
+
// send is rejected as "already processing" — queue it as a follow-up.
|
|
355
|
+
pi.sendUserMessage("Implement the plan you just described — make the actual file changes now.", {
|
|
356
|
+
deliverAs: "followUp",
|
|
357
|
+
});
|
|
358
|
+
} else {
|
|
359
|
+
(ctx as any).ui?.notify?.(
|
|
360
|
+
"plan not implemented — refine your request, or shift+tab to leave plan mode",
|
|
361
|
+
"info",
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// A new/resumed session resets all plan-mode state.
|
|
367
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
368
|
+
planModeOn = false;
|
|
369
|
+
orchestrating = false;
|
|
370
|
+
planGuardActive = false;
|
|
371
|
+
synthesisActive = false;
|
|
372
|
+
pendingSynthesis = null;
|
|
373
|
+
if (currentAbort) currentAbort.abort();
|
|
374
|
+
currentAbort = null;
|
|
375
|
+
setIndicator(ctx, false);
|
|
376
|
+
});
|
|
377
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { extractJsonArray, digestReports } from "./index.ts";
|
|
3
|
+
import type { SubCoderResult } from "../subagent/spawn.ts";
|
|
4
|
+
|
|
5
|
+
describe("extractJsonArray", () => {
|
|
6
|
+
it("parses a bare JSON array", () => {
|
|
7
|
+
expect(extractJsonArray('[{"label":"a","task":"t"}]')).toEqual([{ label: "a", task: "t" }]);
|
|
8
|
+
});
|
|
9
|
+
it("pulls the array out of surrounding prose / fences", () => {
|
|
10
|
+
const text = 'Here is the plan:\n```json\n[{"q":"why?","options":["a","b"]}]\n```\nThanks!';
|
|
11
|
+
expect(extractJsonArray(text)).toEqual([{ q: "why?", options: ["a", "b"] }]);
|
|
12
|
+
});
|
|
13
|
+
it("returns [] when there is no array", () => {
|
|
14
|
+
expect(extractJsonArray("no json here")).toEqual([]);
|
|
15
|
+
expect(extractJsonArray("")).toEqual([]);
|
|
16
|
+
});
|
|
17
|
+
it("returns [] on malformed JSON rather than throwing", () => {
|
|
18
|
+
expect(extractJsonArray("[ this is not, valid json ]")).toEqual([]);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("digestReports", () => {
|
|
23
|
+
const mk = (over: Partial<SubCoderResult>): SubCoderResult => ({
|
|
24
|
+
id: "1",
|
|
25
|
+
label: "x",
|
|
26
|
+
task: "t",
|
|
27
|
+
exitCode: 0,
|
|
28
|
+
report: "",
|
|
29
|
+
messages: [],
|
|
30
|
+
stderr: "",
|
|
31
|
+
usage: { input: 0, output: 0, cost: 0, turns: 0, contextTokens: 0 },
|
|
32
|
+
...over,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("renders each report under its label heading", () => {
|
|
36
|
+
const out = digestReports([
|
|
37
|
+
mk({ label: "auth", report: "uses JWT" }),
|
|
38
|
+
mk({ label: "db", report: "postgres" }),
|
|
39
|
+
]);
|
|
40
|
+
expect(out).toContain("### auth\nuses JWT");
|
|
41
|
+
expect(out).toContain("### db\npostgres");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("marks failed sub-coders instead of dropping them", () => {
|
|
45
|
+
const out = digestReports([mk({ label: "web", exitCode: 1, errorMessage: "timeout" })]);
|
|
46
|
+
expect(out).toContain("### web");
|
|
47
|
+
expect(out).toContain("failed: timeout");
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Animated single-line status for Plan Mode's reasoning phases ("deciding what
|
|
2
|
+
// to explore…", "preparing clarifying questions…", etc.). Shows a spinner that
|
|
3
|
+
// animates and a running m:ss timer counting total plan-mode time, so the user
|
|
4
|
+
// can see it's working and how long it's taken.
|
|
5
|
+
//
|
|
6
|
+
// Same approach as the sub-coder tracker: string[] re-set on a ~120ms timer
|
|
7
|
+
// (needed to animate the spinner + tick the clock), colored with raw SGR so it
|
|
8
|
+
// doesn't depend on the active theme.
|
|
9
|
+
|
|
10
|
+
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
11
|
+
const honey = (s: string) => `\x1b[38;2;225;90;31m${s}\x1b[39m`;
|
|
12
|
+
const gray = (s: string) => `\x1b[90m${s}\x1b[39m`;
|
|
13
|
+
|
|
14
|
+
function fmtElapsed(ms: number): string {
|
|
15
|
+
const total = Math.max(0, Math.floor(ms / 1000));
|
|
16
|
+
return `${Math.floor(total / 60)}:${(total % 60).toString().padStart(2, "0")}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface StatusUI {
|
|
20
|
+
hasUI: boolean;
|
|
21
|
+
ui: {
|
|
22
|
+
setWidget: (
|
|
23
|
+
key: string,
|
|
24
|
+
content: string[] | undefined,
|
|
25
|
+
options?: { placement?: "aboveEditor" | "belowEditor" },
|
|
26
|
+
) => void;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class PlanStatus {
|
|
31
|
+
private message = "";
|
|
32
|
+
private startMs = 0;
|
|
33
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
34
|
+
private lastFrame = "";
|
|
35
|
+
|
|
36
|
+
constructor(
|
|
37
|
+
private ctx: StatusUI,
|
|
38
|
+
private key = "plan-status",
|
|
39
|
+
private placement: "aboveEditor" | "belowEditor" = "aboveEditor",
|
|
40
|
+
) {}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Begin showing `message` and start animating. Pass `since` (a timestamp) to
|
|
44
|
+
* keep one continuous timer across phases — the elapsed shown counts from
|
|
45
|
+
* `since`, not from when start() was called.
|
|
46
|
+
*/
|
|
47
|
+
start(message: string, since?: number): void {
|
|
48
|
+
if (!this.ctx.hasUI) return;
|
|
49
|
+
this.message = message;
|
|
50
|
+
this.startMs = since ?? Date.now();
|
|
51
|
+
this.render();
|
|
52
|
+
if (!this.timer) this.timer = setInterval(() => this.render(), 120);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Switch the phase message; the timer keeps counting total elapsed. */
|
|
56
|
+
set(message: string): void {
|
|
57
|
+
this.message = message;
|
|
58
|
+
if (this.ctx.hasUI) this.render();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Stop the animation and clear the line. */
|
|
62
|
+
stop(): void {
|
|
63
|
+
if (this.timer) {
|
|
64
|
+
clearInterval(this.timer);
|
|
65
|
+
this.timer = null;
|
|
66
|
+
}
|
|
67
|
+
if (this.ctx.hasUI) this.ctx.ui.setWidget(this.key, undefined, { placement: this.placement });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private render(): void {
|
|
71
|
+
if (!this.ctx.hasUI) return;
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
const frame = SPINNER[Math.floor(now / 100) % SPINNER.length];
|
|
74
|
+
const line = `${honey(frame)} ${this.message} ${gray(fmtElapsed(now - this.startMs))}`;
|
|
75
|
+
if (line === this.lastFrame) return; // diff-guard
|
|
76
|
+
this.lastFrame = line;
|
|
77
|
+
this.ctx.ui.setWidget(this.key, [line], { placement: this.placement });
|
|
78
|
+
}
|
|
79
|
+
}
|