@janvitos/pi-plan-build 0.1.6 → 0.1.8

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.
Files changed (4) hide show
  1. package/README.md +3 -2
  2. package/index.ts +23 -13
  3. package/package.json +1 -1
  4. package/utils.ts +80 -4
package/README.md CHANGED
@@ -8,7 +8,7 @@ A global [Pi coding agent](https://github.com/badlogic/pi-mono) extension that a
8
8
 
9
9
  - New sessions start in **Build** mode.
10
10
  - Bare `Tab` cycles **Build → Plan → Build**, while active autocomplete dropdowns retain Pi's normal Tab completion.
11
- - Bracketed **build** (blue) and **plan** (yellow) indicators rendered as bold prompt prefixes inside Pi's text input box.
11
+ - The composer uses OpenCode prompt-inspired blue/orange mode colors on a rounded `╭─` top border and `│` rail, plus a mode/thinking metadata row and Pi's bottom border.
12
12
  - `/plan`, `/build`, and the `--plan` startup flag.
13
13
  - Per-session plans at `~/.pi/agent/plans/<session-id>.md`.
14
14
  - In Plan mode, built-in `edit` and `write` are restricted to the exact plan file.
@@ -21,6 +21,7 @@ A global [Pi coding agent](https://github.com/badlogic/pi-mono) extension that a
21
21
  - **Stay in Plan mode**
22
22
  - Staying in Plan mode produces a durable acknowledgement and stops the run until the user responds.
23
23
  - Mode state survives reloads, resumes, and forks.
24
+ - When Pi recreates the custom editor, the latest 100 user prompts from the active session branch are restored for Up/Down history navigation.
24
25
 
25
26
  ## Requirements
26
27
 
@@ -110,7 +111,7 @@ npm test
110
111
  npm pack --dry-run
111
112
  ```
112
113
 
113
- The tests cover state decoding, safe plan paths, mutation restrictions, deferred transitions, complete plan rendering, approval decisions, stop behavior, fresh-session handoff content, and question formatting.
114
+ The tests cover state decoding, safe plan paths, mutation restrictions, deferred transitions, mode rendering, session-based prompt history restoration, complete plan rendering, approval decisions, stop behavior, fresh-session handoff content, and question formatting.
114
115
 
115
116
  ### Publishing
116
117
 
package/index.ts CHANGED
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { CustomEditor, getAgentDir, getMarkdownTheme, type EntryRenderer, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
5
- import { Key, Markdown, matchesKey, Text, visibleWidth } from "@earendil-works/pi-tui";
5
+ import { Key, Markdown, matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui";
6
6
  import { Type } from "typebox";
7
7
  import { registerQuestionTool } from "./question-ui.ts";
8
8
  import {
@@ -19,7 +19,10 @@ import {
19
19
  buildPlanReviewMessage,
20
20
  classifyPlanExitChoice,
21
21
  decodeModeState,
22
- formatModeStatus,
22
+ extractPromptHistory,
23
+ formatModeMetadata,
24
+ formatModeRail,
25
+ formatModeTopBorder,
23
26
  isAllowedPlanMutation,
24
27
  makePlanPath,
25
28
  nextMode,
@@ -27,6 +30,7 @@ import {
27
30
  PLAN_EXIT_FRESH_CHOICE,
28
31
  PLAN_EXIT_STAY_ACKNOWLEDGEMENT,
29
32
  PLAN_EXIT_STAY_CHOICE,
33
+ renderModeComposer,
30
34
  type Mode,
31
35
  unique,
32
36
  } from "./utils.ts";
@@ -366,7 +370,7 @@ export default function planBuildModes(pi: ExtensionAPI): void {
366
370
  updateModeIndicator(ctx);
367
371
  });
368
372
 
369
- pi.on("session_start", async (_event, ctx) => {
373
+ pi.on("session_start", async (event, ctx) => {
370
374
  currentContext = ctx;
371
375
  const entries = ctx.sessionManager.getEntries();
372
376
  const latest = entries
@@ -389,6 +393,8 @@ export default function planBuildModes(pi: ExtensionAPI): void {
389
393
  updateModeIndicator(ctx);
390
394
 
391
395
  if (ctx.mode === "tui") {
396
+ // Startup history is populated after session_start; replacement flows recreate the editor after that step.
397
+ const promptHistory = event.reason === "startup" ? [] : extractPromptHistory(ctx.sessionManager.getBranch());
392
398
  class ModeEditor extends CustomEditor {
393
399
  onCycle?: () => void;
394
400
 
@@ -397,18 +403,21 @@ export default function planBuildModes(pi: ExtensionAPI): void {
397
403
  }
398
404
 
399
405
  override render(width: number): string[] {
400
- const badge = formatModeStatus(selectedMode, ctx.ui.theme, this.borderColor);
401
- const prompt = `${badge} `;
402
- const promptWidth = visibleWidth(prompt);
403
- const paddingWidth = Math.min(promptWidth, Math.max(0, Math.floor((width - 1) / 2)));
404
- if (this.getPaddingX() !== paddingWidth) this.setPaddingX(paddingWidth);
406
+ const railWidth = 2;
407
+ const paddingWidth = Math.min(railWidth, Math.max(0, Math.floor((width - 1) / 2)));
408
+ if (this.getPaddingX() !== railWidth) this.setPaddingX(railWidth);
405
409
 
406
410
  const lines = super.render(width);
407
- const reservedPrefix = " ".repeat(paddingWidth);
408
- if (paddingWidth === promptWidth && lines[1]?.startsWith(reservedPrefix)) {
409
- lines[1] = prompt + lines[1].slice(reservedPrefix.length);
410
- }
411
- return lines;
411
+ if (paddingWidth !== railWidth) return lines;
412
+
413
+ const rail = `${formatModeRail(selectedMode)} `;
414
+ const metadata = truncateToWidth(
415
+ formatModeMetadata(selectedMode, pi.getThinkingLevel(), ctx.ui.theme, this.borderColor),
416
+ width,
417
+ "",
418
+ );
419
+ const topBorder = formatModeTopBorder(selectedMode, width);
420
+ return renderModeComposer(lines, topBorder, rail, metadata, railWidth);
412
421
  }
413
422
 
414
423
  override handleInput(data: string): void {
@@ -421,6 +430,7 @@ export default function planBuildModes(pi: ExtensionAPI): void {
421
430
  }
422
431
  ctx.ui.setEditorComponent((tui, theme, keybindings) => {
423
432
  const editor = new ModeEditor(tui, theme, keybindings);
433
+ for (const prompt of promptHistory) editor.addToHistory(prompt);
424
434
  requestEditorRender = () => editor.requestModeRender();
425
435
  editor.onCycle = () => {
426
436
  if (currentContext) void selectMode(nextMode(selectedMode), currentContext, "manual");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@janvitos/pi-plan-build",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Plan safely, approve explicitly, then implement here or in a clean session.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/utils.ts CHANGED
@@ -4,18 +4,66 @@ export type Mode = "build" | "plan";
4
4
 
5
5
  const ANSI_RESET = "\x1b[0m";
6
6
  const MODE_LABELS: Record<Mode, { color: string; text: string }> = {
7
- plan: { color: "38;2;255;215;0", text: "plan" },
8
- build: { color: "38;2;59;130;246", text: "build" },
7
+ plan: { color: "38;2;245;167;66", text: "plan" },
8
+ build: { color: "38;2;92;156;245", text: "build" },
9
9
  };
10
10
 
11
11
  export interface ModeStatusTheme {
12
12
  bold(text: string): string;
13
+ fg(color: "dim", text: string): string;
13
14
  }
14
15
 
15
- export function formatModeStatus(mode: Mode, theme: ModeStatusTheme, borderColor: (text: string) => string): string {
16
+ function formatModeColor(mode: Mode, text: string): string {
17
+ return `\x1b[${MODE_LABELS[mode].color}m${text}${ANSI_RESET}`;
18
+ }
19
+
20
+ export function formatModeRail(mode: Mode): string {
21
+ return formatModeColor(mode, "│");
22
+ }
23
+
24
+ export function formatModeTopBorder(mode: Mode, width: number): string {
25
+ if (width <= 0) return "";
26
+ return formatModeColor(mode, `╭${"─".repeat(width - 1)}`);
27
+ }
28
+
29
+ export function formatModeMetadata(
30
+ mode: Mode,
31
+ thinkingLevel: string,
32
+ theme: ModeStatusTheme,
33
+ thinkingColor: (text: string) => string,
34
+ ): string {
16
35
  const label = MODE_LABELS[mode];
17
36
  const modeText = `\x1b[${label.color}m${theme.bold(label.text)}${ANSI_RESET}`;
18
- return borderColor("[") + modeText + borderColor("]");
37
+ return `${formatModeRail(mode)} ${modeText}${theme.fg("dim", " · ")}${thinkingColor(thinkingLevel)}`;
38
+ }
39
+
40
+ export function renderModeComposer(
41
+ lines: string[],
42
+ topBorder: string,
43
+ railPrefix: string,
44
+ metadata: string,
45
+ reservedWidth: number,
46
+ ): string[] {
47
+ if (reservedWidth <= 0 || lines.length < 3) return lines;
48
+ const reservedPrefix = " ".repeat(reservedWidth);
49
+ const bottomBorderIndex = lines.findIndex((line, index) => index > 0 && !line.startsWith(reservedPrefix));
50
+ if (bottomBorderIndex < 2) return lines;
51
+ const promptLines = lines
52
+ .slice(1, bottomBorderIndex)
53
+ .map((line) => railPrefix + line.slice(reservedPrefix.length));
54
+ const bottomBorder = lines[bottomBorderIndex]!.replace(
55
+ /^((?:\x1b\[[0-?]*[ -/]*[@-~])*)./u,
56
+ "$1 ",
57
+ );
58
+ return [
59
+ topBorder,
60
+ ...promptLines,
61
+ railPrefix,
62
+ metadata,
63
+ bottomBorder,
64
+ "",
65
+ ...lines.slice(bottomBorderIndex + 1),
66
+ ];
19
67
  }
20
68
 
21
69
  export const PLAN_EXIT_APPROVE_CHOICE = "Switch to Build and implement here";
@@ -121,6 +169,34 @@ export function formatQuestionAnswers(answers: QuestionAnswerData[]): string {
121
169
  return answers.map((answer) => `"${answer.question}"="${answer.answers.length ? answer.answers.join(", ") : "Unanswered"}"`).join(", ");
122
170
  }
123
171
 
172
+ export function extractPromptHistory(entries: readonly unknown[], limit = 100): string[] {
173
+ const prompts: string[] = [];
174
+ for (const entry of entries) {
175
+ if (!entry || typeof entry !== "object") continue;
176
+ const candidate = entry as {
177
+ type?: unknown;
178
+ message?: { role?: unknown; content?: unknown };
179
+ };
180
+ if (candidate.type !== "message" || candidate.message?.role !== "user") continue;
181
+
182
+ const content = candidate.message.content;
183
+ const text = typeof content === "string"
184
+ ? content
185
+ : Array.isArray(content)
186
+ ? content
187
+ .filter((block): block is { type: "text"; text: string } =>
188
+ !!block && typeof block === "object" && (block as { type?: unknown }).type === "text" && typeof (block as { text?: unknown }).text === "string")
189
+ .map((block) => block.text)
190
+ .join("")
191
+ : "";
192
+ const trimmed = text.trim();
193
+ if (!trimmed || prompts.at(-1) === trimmed) continue;
194
+ prompts.push(trimmed);
195
+ }
196
+ const maxEntries = Math.max(0, Math.floor(limit));
197
+ return maxEntries === 0 ? [] : prompts.slice(-maxEntries);
198
+ }
199
+
124
200
  export function nextMode(mode: Mode): Mode {
125
201
  return mode === "build" ? "plan" : "build";
126
202
  }