little-coder 1.8.3 → 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,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
+ }
@@ -0,0 +1,154 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { CustomEditor } from "@earendil-works/pi-coding-agent";
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+
7
+ // Up-arrow prompt history, persisted across sessions.
8
+ //
9
+ // pi's default editor exposes an `addToHistory` hook (which pi calls on every
10
+ // submit) but ships no actual navigation, so up-arrow does nothing. We provide a
11
+ // custom editor — a subclass of pi's own CustomEditor, so pi copies all the
12
+ // app keybindings, autocomplete, and submit wiring onto it (see
13
+ // interactive-mode.setCustomEditorComponent) — that implements `addToHistory`
14
+ // and recalls history on ↑ / ↓.
15
+ //
16
+ // Why a custom editor and not raw onTerminalInput key-matching: pi runs the
17
+ // Kitty keyboard protocol (flags=7), so arrows arrive as CSI-u sequences with
18
+ // press/repeat/release events. The editor path (a) only sees press/repeat —
19
+ // the TUI filters releases before handleInput — and (b) lets us detect ↑/↓ via
20
+ // keybindings.matches(), which already understands every encoding. Both are
21
+ // brittle to reproduce from raw bytes.
22
+ //
23
+ // History is stored in <agentDir>/little-coder-prompt-history.json so a brand
24
+ // new session (even one with no messages) can recall prompts from earlier runs.
25
+
26
+ const MAX = 100;
27
+
28
+ function agentDir(): string {
29
+ const env = process.env.PI_CODING_AGENT_DIR;
30
+ if (env && env.trim().length > 0) {
31
+ if (env === "~") return homedir();
32
+ if (env.startsWith("~/")) return homedir() + env.slice(1);
33
+ return env;
34
+ }
35
+ return join(homedir(), ".pi", "agent");
36
+ }
37
+
38
+ function historyFile(): string {
39
+ return join(agentDir(), "little-coder-prompt-history.json");
40
+ }
41
+
42
+ export function loadHistory(): string[] {
43
+ try {
44
+ const raw = JSON.parse(readFileSync(historyFile(), "utf-8"));
45
+ if (Array.isArray(raw)) return raw.filter((x) => typeof x === "string").slice(-MAX);
46
+ } catch {
47
+ // missing / unreadable / corrupt — start empty
48
+ }
49
+ return [];
50
+ }
51
+
52
+ function saveHistory(items: string[]): void {
53
+ try {
54
+ const dir = dirname(historyFile());
55
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
56
+ writeFileSync(historyFile(), JSON.stringify(items.slice(-MAX)));
57
+ } catch {
58
+ // best-effort; recall still works in-process this session
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Pure history store. `recall("up"|"down", currentText)` returns the text to
64
+ * place in the editor, or null to let the editor handle the key normally.
65
+ * Navigation only STARTS from an empty prompt (so it never clobbers a draft or
66
+ * fights multi-line cursor movement); once navigating, ↑/↓ walk the list.
67
+ */
68
+ export function makeHistory(initial: string[] = [], persist: (items: string[]) => void = () => {}) {
69
+ const items = initial.slice(-MAX);
70
+ let nav = -1;
71
+
72
+ return {
73
+ items,
74
+ add(text: string) {
75
+ const t = (text ?? "").trim();
76
+ if (!t) return;
77
+ if (items[items.length - 1] !== text) {
78
+ items.push(text);
79
+ while (items.length > MAX) items.shift();
80
+ persist(items);
81
+ }
82
+ nav = -1;
83
+ },
84
+ reset() {
85
+ nav = -1;
86
+ },
87
+ recall(dir: "up" | "down", current: string): string | null {
88
+ if (dir === "up") {
89
+ if (nav === -1) {
90
+ if (current !== "" || items.length === 0) return null; // only from empty
91
+ nav = items.length - 1;
92
+ } else if (nav > 0) {
93
+ nav -= 1;
94
+ }
95
+ return items[nav];
96
+ }
97
+ // down
98
+ if (nav === -1) return null;
99
+ if (nav < items.length - 1) {
100
+ nav += 1;
101
+ return items[nav];
102
+ }
103
+ nav = -1;
104
+ return ""; // past the newest → empty prompt
105
+ },
106
+ };
107
+ }
108
+
109
+ export default function (pi: ExtensionAPI) {
110
+ const store = makeHistory(loadHistory(), saveHistory);
111
+
112
+ // A CustomEditor that adds history recall. pi copies app keybindings,
113
+ // autocomplete, and submit/change wiring onto it after construction.
114
+ class HistoryEditor extends CustomEditor {
115
+ private kb: any;
116
+ private tuiRef: any;
117
+ constructor(tui: any, theme: any, keybindings: any, options?: any) {
118
+ super(tui, theme, keybindings, options);
119
+ this.kb = keybindings;
120
+ this.tuiRef = tui;
121
+ }
122
+
123
+ // pi calls this on every submit — our hook to record + persist the prompt.
124
+ addToHistory(text: string): void {
125
+ store.add(text);
126
+ }
127
+
128
+ handleInput(data: string): void {
129
+ const up = this.kb?.matches?.(data, "tui.editor.cursorUp");
130
+ const down = this.kb?.matches?.(data, "tui.editor.cursorDown");
131
+ // Don't hijack ↑/↓ while the autocomplete dropdown is open — it owns them.
132
+ const autocompleting = (this as any).isShowingAutocomplete?.() === true;
133
+ if ((up || down) && !autocompleting) {
134
+ const next = store.recall(up ? "up" : "down", this.getText());
135
+ if (next !== null) {
136
+ this.setText(next);
137
+ this.tuiRef?.requestRender?.();
138
+ return;
139
+ }
140
+ }
141
+ // Any non-navigation key ends the current recall walk.
142
+ if (!up && !down) store.reset();
143
+ super.handleInput(data);
144
+ }
145
+ }
146
+
147
+ pi.on("session_start", async (_event, ctx) => {
148
+ if (!ctx.hasUI || typeof (ctx.ui as any).setEditorComponent !== "function") return;
149
+ store.reset();
150
+ (ctx.ui as any).setEditorComponent(
151
+ (tui: any, theme: any, keybindings: any) => new HistoryEditor(tui, theme, keybindings),
152
+ );
153
+ });
154
+ }