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.
@@ -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
- function setTitleForCwd(setTitle: (t: string) => void, cwd: string): void {
74
- setTitle(`little-coder - ${basename(cwd)}`);
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
- setTitleForCwd(ctx.ui.setTitle.bind(ctx.ui), ctx.cwd);
120
+ reassertTitle(ctx);
96
121
  });
97
122
 
98
- pi.on("turn_start", async (_event, ctx) => {
99
- if (!ctx.hasUI) return;
100
- setTitleForCwd(ctx.ui.setTitle.bind(ctx.ui), ctx.cwd);
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
- pi.on("turn_end", async (_event, ctx) => {
104
- if (!ctx.hasUI) return;
105
- setTitleForCwd(ctx.ui.setTitle.bind(ctx.ui), ctx.cwd);
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>=500) break }`
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 500
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 = 500;
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 500). " +
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
+ }