@xynogen/pix-core 0.5.54 → 0.6.1

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
@@ -36,15 +36,10 @@ Pi activates extensions per installed package via each package's `pi.extensions`
36
36
  | `pix-grep` | `grep` — pattern search with FFF-prioritised results |
37
37
  | `pix-ls` | `ls` — directory listing as an icon tree |
38
38
  | `pix-bash` | `bash` — shell execution with framed output + exit-code summary |
39
+ | `pix-powershell` | `powershell` — same rendering for Pi's optional Windows tool; inert unless you enable `powershell` |
39
40
  | `pix-todo` | `todo` — durable execution checklist |
40
41
  | `pix-ask` | `ask_user` — structured TUI questionnaire |
41
42
 
42
- **Shell integration**
43
-
44
- | Feature | Description |
45
- |---|---|
46
- | `$SHELL` respect | User `!` commands run through `$SHELL` instead of Pi's default `/bin/bash`. For zsh, `.zshrc` is sourced and aliases expand correctly via `eval`. Other non-bash shells get the shell redirect without rc wrapping. |
47
-
48
43
  **Shared data + behaviour**
49
44
 
50
45
  | Package | Description |
@@ -62,18 +57,22 @@ Pi activates extensions per installed package via each package's `pi.extensions`
62
57
  pi install npm:@xynogen/pix-core
63
58
  ```
64
59
 
65
- > Installs and activates the core pix UI/UX extensions in one command. Members are deduped if also installed directly.
60
+ > Installs and activates every bundled pix package. A package that you also install directly loads only once.
66
61
 
67
62
  ## Full distro
68
63
 
69
- Source: [github.com/xynogen/pix-mono](https://github.com/xynogen/pix-mono)
70
-
71
- To install the complete pix suite (all packages + Pi itself):
64
+ This package is part of [Pix](https://github.com/xynogen/pix-mono). The installer sets up Pi and the full distro. See [Install](https://github.com/xynogen/pix-mono#install) for the notes for each OS.
72
65
 
73
66
  ```bash
67
+ # Linux / macOS
74
68
  curl -fsSL https://raw.githubusercontent.com/xynogen/pix-mono/main/scripts/install.sh | sh
75
69
  ```
76
70
 
71
+ ```powershell
72
+ # Windows
73
+ irm https://raw.githubusercontent.com/xynogen/pix-mono/main/scripts/install.ps1 | iex
74
+ ```
75
+
77
76
  ## License
78
77
 
79
- MIT
78
+ MIT. See [LICENSE](LICENSE).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-core",
3
- "version": "0.5.54",
3
+ "version": "0.6.1",
4
4
  "description": "Pi extension bundle — installs and activates all core pix-* extensions",
5
5
  "type": "module",
6
6
  "main": "src/extension.ts",
@@ -43,31 +43,32 @@
43
43
  "access": "public"
44
44
  },
45
45
  "dependencies": {
46
- "@xynogen/pix-runtime": "^0.10.2",
47
- "@xynogen/pix-data": "^0.5.1",
48
- "@xynogen/pix-pretty": "^1.27.0",
49
- "@xynogen/pix-welcome": "^0.2.3",
50
- "@xynogen/pix-footer": "^0.2.5",
51
- "@xynogen/pix-diagnostics": "^0.3.2",
52
- "@xynogen/pix-display": "^0.2.1",
53
- "@xynogen/pix-prompts": "^0.5.1",
54
- "@xynogen/pix-skills": "^0.8.8",
55
- "@xynogen/pix-models": "^0.2.8",
56
- "@xynogen/pix-update": "^0.2.2",
57
- "@xynogen/pix-commands": "^0.6.6",
58
- "@xynogen/pix-nudge": "^0.3.0",
59
- "@xynogen/pix-read": "^0.4.0",
60
- "@xynogen/pix-write": "^0.2.5",
61
- "@xynogen/pix-edit": "^0.2.5",
62
- "@xynogen/pix-find": "^0.3.1",
63
- "@xynogen/pix-grep": "^0.3.1",
64
- "@xynogen/pix-ls": "^0.3.1",
65
- "@xynogen/pix-bash": "^0.2.7",
66
- "@xynogen/pix-todo": "^0.4.1",
67
- "@xynogen/pix-ask": "^0.2.30",
68
- "@xynogen/pix-optimizer": "^1.2.0",
69
- "@xynogen/pix-gate": "^0.3.2",
70
- "@xynogen/pix-subagent": "^0.10.2"
46
+ "@xynogen/pix-runtime": "^0.12.2",
47
+ "@xynogen/pix-data": "^0.5.3",
48
+ "@xynogen/pix-pretty": "^1.29.0",
49
+ "@xynogen/pix-welcome": "^0.2.5",
50
+ "@xynogen/pix-footer": "^0.2.7",
51
+ "@xynogen/pix-diagnostics": "^0.3.5",
52
+ "@xynogen/pix-display": "^0.3.0",
53
+ "@xynogen/pix-prompts": "^0.5.4",
54
+ "@xynogen/pix-skills": "^0.9.1",
55
+ "@xynogen/pix-models": "^0.3.0",
56
+ "@xynogen/pix-update": "^0.2.4",
57
+ "@xynogen/pix-commands": "^0.6.8",
58
+ "@xynogen/pix-nudge": "^0.3.2",
59
+ "@xynogen/pix-read": "^0.4.2",
60
+ "@xynogen/pix-write": "^0.2.7",
61
+ "@xynogen/pix-edit": "^0.2.7",
62
+ "@xynogen/pix-find": "^0.3.3",
63
+ "@xynogen/pix-grep": "^0.3.3",
64
+ "@xynogen/pix-ls": "^0.3.3",
65
+ "@xynogen/pix-bash": "^0.2.9",
66
+ "@xynogen/pix-powershell": "^0.1.0",
67
+ "@xynogen/pix-todo": "^0.4.5",
68
+ "@xynogen/pix-ask": "^0.3.0",
69
+ "@xynogen/pix-optimizer": "^1.3.0",
70
+ "@xynogen/pix-gate": "^0.4.0",
71
+ "@xynogen/pix-subagent": "^0.10.5"
71
72
  },
72
73
  "peerDependencies": {
73
74
  "@earendil-works/pi-ai": "*",
package/src/compaction.ts CHANGED
@@ -28,6 +28,7 @@
28
28
  import { complete } from "@earendil-works/pi-ai/compat";
29
29
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
30
30
  import { convertToLlm, serializeConversation } from "@earendil-works/pi-coding-agent";
31
+ import { getErrorMessage } from "@xynogen/pix-pretty/utils";
31
32
  import { config } from "@xynogen/pix-runtime/config";
32
33
  import { compactionSection } from "@xynogen/pix-runtime/sections";
33
34
 
@@ -167,7 +168,7 @@ export default function registerCompaction(pi: ExtensionAPI): void {
167
168
  };
168
169
  } catch (error) {
169
170
  if (signal.aborted) return;
170
- const message = error instanceof Error ? error.message : String(error);
171
+ const message = getErrorMessage(error);
171
172
  ctx.ui.notify(`Compaction failed (${message}); using built-in`, "error");
172
173
  return;
173
174
  }
@@ -251,7 +252,7 @@ export default function registerCompaction(pi: ExtensionAPI): void {
251
252
  try {
252
253
  pi.sendUserMessage(RESUME_NUDGE, { deliverAs: "followUp" });
253
254
  } catch (err) {
254
- const message = err instanceof Error ? err.message : String(err);
255
+ const message = getErrorMessage(err);
255
256
  ctx.ui.notify(`Compaction: resume nudge skipped (${message})`, "warning");
256
257
  }
257
258
  }, 0);
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Keep the prompt lean: only the core tools are declared up front. Every other
3
+ * `direct` tool a bundled member registers becomes `deferred`, and `tool_search`
4
+ * is activated so the model can find and load them on demand.
5
+ *
6
+ * It patches `registerTool` on pix-core's own `pi` object. Pi gives each
7
+ * extension its own API object, so tools of standalone installs and other
8
+ * extensions keep their declared exposure.
9
+ */
10
+
11
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
+
13
+ export const CORE_TOOLS: ReadonlySet<string> = new Set(["read", "bash", "edit", "write"]);
14
+ const TOOL_SEARCH = "tool_search";
15
+
16
+ export default function deferNonCoreTools(pi: ExtensionAPI): void {
17
+ const register = pi.registerTool.bind(pi);
18
+ pi.registerTool = (tool) =>
19
+ register(
20
+ CORE_TOOLS.has(tool.name) || (tool.exposure ?? "direct") !== "direct"
21
+ ? tool
22
+ : { ...tool, exposure: "deferred" },
23
+ );
24
+
25
+ // Pi registers tool_search inactive. Without it, deferred tools are unreachable.
26
+ pi.on("session_start", () => {
27
+ const active = pi.getActiveTools();
28
+ if (active.includes(TOOL_SEARCH)) return;
29
+ if (!pi.getAllTools().some((t) => t.name === TOOL_SEARCH)) return;
30
+ pi.setActiveTools([...active, TOOL_SEARCH]);
31
+ });
32
+ }
package/src/extension.ts CHANGED
@@ -12,7 +12,6 @@
12
12
  */
13
13
 
14
14
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
- import { createLocalBashOperations } from "@earendil-works/pi-coding-agent";
16
15
  import registerAsk from "@xynogen/pix-ask";
17
16
  import registerBash from "@xynogen/pix-bash/extension";
18
17
  import registerCommands from "@xynogen/pix-commands/extension";
@@ -28,6 +27,7 @@ import registerLs from "@xynogen/pix-ls/extension";
28
27
  import registerModels from "@xynogen/pix-models/extension";
29
28
  import registerNudge from "@xynogen/pix-nudge/extension";
30
29
  import registerOptimizer from "@xynogen/pix-optimizer";
30
+ import registerPowerShell from "@xynogen/pix-powershell/extension";
31
31
  import registerPretty from "@xynogen/pix-pretty";
32
32
  import registerPrompts from "@xynogen/pix-prompts/extension";
33
33
  import registerRead from "@xynogen/pix-read/extension";
@@ -39,11 +39,15 @@ import registerUpdate from "@xynogen/pix-update/extension";
39
39
  import registerWelcome from "@xynogen/pix-welcome/extension";
40
40
  import registerWrite from "@xynogen/pix-write/extension";
41
41
  import registerCompaction from "./compaction.ts";
42
+ import deferNonCoreTools from "./defer-tools.ts";
43
+ import registerPlanMode from "./plan-mode.ts";
42
44
 
43
45
  type PixExtension = (pi: ExtensionAPI) => void;
44
46
 
45
47
  // Compile-time boundary: every member must accept the Pi host contract.
46
48
  const MEMBERS = [
49
+ // Must run first: it wraps registerTool so non-core member tools register deferred.
50
+ deferNonCoreTools,
47
51
  // pix-runtime owns pix.json (init/reload/flush) and the /pix settings command.
48
52
  // It must run first so every config consumer below reads a live runtime.
49
53
  registerRuntime,
@@ -70,6 +74,8 @@ const MEMBERS = [
70
74
  registerGrep,
71
75
  registerLs,
72
76
  registerBash,
77
+ // No-op unless Windows AND the user enabled Pi's optional `powershell` tool.
78
+ registerPowerShell,
73
79
  registerTodo,
74
80
  registerAsk,
75
81
  registerOptimizer,
@@ -78,61 +84,11 @@ const MEMBERS = [
78
84
  // Custom compaction: replaces pi's built-in summary + trigger (reads
79
85
  // compaction section; runs after runtime is live).
80
86
  registerCompaction,
87
+ // /plan: plan manager modal. Plan mode (read + bash + write to .pi/plans) turns on
88
+ // for new/edit. Tab in an empty prompt, or ctrl+alt+p, toggles it.
89
+ registerPlanMode,
81
90
  ] satisfies readonly PixExtension[];
82
91
 
83
92
  export default function (pi: ExtensionAPI): void {
84
93
  for (const register of MEMBERS) register(pi);
85
-
86
- // ── Respect $SHELL for user ! commands ──────────────────────────────────
87
- // Pi defaults to /bin/bash for ! commands. When the user's login shell
88
- // differs (zsh, fish, nushell, …), redirect ! execution through $SHELL
89
- // in interactive mode so aliases, functions, and rc config all work.
90
- const userShell = process.env.SHELL;
91
- if (userShell && !/\bbash$/.test(userShell)) {
92
- const ops = createInteractiveShellOps(userShell);
93
- pi.on("user_bash", () => ({ operations: ops }));
94
- }
95
- }
96
-
97
- /**
98
- * Build BashOperations that run commands through the user's real shell with
99
- * rc files loaded and alias expansion enabled.
100
- *
101
- * Problem: `zsh -c "gss"` is non-interactive — it skips `.zshrc` and disables
102
- * alias expansion. Even `zsh -c ". ~/.zshrc; gss"` fails because aliases
103
- * are resolved at parse-time, before `.zshrc` is sourced.
104
- *
105
- * Solution: source the rc file, then `eval` the command. `eval` creates a
106
- * new parse boundary, so aliases defined by the source are available.
107
- */
108
- function createInteractiveShellOps(
109
- shellPath: string,
110
- ): import("@earendil-works/pi-coding-agent").BashOperations {
111
- const inner = createLocalBashOperations({ shellPath });
112
- const name = shellPath.split("/").pop() ?? "";
113
- const home = process.env.HOME ?? "~";
114
-
115
- return {
116
- exec(command, cwd, options) {
117
- return inner.exec(wrapForShell(name, home, command), cwd, options);
118
- },
119
- };
120
- }
121
-
122
- /**
123
- * Wrap a command so it runs inside the user's shell with rc and alias support.
124
- */
125
- function wrapForShell(shell: string, home: string, command: string): string {
126
- if (shell === "zsh") {
127
- // 1. source .zshrc for aliases/functions/PATH
128
- // 2. eval to create a fresh parse boundary so aliases expand
129
- return `. ${home}/.zshrc 2>/dev/null; eval ${escapeForShell(command)}`;
130
- }
131
- // Other shells: run as-is, rely on shellPath alone
132
- return command;
133
- }
134
-
135
- /** Single-quote a string for safe shell embedding. */
136
- function escapeForShell(s: string): string {
137
- return `'${s.replace(/'/g, "'\\''")}'`;
138
94
  }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Plan manager overlay for `/plan` — one framed modal (pix-pretty frameModal)
3
+ * with three views: plan list, plan detail, and delete confirm. Create and
4
+ * edit hand off to the model through the prompt bar.
5
+ * The modal only returns a decision; plan-mode.ts performs it visibly.
6
+ */
7
+
8
+ import type { Theme } from "@earendil-works/pi-coding-agent";
9
+ import {
10
+ type KeybindingsManager,
11
+ matchesKey,
12
+ type SelectItem,
13
+ SelectList,
14
+ type TUI,
15
+ } from "@earendil-works/pi-tui";
16
+ import {
17
+ frameModal,
18
+ MIN_MODAL_HEIGHT,
19
+ ModalPager,
20
+ modalWidth,
21
+ selectListTheme,
22
+ terminalModalHeight,
23
+ } from "@xynogen/pix-pretty/modal-frame";
24
+ import type { Plan } from "./plan-mode.ts";
25
+
26
+ export type PlanModalResult =
27
+ | { kind: "execute"; plan: Plan }
28
+ | { kind: "delete"; plan: Plan }
29
+ | { kind: "edit"; plan: Plan }
30
+ | { kind: "new" }
31
+ | { kind: "toggle" };
32
+
33
+ const LIST_ROWS = 10;
34
+
35
+ type View = "list" | "plan" | "confirm";
36
+
37
+ export class PlanModal {
38
+ private view: View = "list";
39
+ private plan: Plan | undefined;
40
+ private readonly pager = new ModalPager();
41
+ private readonly list: SelectList;
42
+ private actions: SelectList | undefined;
43
+
44
+ constructor(
45
+ private readonly plans: Plan[],
46
+ private readonly planDir: string,
47
+ private readonly planMode: boolean,
48
+ private readonly tui: TUI,
49
+ private readonly theme: Theme,
50
+ private readonly kb: KeybindingsManager,
51
+ private readonly done: (result: PlanModalResult | undefined) => void,
52
+ ) {
53
+ const items: SelectItem[] = [
54
+ {
55
+ value: "new",
56
+ label: "+ New plan",
57
+ description: "Plan mode on + plan guide in the prompt bar",
58
+ },
59
+ ...plans.map((p, i) => ({ value: String(i), label: p.title, description: p.description })),
60
+ ];
61
+ this.list = new SelectList(items, LIST_ROWS, selectListTheme(theme));
62
+ this.list.onSelect = (item) => {
63
+ const plan = plans[Number(item.value)];
64
+ if (item.value === "new") done({ kind: "new" });
65
+ else if (plan) this.openPlan(plan);
66
+ };
67
+ this.list.onCancel = () => done(undefined);
68
+ }
69
+
70
+ private selectedPlan(): Plan | undefined {
71
+ return this.plans[Number(this.list.getSelectedItem()?.value)];
72
+ }
73
+
74
+ private edit(plan: Plan): void {
75
+ this.done({ kind: "edit", plan });
76
+ }
77
+
78
+ private go(view: View): void {
79
+ this.view = view;
80
+ this.pager.reset();
81
+ }
82
+
83
+ private actionList(
84
+ items: string[],
85
+ onSelect: (value: string) => void,
86
+ onCancel: () => void,
87
+ ): SelectList {
88
+ const list = new SelectList(
89
+ items.map((v) => ({ value: v, label: v })),
90
+ items.length,
91
+ selectListTheme(this.theme),
92
+ );
93
+ list.onSelect = (item) => onSelect(item.value);
94
+ list.onCancel = onCancel;
95
+ return list;
96
+ }
97
+
98
+ private openPlan(plan: Plan): void {
99
+ this.plan = plan;
100
+ this.actions = this.actionList(
101
+ ["Execute", "Edit", "Delete", "Back"],
102
+ (v) => {
103
+ if (v === "Execute") this.done({ kind: "execute", plan });
104
+ else if (v === "Edit") this.edit(plan);
105
+ else if (v === "Delete") this.confirmDelete(plan);
106
+ else this.go("list");
107
+ },
108
+ () => this.go("list"),
109
+ );
110
+ this.go("plan");
111
+ }
112
+
113
+ private confirmDelete(plan: Plan): void {
114
+ this.plan = plan;
115
+ this.actions = this.actionList(
116
+ ["Delete", "Cancel"],
117
+ (v) => (v === "Delete" ? this.done({ kind: "delete", plan }) : this.openPlan(plan)),
118
+ () => this.openPlan(plan),
119
+ );
120
+ this.go("confirm");
121
+ }
122
+
123
+ handleInput(data: string): void {
124
+ if (this.view === "list") this.handleListInput(data);
125
+ else if (!this.pager.handleInput(data, this.kb, true)) this.actions?.handleInput(data);
126
+ this.tui.requestRender();
127
+ }
128
+
129
+ private handleListInput(data: string): void {
130
+ const plan = this.selectedPlan();
131
+ if (matchesKey(data, "t")) this.done({ kind: "toggle" });
132
+ else if (plan && matchesKey(data, "d")) this.confirmDelete(plan);
133
+ else if (plan && matchesKey(data, "e")) this.edit(plan);
134
+ else this.list.handleInput(data);
135
+ }
136
+
137
+ render(width: number): string[] {
138
+ const t = this.theme;
139
+ const mw = modalWidth(width);
140
+ const inner = mw - 4;
141
+ const title = (s: string) => t.fg("accent", t.bold(s));
142
+ const hint = (s: string) => t.fg("muted", s);
143
+ const rule = t.fg("muted", "─".repeat(inner));
144
+ let header: string[];
145
+ let body: string[];
146
+ let footer: string[];
147
+
148
+ if (this.view === "list") {
149
+ header = [
150
+ title("Plans"),
151
+ hint(
152
+ `${this.plans.length} saved · ${this.planDir} · plan mode ${this.planMode ? "on" : "off"}`,
153
+ ),
154
+ ];
155
+ body = this.list.render(inner);
156
+ footer = [
157
+ rule,
158
+ hint("↑↓ choose • enter open • e edit • d delete • t toggle mode • esc close"),
159
+ ];
160
+ } else {
161
+ const plan = this.plan as Plan;
162
+ const confirm = this.view === "confirm";
163
+ header = [
164
+ confirm ? t.fg("warning", t.bold(`Delete ${plan.file}?`)) : title(plan.title),
165
+ ...(plan.description ? [t.fg("dim", plan.description)] : []),
166
+ hint(`${this.planDir}/${plan.file}`),
167
+ ];
168
+ body = plan.body.split("\n");
169
+ footer = [
170
+ rule,
171
+ ...(this.actions?.render(inner) ?? []),
172
+ hint("↑↓ choose • ←→/PgUp/PgDn scroll • enter select • esc back"),
173
+ ];
174
+ }
175
+
176
+ const result = frameModal({
177
+ width: mw,
178
+ maxHeight: terminalModalHeight(this.tui.terminal?.rows),
179
+ minHeight: MIN_MODAL_HEIGHT,
180
+ header,
181
+ body,
182
+ footer,
183
+ bodyOffset: this.pager.bodyOffset,
184
+ color: (s) => t.fg("accent", s),
185
+ bg: (s) => t.bg("customMessageBg", s),
186
+ fg: (s) => t.fg("text", s),
187
+ overflowLine: ({ page, totalPages }) => hint(`←→/PgUp/PgDn scroll • ${page}/${totalPages}`),
188
+ });
189
+ this.pager.sync(result);
190
+ return result.lines;
191
+ }
192
+
193
+ invalidate(): void {}
194
+ }
@@ -0,0 +1,266 @@
1
+ /**
2
+ * pix-core plan mode — `/plan` opens a modal to manage saved plans in
3
+ * `<cwd>/.pi/plans/*.md`. Plan mode turns on when the user starts a new plan or
4
+ * edits one from the modal. Shift+Tab, or ctrl+alt+p, toggles it by hand.
5
+ * Tab in an empty prompt cycles the thinking level (Pi's default Shift+Tab action).
6
+ *
7
+ * While plan mode is on:
8
+ * - active tools shrink to `read` + `write` + `bash` (previous set restored on exit);
9
+ * - `write` may only target `.pi/plans/` (tool_call guard, visible block);
10
+ * - no hidden prompt: "+ New plan" pastes PLAN_GUIDE into the prompt bar as a chip (sent as <paste>).
11
+ *
12
+ * Plan file format: YAML-ish frontmatter (`title`, `description`) + markdown
13
+ * body (the plan itself). Executing a plan sends a normal, visible user
14
+ * message — no hidden automation.
15
+ */
16
+
17
+ import { mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
18
+ import { isAbsolute, join, relative, resolve } from "node:path";
19
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
20
+ import { CustomEditor, isToolCallEventType } from "@earendil-works/pi-coding-agent";
21
+ import { Key, matchesKey } from "@earendil-works/pi-tui";
22
+ import { chipTag } from "@xynogen/pix-pretty/chips";
23
+ import { icon } from "@xynogen/pix-pretty/icon-catalog";
24
+ import { modalOverlayOptions } from "@xynogen/pix-pretty/modal-frame";
25
+ import { projectDir } from "@xynogen/pix-runtime/paths";
26
+ import { PlanModal, type PlanModalResult } from "./plan-modal.ts";
27
+
28
+ // ponytail: bash is not sandboxed here; PLAN_GUIDE asks for read-only use and pix-gate
29
+ // still guards destructive commands. Add an allowlist guard if the model abuses it.
30
+ const PLAN_TOOLS = ["read", "write", "bash"];
31
+ const PLAN_DIR = `${projectDir()}/plans`; // "/" so prompts and hints read the same on Windows
32
+ const STATE_ENTRY = "pix-plan-mode";
33
+
34
+ // Visible guide: /plan → "+ New plan" pastes this into the prompt bar as a chip for the user
35
+ // to read, edit, and send. Nothing is injected into the system prompt.
36
+ export const PLAN_GUIDE = `[PLAN MODE] Write an implementation plan. Do not change project code.
37
+ Explore with \`read\` and read-only \`bash\` (git log/status/diff, ls, rg, --help). Never use bash to edit, install, commit, or delete.
38
+ Save ONE plan with \`write\` to \`.pi/plans/YYYY-MM-DD-<feature-name>.md\` (writes elsewhere are blocked).
39
+
40
+ Format:
41
+ ---
42
+ title: <Feature name>
43
+ description: <One sentence: what this builds and why>
44
+ ---
45
+ # <Feature Name> Implementation Plan
46
+ **Goal:** <one sentence>
47
+ **Architecture:** <2-3 sentences>
48
+ **Tech Stack:** <key technologies>
49
+ ---
50
+ ### Task N: <Component Name>
51
+ **Files:** Create/Modify/Test with exact paths
52
+ **Step 1: Write the failing test** — exact test code
53
+ **Step 2: Run test to verify it fails** — exact command + expected FAIL output
54
+ **Step 3: Write minimal implementation** — exact code
55
+ **Step 4: Run test to verify it passes** — exact command + expected PASS
56
+ **Step 5: Commit** — \`git commit -m "feat: ..."\`
57
+
58
+ Rules: exact file paths, complete code, exact commands with expected output, bite-sized steps (2-5 min each), TDD first, DRY, YAGNI. Never git-add the plan.
59
+ When done, say: "Plan saved to <path>. Use /plan to run it."
60
+ The goal follows this guide.`;
61
+
62
+ export interface Plan {
63
+ file: string;
64
+ title: string;
65
+ description: string;
66
+ body: string;
67
+ }
68
+
69
+ /** Parse `---\ntitle: …\ndescription: …\n---\nbody`. Missing frontmatter → file name as title. */
70
+ export function parsePlan(file: string, text: string): Plan {
71
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text);
72
+ const meta = m?.[1] ?? "";
73
+ const field = (k: string) => new RegExp(`^${k}:\\s*(.*)$`, "m").exec(meta)?.[1]?.trim() ?? "";
74
+ return {
75
+ file,
76
+ title: field("title") || file.replace(/\.md$/, ""),
77
+ description: field("description"),
78
+ body: m ? (m[2] ?? "") : text,
79
+ };
80
+ }
81
+
82
+ /** True when `path` (relative to cwd or absolute) resolves inside `<cwd>/.pi/plans/`. */
83
+ export function isPlanPath(cwd: string, path: string): boolean {
84
+ const rel = relative(resolve(cwd, PLAN_DIR), resolve(cwd, path));
85
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
86
+ }
87
+
88
+ function listPlans(cwd: string): Plan[] {
89
+ const dir = resolve(cwd, PLAN_DIR);
90
+ let files: string[];
91
+ try {
92
+ files = readdirSync(dir).filter((f) => f.endsWith(".md"));
93
+ } catch {
94
+ return [];
95
+ }
96
+ return files
97
+ .sort()
98
+ .reverse()
99
+ .map((f) => parsePlan(f, readFileSync(join(dir, f), "utf-8")));
100
+ }
101
+
102
+ /**
103
+ * Shift+Tab toggles plan mode. Tab cycles the thinking level, but only in an empty
104
+ * prompt with no autocomplete list open. Any text keeps Tab for Pi's completion.
105
+ * The thinking cycle reuses Pi's own `app.thinking.cycle` handler (status line included).
106
+ */
107
+ export function attachModeKeys(editor: CustomEditor, toggleMode: () => void): void {
108
+ const handleInput = editor.handleInput.bind(editor);
109
+ editor.handleInput = (data: string) => {
110
+ if (matchesKey(data, "shift+tab")) {
111
+ toggleMode();
112
+ return;
113
+ }
114
+ if (matchesKey(data, "tab") && editor.getText() === "" && !editor.isShowingAutocomplete()) {
115
+ editor.actionHandlers.get("app.thinking.cycle")?.();
116
+ return;
117
+ }
118
+ handleInput(data);
119
+ };
120
+ }
121
+
122
+ /** Footer status: plan mode shows its icon and label, normal mode shows only its icon. */
123
+ export function modeStatus(
124
+ on: boolean,
125
+ fg: (role: "warning" | "muted", text: string) => string,
126
+ ): string {
127
+ return on ? fg("warning", icon("mode.plan")) : fg("muted", icon("mode.normal"));
128
+ }
129
+
130
+ export default function registerPlanMode(pi: ExtensionAPI): void {
131
+ let enabled = false;
132
+ let toolsBefore: string[] | undefined;
133
+
134
+ function apply(ctx: ExtensionContext, on: boolean): void {
135
+ if (on && !enabled) {
136
+ toolsBefore = pi.getActiveTools();
137
+ pi.setActiveTools(PLAN_TOOLS);
138
+ mkdirSync(resolve(ctx.cwd, PLAN_DIR), { recursive: true });
139
+ } else if (!on && enabled) {
140
+ if (toolsBefore) pi.setActiveTools(toolsBefore);
141
+ toolsBefore = undefined;
142
+ }
143
+ const changed = enabled !== on;
144
+ enabled = on;
145
+ ctx.ui.setStatus(
146
+ "plan",
147
+ modeStatus(on, (r, t) => ctx.ui.theme.fg(r, t)),
148
+ );
149
+ pi.appendEntry(STATE_ENTRY, { enabled, toolsBefore });
150
+ if (changed) {
151
+ ctx.ui.notify(
152
+ on
153
+ ? "Plan mode on: read + bash + write (.pi/plans only)."
154
+ : "Plan mode off: tools restored.",
155
+ );
156
+ }
157
+ }
158
+
159
+ async function openModal(ctx: ExtensionContext): Promise<void> {
160
+ let requestRender = () => {};
161
+ const result = await ctx.ui.custom<PlanModalResult | undefined>(
162
+ (tui, theme, kb, done) => {
163
+ requestRender = () => tui.requestRender();
164
+ return new PlanModal(listPlans(ctx.cwd), PLAN_DIR, enabled, tui, theme, kb, done);
165
+ },
166
+ { overlay: true, overlayOptions: modalOverlayOptions() },
167
+ );
168
+ if (!result) return;
169
+ if (result.kind === "toggle") {
170
+ apply(ctx, !enabled);
171
+ return openModal(ctx);
172
+ }
173
+ if (result.kind === "new") {
174
+ apply(ctx, true);
175
+ // pix-display turns <prompt name="plan"> into a "prompt" chip and sends
176
+ // the tag verbatim, so the model sees the guide and the user sees a chip.
177
+ // pix-display adds the trailing space after the chip.
178
+ ctx.ui.setEditorText("");
179
+ ctx.ui.pasteToEditor(chipTag.prompt("plan", PLAN_GUIDE));
180
+ // pasteToEditor does not repaint; without this the chip waits for the next
181
+ // unrelated render (footer tick, keypress), which felt like a 2 s lag.
182
+ requestRender();
183
+ return;
184
+ }
185
+ const path = join(PLAN_DIR, result.plan.file);
186
+ if (result.kind === "edit") {
187
+ // The model edits the file. The chip holds the instruction + path; the user
188
+ // types only the change after it.
189
+ apply(ctx, true);
190
+ ctx.ui.setEditorText("");
191
+ ctx.ui.pasteToEditor(
192
+ chipTag.prompt(
193
+ "plan-edit",
194
+ `Edit the plan in \`${path}\` with \`write\`. Keep its title/description header. Apply the change the user gives after this tag.`,
195
+ ),
196
+ );
197
+ requestRender();
198
+ return;
199
+ }
200
+ if (result.kind === "delete") {
201
+ rmSync(resolve(ctx.cwd, path));
202
+ ctx.ui.notify(`Deleted ${path}`, "info");
203
+ return openModal(ctx);
204
+ }
205
+ apply(ctx, false);
206
+ pi.sendUserMessage(`Execute the plan in \`${path}\`. Follow its tasks in order.`);
207
+ }
208
+
209
+ pi.registerCommand("plan", {
210
+ description: "Manage saved plans (.pi/plans): new, execute, edit, delete, toggle plan mode",
211
+ handler: async (_args, ctx) => openModal(ctx),
212
+ });
213
+
214
+ pi.registerShortcut(Key.ctrlAlt("p"), {
215
+ description: "Toggle plan mode",
216
+ handler: (ctx) => apply(ctx, !enabled),
217
+ });
218
+
219
+ // ponytail: registerShortcut("tab") runs before the editor and would break Pi's
220
+ // Tab autocomplete. Wrap the editor so Tab acts only when the prompt is empty.
221
+ pi.on("session_start", (_event, ctx) => {
222
+ if (ctx.mode !== "tui") return;
223
+ const previous = ctx.ui.getEditorComponent();
224
+ ctx.ui.setEditorComponent((tui, theme, kb) => {
225
+ const editor = previous?.(tui, theme, kb) ?? new CustomEditor(tui, theme, kb);
226
+ if (editor instanceof CustomEditor) attachModeKeys(editor, () => apply(ctx, !enabled));
227
+ return editor;
228
+ });
229
+ });
230
+
231
+ pi.on("tool_call", async (event, ctx) => {
232
+ if (!enabled) return;
233
+ if (!PLAN_TOOLS.includes(event.toolName)) {
234
+ return {
235
+ block: true,
236
+ reason:
237
+ "Plan mode: only read, bash, and write are allowed. Toggle off with Shift+Tab or ctrl+alt+p.",
238
+ };
239
+ }
240
+ if (isToolCallEventType("write", event) && !isPlanPath(ctx.cwd, event.input.path)) {
241
+ return {
242
+ block: true,
243
+ reason: `Plan mode: write only inside ${PLAN_DIR}/. Got: ${event.input.path}`,
244
+ };
245
+ }
246
+ });
247
+
248
+ pi.on("session_start", async (_event, ctx) => {
249
+ const entry = ctx.sessionManager
250
+ .getEntries()
251
+ .filter(
252
+ (e: { type: string; customType?: string }) =>
253
+ e.type === "custom" && e.customType === STATE_ENTRY,
254
+ )
255
+ .pop() as { data?: { enabled?: boolean; toolsBefore?: string[] } } | undefined;
256
+ if (entry?.data?.enabled) {
257
+ toolsBefore = entry.data.toolsBefore;
258
+ enabled = true;
259
+ pi.setActiveTools(PLAN_TOOLS);
260
+ }
261
+ ctx.ui.setStatus(
262
+ "plan",
263
+ modeStatus(enabled, (r, t) => ctx.ui.theme.fg(r, t)),
264
+ );
265
+ });
266
+ }