@janvitos/pi-plan-build 0.1.46 → 0.1.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -26,7 +26,7 @@ Optionally keep the approved plan visible in a docked side panel while implement
26
26
 
27
27
  - New sessions start in **Build** mode.
28
28
  - Bare `Tab` cycles **Build → Plan → Build**, while active autocomplete dropdowns retain Pi's normal Tab completion.
29
- - The composer uses OpenCode prompt-inspired blue/orange mode colors on the rounded top-left border and left rail, complemented by Pi's border color on the right rail and rounded bottom-right border; rounded corners inherit their vertical rail colors while horizontal `╌` segments bridge the borders at both junctions, paired with a light vertical `┆` at the top right and a mode-specific bottom-left transition: thin `┆` in Plan and heavy `┇` in Build. It also includes a mode/model/thinking metadata row; cycling the thinking level updates this row without adding a duplicate status above the composer. The model is shown as `model-id [provider]` (for example, `gpt-5.6-luna [openai]`), with the model ID inheriting the terminal foreground like unselected entries in `/model` and the provider using the footer's dim text color. The footer keeps the remaining path and usage stats without duplicating model metadata.
29
+ - The composer uses OpenCode prompt-inspired blue/orange mode colors on the rounded top-left border and left rail, complemented by Pi's border color on the right rail and rounded bottom-right border; rounded corners inherit their vertical rail colors while horizontal `╌` segments bridge the borders at both junctions, paired with a light vertical `┆` at the top right and a mode-specific bottom-left transition: thin `┆` in Plan and heavy `┇` in Build. User messages carry a heavy dashed `┇` transcript rail in the mode used to submit them—using the active Pi theme's `warning` color in Plan and `thinkingLow` color in Build—and retain that color after mode changes and session restores. It also includes a mode/model/thinking metadata row; cycling the thinking level updates this row without adding a duplicate status above the composer. The model is shown as `model-id [provider]` (for example, `gpt-5.6-luna [openai]`), with the model ID inheriting the terminal foreground like unselected entries in `/model` and the provider using the footer's dim text color. The footer keeps the remaining path and usage stats without duplicating model metadata.
30
30
  - `/plan`, `/build`, and the `--plan` startup flag.
31
31
  - Per-session plans at `~/.pi/agent/plans/<session-id>.md`.
32
32
  - In Plan mode, built-in `edit` and `write` are restricted to the exact plan file.
@@ -148,7 +148,7 @@ Once the request is sufficiently understood and the agent is ready to present th
148
148
 
149
149
  ## Design and attribution
150
150
 
151
- Pi Plan & Build is an independent extension with its own workflow and UI behavior. Its conversational read-only lifecycle follows OpenCode’s standard Plan agent, while persisted finalization and approval are adapted for Pi. Earlier prompt and transition semantics were informed by OpenCode 1.18.16, and clean-session implementation ideas were informed by the former `pi-plan-mode` extension. This project is not affiliated with either project.
151
+ Pi Plan & Build is an independent extension with its own workflow and UI behavior. Its conversational read-only lifecycle follows OpenCode’s standard Plan agent, while persisted finalization and approval are adapted for Pi. Earlier prompt and transition semantics were informed by OpenCode 1.18.16, and clean-session implementation ideas were informed by the former `pi-plan-mode` extension. The mode-colored transcript rail decorates Pi's exported `UserMessageComponent` because Pi does not currently expose a built-in user-message renderer hook; this compatibility layer is guarded against duplicate installation on reload. This project is not affiliated with either project.
152
152
 
153
153
  The Plan workflow uses Pi's native exploration tools directly and does not bundle or require subagents.
154
154
 
package/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- import { CustomEditor, getAgentDir, getMarkdownTheme, type EntryRenderer, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import { CustomEditor, getAgentDir, getMarkdownTheme, parseSkillBlock, type EntryRenderer, type ExtensionAPI, type ExtensionContext, UserMessageComponent } from "@earendil-works/pi-coding-agent";
5
5
  import { HStack, Key, Markdown, matchesKey, Text, truncateToWidth, visibleWidth, isViewportTUI, type Component, type TUI, type ViewportTUI } from "@earendil-works/pi-tui";
6
6
  import { Type } from "typebox";
7
7
  import { registerQuestionTool } from "./question-ui.ts";
@@ -28,6 +28,7 @@ import {
28
28
  type PlanExecutionState,
29
29
  } from "./plan-execution.ts";
30
30
  import { PlanPanel } from "./plan-panel.ts";
31
+ import { collectTranscriptModeRecords, extractUserMessageText, installUserMessageRail } from "./user-message-rail.ts";
31
32
  import {
32
33
  applyManualSelection,
33
34
  buildFreshImplementationHandoff,
@@ -105,6 +106,23 @@ export default function planBuildModes(pi: ExtensionAPI): void {
105
106
  let originalLayoutRoot: Component | undefined;
106
107
  let panelLayoutRoot: Component | undefined;
107
108
  let fullscreenPanelCapable = false;
109
+ const displayUserMessageText = (text: string): string | undefined => {
110
+ const skillBlock = parseSkillBlock(text);
111
+ return skillBlock ? skillBlock.userMessage || undefined : text || undefined;
112
+ };
113
+ const userMessageRail = installUserMessageRail(UserMessageComponent, {
114
+ formatRail: (mode, glyph) => currentContext ? formatModeRail(mode, currentContext.ui.theme, glyph) : glyph,
115
+ getFallbackMode: () => runMode ?? selectedMode,
116
+ });
117
+ const restoreUserMessageRails = (entries: readonly unknown[]) => {
118
+ userMessageRail.setTranscript(
119
+ collectTranscriptModeRecords(entries, {
120
+ stateTypes: new Set([STATE_TYPE, LEGACY_STATE_TYPE]),
121
+ decodeState: decodeModeState,
122
+ displayText: displayUserMessageText,
123
+ }),
124
+ );
125
+ };
108
126
 
109
127
  pi.registerFlag("plan", {
110
128
  description: "Start in Plan mode",
@@ -665,7 +683,14 @@ export default function planBuildModes(pi: ExtensionAPI): void {
665
683
  if (execution && execution.status !== "completed") ensurePanelLayout();
666
684
  });
667
685
 
686
+ pi.on("message_start", (event) => {
687
+ if (event.message.role !== "user") return;
688
+ const text = displayUserMessageText(extractUserMessageText(event.message.content));
689
+ if (text) userMessageRail.addMessage(text, runMode ?? selectedMode);
690
+ });
691
+
668
692
  pi.on("session_start", async (event, ctx) => {
693
+ userMessageRail.activate();
669
694
  currentContext = ctx;
670
695
  const entries = ctx.sessionManager.getEntries();
671
696
  const latest = entries
@@ -679,6 +704,7 @@ export default function planBuildModes(pi: ExtensionAPI): void {
679
704
  const raw = latest?.data as StoredState | undefined;
680
705
  execution = decodePlanExecution(raw?.execution);
681
706
  selectedMode = decoded?.selectedMode ?? (pi.getFlag("plan") === true ? "plan" : "build");
707
+ restoreUserMessageRails(ctx.sessionManager.getBranch());
682
708
  pendingReminder = raw?.pendingReminder ?? (decoded ? undefined : pi.getFlag("plan") === true ? "plan" : undefined);
683
709
  toolsBeforeModes = Array.isArray(raw?.toolsBeforeModes)
684
710
  ? raw.toolsBeforeModes.filter((name): name is string => typeof name === "string" && !MANAGED_TOOLS.has(name))
@@ -787,10 +813,10 @@ export default function planBuildModes(pi: ExtensionAPI): void {
787
813
  const lines = super.render(width);
788
814
  if (paddingWidth !== railWidth) return lines;
789
815
 
790
- const leftRail = `${formatModeRail(selectedMode)} `;
816
+ const leftRail = `${formatModeRail(selectedMode, ctx.ui.theme)} `;
791
817
  const rightRail = this.borderColor("│");
792
818
  const topRightVerticalTransition = this.borderColor("┆");
793
- const bottomLeftVerticalTransition = formatModeRail(selectedMode, selectedMode === "build" ? "┇" : "┆");
819
+ const bottomLeftVerticalTransition = formatModeRail(selectedMode, ctx.ui.theme, selectedMode === "build" ? "┇" : "┆");
794
820
  const metadata = truncateToWidth(
795
821
  formatModeMetadata(selectedMode, pi.getThinkingLevel(), ctx.ui.theme, this.borderColor, {
796
822
  modelName: ctx.model?.id ?? "no-model",
@@ -800,7 +826,7 @@ export default function planBuildModes(pi: ExtensionAPI): void {
800
826
  width - 1,
801
827
  "",
802
828
  );
803
- const topBorder = formatModeTopBorder(selectedMode, width, this.borderColor("╮"));
829
+ const topBorder = formatModeTopBorder(selectedMode, width, this.borderColor("╮"), ctx.ui.theme);
804
830
  return renderModeComposer(
805
831
  lines,
806
832
  topBorder,
@@ -808,7 +834,7 @@ export default function planBuildModes(pi: ExtensionAPI): void {
808
834
  rightRail,
809
835
  topRightVerticalTransition,
810
836
  metadata,
811
- formatModeRail(selectedMode, "╰"),
837
+ formatModeRail(selectedMode, ctx.ui.theme, "╰"),
812
838
  railWidth,
813
839
  width,
814
840
  {
@@ -863,6 +889,7 @@ export default function planBuildModes(pi: ExtensionAPI): void {
863
889
  });
864
890
 
865
891
  pi.on("session_shutdown", async (_event, ctx) => {
892
+ userMessageRail.deactivate();
866
893
  removePanelLayout();
867
894
  ctx.ui.setStatus(STATUS_KEY, undefined);
868
895
  ctx.ui.setFooter(undefined);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@janvitos/pi-plan-build",
3
- "version": "0.1.46",
3
+ "version": "0.1.47",
4
4
  "description": "Plan safely, approve explicitly, then implement here or in a clean session.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,6 +25,7 @@
25
25
  "question-ui.ts",
26
26
  "plan-execution.ts",
27
27
  "plan-panel.ts",
28
+ "user-message-rail.ts",
28
29
  "utils.ts",
29
30
  "docs/images"
30
31
  ],
@@ -32,7 +33,7 @@
32
33
  "access": "public"
33
34
  },
34
35
  "scripts": {
35
- "test": "node --experimental-strip-types --test utils.test.ts question-ui.test.ts plan-execution.test.ts plan-panel.test.ts",
36
+ "test": "node --experimental-strip-types --test utils.test.ts question-ui.test.ts plan-execution.test.ts plan-panel.test.ts user-message-rail.test.ts",
36
37
  "prepublishOnly": "npm test"
37
38
  },
38
39
  "peerDependencies": {
@@ -0,0 +1,187 @@
1
+ import type { Mode } from "./utils.ts";
2
+
3
+ const OSC133_PREFIX = /^((?:\x1b\]133;[ABC]\x07)*)/u;
4
+ const PATCH_KEY = Symbol.for("@janvitos/pi-plan-build:user-message-rail");
5
+
6
+ export interface TranscriptModeRecord {
7
+ text: string;
8
+ mode: Mode;
9
+ }
10
+
11
+ interface UserMessageLike {
12
+ text?: unknown;
13
+ render(width: number): string[];
14
+ }
15
+
16
+ interface UserMessageClass {
17
+ prototype: UserMessageLike;
18
+ }
19
+
20
+ interface RailPatchState {
21
+ originalRender: (this: UserMessageLike, width: number) => string[];
22
+ componentModes: WeakMap<object, Mode>;
23
+ resolver: TranscriptModeResolver;
24
+ formatRail: (mode: Mode, glyph: string) => string;
25
+ getFallbackMode: () => Mode;
26
+ glyph: string;
27
+ owner?: symbol;
28
+ }
29
+
30
+ export interface UserMessageRailController {
31
+ activate(): void;
32
+ setTranscript(records: readonly TranscriptModeRecord[]): void;
33
+ addMessage(text: string, mode: Mode): void;
34
+ deactivate(): void;
35
+ }
36
+
37
+ export class TranscriptModeResolver {
38
+ private records: TranscriptModeRecord[] = [];
39
+ private assigned = 0;
40
+
41
+ setTranscript(records: readonly TranscriptModeRecord[]): void {
42
+ this.records = [...records];
43
+ this.assigned = 0;
44
+ }
45
+
46
+ addMessage(text: string, mode: Mode): void {
47
+ if (!text) return;
48
+ this.records.push({ text, mode });
49
+ }
50
+
51
+ resolve(text: string, fallback: Mode): Mode {
52
+ if (this.records.length === 0) {
53
+ this.assigned++;
54
+ return fallback;
55
+ }
56
+
57
+ const start = this.assigned % this.records.length;
58
+ for (let offset = 0; offset < this.records.length; offset++) {
59
+ const index = (start + offset) % this.records.length;
60
+ const record = this.records[index]!;
61
+ if (record.text !== text) continue;
62
+ this.assigned += offset + 1;
63
+ return record.mode;
64
+ }
65
+
66
+ this.assigned++;
67
+ return fallback;
68
+ }
69
+ }
70
+
71
+ export function extractUserMessageText(content: unknown): string {
72
+ if (typeof content === "string") return content;
73
+ if (!Array.isArray(content)) return "";
74
+ return content
75
+ .filter(
76
+ (block): block is { type: "text"; text: string } =>
77
+ !!block &&
78
+ typeof block === "object" &&
79
+ (block as { type?: unknown }).type === "text" &&
80
+ typeof (block as { text?: unknown }).text === "string",
81
+ )
82
+ .map((block) => block.text)
83
+ .join("");
84
+ }
85
+
86
+ export function collectTranscriptModeRecords(
87
+ entries: readonly unknown[],
88
+ options: {
89
+ initialMode?: Mode;
90
+ stateTypes: ReadonlySet<string>;
91
+ decodeState(data: unknown): { selectedMode: Mode } | undefined;
92
+ displayText(text: string): string | undefined;
93
+ },
94
+ ): TranscriptModeRecord[] {
95
+ let mode = options.initialMode ?? "build";
96
+ const records: TranscriptModeRecord[] = [];
97
+
98
+ for (const entry of entries) {
99
+ if (!entry || typeof entry !== "object") continue;
100
+ const candidate = entry as {
101
+ type?: unknown;
102
+ customType?: unknown;
103
+ data?: unknown;
104
+ message?: { role?: unknown; content?: unknown };
105
+ };
106
+ if (
107
+ candidate.type === "custom" &&
108
+ typeof candidate.customType === "string" &&
109
+ options.stateTypes.has(candidate.customType)
110
+ ) {
111
+ mode = options.decodeState(candidate.data)?.selectedMode ?? mode;
112
+ continue;
113
+ }
114
+ if (candidate.type !== "message" || candidate.message?.role !== "user") continue;
115
+ const displayText = options.displayText(extractUserMessageText(candidate.message.content));
116
+ if (displayText) records.push({ text: displayText, mode });
117
+ }
118
+ return records;
119
+ }
120
+
121
+ function prependRail(line: string, rail: string): string {
122
+ return line.replace(OSC133_PREFIX, `$1${rail}`);
123
+ }
124
+
125
+ export function installUserMessageRail(
126
+ UserMessageComponent: UserMessageClass,
127
+ options: {
128
+ formatRail: (mode: Mode, glyph: string) => string;
129
+ getFallbackMode: () => Mode;
130
+ },
131
+ ): UserMessageRailController {
132
+ const globalState = globalThis as typeof globalThis & { [PATCH_KEY]?: RailPatchState };
133
+ let state = globalState[PATCH_KEY];
134
+ if (!state) {
135
+ const originalRender = UserMessageComponent.prototype.render;
136
+ state = {
137
+ originalRender,
138
+ componentModes: new WeakMap(),
139
+ resolver: new TranscriptModeResolver(),
140
+ formatRail: options.formatRail,
141
+ getFallbackMode: options.getFallbackMode,
142
+ glyph: "┇",
143
+ };
144
+ globalState[PATCH_KEY] = state;
145
+ }
146
+
147
+ const owner = Symbol("pi-plan-build-user-message-rail-owner");
148
+ state.owner = owner;
149
+ state.formatRail = options.formatRail;
150
+ state.getFallbackMode = options.getFallbackMode;
151
+ state.glyph = "┇";
152
+ // Reinstall from the preserved original on every extension load. This migrates
153
+ // already-running processes away from stale decorator code without stacking wrappers.
154
+ UserMessageComponent.prototype.render = function renderWithModeRail(width: number): string[] {
155
+ const active = globalState[PATCH_KEY];
156
+ if (!active?.owner || width <= 1) return state.originalRender.call(this, width);
157
+ let mode = active.componentModes.get(this);
158
+ if (!mode) {
159
+ const text = typeof this.text === "string" ? this.text : "";
160
+ mode = active.resolver.resolve(text, active.getFallbackMode());
161
+ active.componentModes.set(this, mode);
162
+ }
163
+ const rail = active.formatRail(mode, active.glyph);
164
+ return active.originalRender.call(this, width - 1).map((line) => prependRail(line, rail));
165
+ };
166
+
167
+ return {
168
+ activate() {
169
+ const active = globalState[PATCH_KEY];
170
+ if (active) active.owner = owner;
171
+ },
172
+ setTranscript(records) {
173
+ const active = globalState[PATCH_KEY];
174
+ if (active?.owner !== owner) return;
175
+ active.componentModes = new WeakMap();
176
+ active.resolver.setTranscript(records);
177
+ },
178
+ addMessage(text, mode) {
179
+ const active = globalState[PATCH_KEY];
180
+ if (active?.owner === owner) active.resolver.addMessage(text, mode);
181
+ },
182
+ deactivate() {
183
+ const active = globalState[PATCH_KEY];
184
+ if (active?.owner === owner) active.owner = undefined;
185
+ },
186
+ };
187
+ }
package/utils.ts CHANGED
@@ -2,15 +2,16 @@ import path, { isAbsolute, relative, resolve, sep } from "node:path";
2
2
 
3
3
  export type Mode = "build" | "plan";
4
4
 
5
- const ANSI_RESET = "\x1b[0m";
6
- const MODE_LABELS: Record<Mode, { color: string; text: string }> = {
7
- plan: { color: "38;2;245;167;66", text: "plan" },
8
- build: { color: "38;2;92;156;245", text: "build" },
5
+ const MODE_LABELS: Record<Mode, string> = {
6
+ plan: "plan",
7
+ build: "build",
9
8
  };
10
9
 
10
+ type ModeThemeColor = "warning" | "thinkingLow";
11
+
11
12
  export interface ModeStatusTheme {
12
13
  bold(text: string): string;
13
- fg(color: "dim", text: string): string;
14
+ fg(color: "dim" | ModeThemeColor, text: string): string;
14
15
  }
15
16
 
16
17
  export interface PromptMetadataOptions {
@@ -36,17 +37,26 @@ export function nextThinkingLevel(
36
37
  return available[(currentIndex + 1) % available.length];
37
38
  }
38
39
 
39
- function formatModeColor(mode: Mode, text: string): string {
40
- return `\x1b[${MODE_LABELS[mode].color}m${text}${ANSI_RESET}`;
40
+ function modeThemeColor(mode: Mode): ModeThemeColor {
41
+ return mode === "plan" ? "warning" : "thinkingLow";
42
+ }
43
+
44
+ function formatModeColor(mode: Mode, text: string, theme: ModeStatusTheme): string {
45
+ return theme.fg(modeThemeColor(mode), text);
41
46
  }
42
47
 
43
- export function formatModeRail(mode: Mode, glyph = "│"): string {
44
- return formatModeColor(mode, glyph);
48
+ export function formatModeRail(mode: Mode, theme: ModeStatusTheme, glyph = "│"): string {
49
+ return formatModeColor(mode, glyph, theme);
45
50
  }
46
51
 
47
- export function formatModeTopBorder(mode: Mode, width: number, topRightCorner: string): string {
52
+ export function formatModeTopBorder(
53
+ mode: Mode,
54
+ width: number,
55
+ topRightCorner: string,
56
+ theme: ModeStatusTheme,
57
+ ): string {
48
58
  if (width <= 2) return "";
49
- return `${formatModeColor(mode, `╭${"─".repeat(width - 3)}╌`)}${topRightCorner}`;
59
+ return `${formatModeColor(mode, `╭${"─".repeat(width - 3)}╌`, theme)}${topRightCorner}`;
50
60
  }
51
61
 
52
62
  export function formatModeMetadata(
@@ -56,15 +66,14 @@ export function formatModeMetadata(
56
66
  thinkingColor: (text: string) => string,
57
67
  options?: PromptMetadataOptions,
58
68
  ): string {
59
- const label = MODE_LABELS[mode];
60
- const modeText = `\x1b[${label.color}m${theme.bold(label.text)}${ANSI_RESET}`;
69
+ const modeText = formatModeColor(mode, theme.bold(MODE_LABELS[mode]), theme);
61
70
  const modelText = options
62
71
  ? `${theme.fg("dim", " • ")}${options.modelName}${
63
72
  options.modelProvider ? theme.fg("dim", ` [${options.modelProvider}]`) : ""
64
73
  }`
65
74
  : "";
66
75
  const thinkingSeparator = " • ";
67
- return `${options?.rail ?? formatModeRail(mode)} ${modeText}${modelText}${theme.fg("dim", thinkingSeparator)}${thinkingColor(thinkingLevel)}`;
76
+ return `${options?.rail ?? formatModeRail(mode, theme)} ${modeText}${modelText}${theme.fg("dim", thinkingSeparator)}${thinkingColor(thinkingLevel)}`;
68
77
  }
69
78
 
70
79
  export function formatTokens(count: number): string {