@openlines/opl-pi-sht 0.2.11 → 0.3.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.
Files changed (89) hide show
  1. package/README.md +92 -82
  2. package/configs/opl-browser.json.sample +3 -2
  3. package/configs/opl-footer.json.sample +3 -2
  4. package/configs/opl-guardian.json.sample +8 -2
  5. package/configs/opl-modes.json.sample +5 -0
  6. package/configs/opl-webaccess.json.sample +7 -1
  7. package/extensions/opl-browser/README.md +81 -55
  8. package/extensions/opl-browser/browser.ts +244 -50
  9. package/extensions/opl-browser/config.ts +15 -7
  10. package/extensions/opl-browser/index.ts +5 -4
  11. package/extensions/opl-browser/package.json +4 -4
  12. package/extensions/opl-browser/validate.ts +313 -60
  13. package/extensions/opl-configurator/README.md +40 -0
  14. package/extensions/opl-configurator/configurator.ts +148 -0
  15. package/extensions/opl-configurator/index.ts +12 -0
  16. package/extensions/opl-ctxtrim/README.md +45 -14
  17. package/extensions/opl-ctxtrim/index.ts +33 -13
  18. package/extensions/opl-footer/README.md +32 -7
  19. package/extensions/opl-footer/config.ts +11 -10
  20. package/extensions/opl-footer/configure.ts +157 -115
  21. package/extensions/opl-footer/git-status.ts +58 -1
  22. package/extensions/opl-footer/icons.ts +3 -0
  23. package/extensions/opl-footer/index.ts +287 -109
  24. package/extensions/opl-footer/segments/caveman.ts +9 -1
  25. package/extensions/opl-footer/segments/chat-mode.ts +8 -1
  26. package/extensions/opl-footer/segments/compactions.ts +13 -0
  27. package/extensions/opl-footer/segments/context.ts +6 -5
  28. package/extensions/opl-footer/segments/index.ts +2 -0
  29. package/extensions/opl-footer/segments/mode-switcher.ts +14 -1
  30. package/extensions/opl-footer/segments/path.ts +1 -1
  31. package/extensions/opl-footer/segments/plan-mode.ts +8 -1
  32. package/extensions/opl-footer/theme.ts +5 -2
  33. package/extensions/opl-footer/types.ts +4 -0
  34. package/extensions/opl-guardian/README.md +17 -5
  35. package/extensions/opl-guardian/config.ts +72 -6
  36. package/extensions/opl-guardian/guardian.ts +42 -10
  37. package/extensions/opl-guardian/index.ts +52 -17
  38. package/extensions/opl-guardian/policies.ts +52 -16
  39. package/extensions/opl-init/README.md +12 -4
  40. package/extensions/opl-init/index.ts +228 -27
  41. package/extensions/opl-input/README.md +17 -3
  42. package/extensions/opl-input/config.ts +59 -5
  43. package/extensions/opl-input/configure.ts +144 -0
  44. package/extensions/opl-input/index.ts +38 -30
  45. package/extensions/opl-input/mode-style.ts +6 -2
  46. package/extensions/opl-modes/README.md +16 -4
  47. package/extensions/opl-modes/config.ts +62 -8
  48. package/extensions/opl-modes/index.ts +48 -17
  49. package/extensions/opl-modes/state.ts +50 -2
  50. package/extensions/opl-modes/types.ts +6 -0
  51. package/extensions/opl-modes/utils.ts +18 -8
  52. package/extensions/opl-questionnaire/index.ts +10 -4
  53. package/extensions/opl-questionnaire/types.ts +4 -0
  54. package/extensions/opl-simplebench/README.md +21 -15
  55. package/extensions/opl-simplebench/artifact.ts +7 -4
  56. package/extensions/opl-simplebench/benchmark.ts +96 -35
  57. package/extensions/opl-simplebench/coding.ts +9 -4
  58. package/extensions/opl-simplebench/index.ts +19 -19
  59. package/extensions/opl-simplebench/llama-server.ts +3 -2
  60. package/extensions/opl-simplebench/report.ts +0 -6
  61. package/extensions/opl-simplebench/research.ts +5 -4
  62. package/extensions/opl-simplebench/scoring.ts +4 -2
  63. package/extensions/opl-simplebench/util/config.ts +15 -8
  64. package/extensions/opl-simplebench/util/exec-env.ts +36 -0
  65. package/extensions/opl-simplebench/util/format.ts +2 -2
  66. package/extensions/opl-simplebench/util/providers.ts +23 -194
  67. package/extensions/opl-todo/README.md +5 -5
  68. package/extensions/opl-todo/config.ts +7 -2
  69. package/extensions/opl-todo/index.ts +50 -9
  70. package/extensions/opl-webaccess/README.md +31 -5
  71. package/extensions/opl-webaccess/config.ts +36 -7
  72. package/extensions/opl-webaccess/extract.ts +37 -47
  73. package/extensions/opl-webaccess/http.ts +259 -0
  74. package/extensions/opl-webaccess/index.ts +12 -6
  75. package/extensions/opl-webaccess/package.json +4 -9
  76. package/extensions/opl-webaccess/pdf.ts +4 -4
  77. package/extensions/opl-webaccess/search.ts +19 -8
  78. package/extensions/opl-webaccess/storage.ts +134 -2
  79. package/extensions/opl-webaccess/types.ts +4 -0
  80. package/extensions/opl-webaccess/utils.ts +221 -33
  81. package/images/ss-init.png +0 -0
  82. package/images/ss-input-footer.png +0 -0
  83. package/images/ss-input-pet.png +0 -0
  84. package/images/ss-mode-custom.png +0 -0
  85. package/images/ss-questionnaire0.png +0 -0
  86. package/images/ss-questionnaire1.png +0 -0
  87. package/images/ss-simplebench.png +0 -0
  88. package/images/ss-todo.png +0 -0
  89. package/package.json +20 -12
@@ -0,0 +1,144 @@
1
+ import type { ExtensionContext, KeybindingsManager, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
3
+ import { SettingsList, truncateToWidth, type SettingItem } from "@earendil-works/pi-tui";
4
+
5
+ import { readInputConfig, saveInputConfig, type EditableInputConfig } from "./config.js";
6
+
7
+ // The configurator seam. opl-configurator owns the /configurator command and reads this
8
+ // globalThis array lazily; the entry shape is duplicated by hand on both sides because
9
+ // install.sh copies one extension directory at a time.
10
+ export const CONFIGURATOR_REGISTRY_KEY = "__oplConfiguratorTabs";
11
+
12
+ /** What the configurator shell hands to each tab's factory. */
13
+ export interface ConfiguratorTabApi {
14
+ ctx: ExtensionContext;
15
+ tui: { requestRender(): void };
16
+ theme: Theme;
17
+ keybindings: KeybindingsManager;
18
+ done: (result: void) => void;
19
+ }
20
+
21
+ export interface ConfiguratorTabComponent {
22
+ render(width: number): string[];
23
+ invalidate?(): void;
24
+ handleInput?(data: string): void;
25
+ }
26
+
27
+ interface ConfiguratorTab {
28
+ id: string;
29
+ hotkey: string;
30
+ label: string;
31
+ create: (api: ConfiguratorTabApi) => ConfiguratorTabComponent;
32
+ }
33
+
34
+ const BASE_COMPANION_TYPES = ["cat", "dog", "bunny"];
35
+
36
+ function unique(values: string[]): string[] {
37
+ return [...new Set(values)];
38
+ }
39
+
40
+ function declaredCompanionTypes(companion: Record<string, unknown>): string[] {
41
+ const types = companion.types;
42
+ if (!Array.isArray(types)) return [];
43
+ return unique(
44
+ types
45
+ .filter((entry): entry is Record<string, unknown> => typeof entry === "object" && entry !== null)
46
+ .map((entry) => entry.typeName)
47
+ .filter((name): name is string => typeof name === "string" && name.length > 0),
48
+ );
49
+ }
50
+
51
+ function companionRecord(config: Record<string, unknown>): Record<string, unknown> {
52
+ return typeof config.companion === "object" && config.companion !== null && !Array.isArray(config.companion)
53
+ ? (config.companion as Record<string, unknown>)
54
+ : {};
55
+ }
56
+
57
+ /** Built-ins plus any declared typeName and the live selection, in that order, deduped. */
58
+ export function companionTypeValues(config: Record<string, unknown>): string[] {
59
+ const companion = companionRecord(config);
60
+ const currentType = typeof companion.type === "string" && companion.type.length > 0 ? companion.type : "cat";
61
+ return unique([...BASE_COMPANION_TYPES, ...declaredCompanionTypes(companion), currentType]);
62
+ }
63
+
64
+ /** Map a setting item id to its write patch; null for an id the tab does not own. */
65
+ export function inputFieldPatch(id: string, value: string): EditableInputConfig | null {
66
+ if (id === "boxedView") return { boxedView: value === "true" };
67
+ if (id === "companion.enabled") return { companion: { enabled: value === "true" } };
68
+ if (id === "companion.type") return { companion: { type: value } };
69
+ return null;
70
+ }
71
+
72
+ /** Publish the input settings screen as a tab, once per extension load. */
73
+ export function registerInputConfiguratorTab(): void {
74
+ const tab: ConfiguratorTab = {
75
+ id: "opl-input",
76
+ hotkey: "i",
77
+ label: "Input",
78
+ create: (api) => createInputComponent(api),
79
+ };
80
+
81
+ const holder = globalThis as Record<string, unknown>;
82
+ const registry = holder[CONFIGURATOR_REGISTRY_KEY];
83
+ if (Array.isArray(registry)) {
84
+ if (!registry.some((entry) => entry?.id === "opl-input")) registry.push(tab);
85
+ } else {
86
+ holder[CONFIGURATOR_REGISTRY_KEY] = [tab];
87
+ }
88
+ }
89
+
90
+ function createInputComponent(api: ConfiguratorTabApi): ConfiguratorTabComponent {
91
+ const { ctx, theme, done } = api;
92
+ let config = readInputConfig();
93
+ let settingsList: SettingsList | null = null;
94
+
95
+ const createSettingsList = (): SettingsList => {
96
+ const companion = companionRecord(config);
97
+ const boxedView = typeof config.boxedView === "boolean" ? config.boxedView : true;
98
+ const enabled = typeof companion.enabled === "boolean" ? companion.enabled : false;
99
+ const currentType = typeof companion.type === "string" && companion.type.length > 0 ? companion.type : "cat";
100
+
101
+ const typeValues = companionTypeValues(config);
102
+
103
+ const items: SettingItem[] = [
104
+ { id: "boxedView", label: "Boxed view", currentValue: boxedView ? "true" : "false", values: ["true", "false"] },
105
+ { id: "companion.enabled", label: "Companion", currentValue: enabled ? "true" : "false", values: ["true", "false"] },
106
+ { id: "companion.type", label: "Companion type", currentValue: currentType, values: typeValues },
107
+ ];
108
+
109
+ return new SettingsList(items, items.length, getSettingsListTheme(), save, () => done(undefined));
110
+ };
111
+
112
+ const save = (id: string, value: string) => {
113
+ const patch = inputFieldPatch(id, value);
114
+ if (!patch) return; // never misroute an unexpected id into companion.type
115
+
116
+ try {
117
+ const changed = saveInputConfig(patch);
118
+ if (changed) {
119
+ config = readInputConfig();
120
+ settingsList = createSettingsList();
121
+ settingsList.selectItem(id);
122
+ ctx.ui.notify("opl-input saved. Run /reload (or restart Pi) to apply.", "info");
123
+ }
124
+ } catch (error) {
125
+ const message = error instanceof Error ? error.message : String(error);
126
+ ctx.ui.notify(`Could not save opl-input config: ${message}`, "error");
127
+ done(undefined);
128
+ }
129
+ };
130
+
131
+ settingsList = createSettingsList();
132
+
133
+ return {
134
+ render(width: number) {
135
+ return [
136
+ truncateToWidth(theme.bold("Configure OPL Input"), width),
137
+ truncateToWidth(theme.fg("dim", "Reload (/reload) is required for changes to apply"), width),
138
+ ...(settingsList ?? createSettingsList()).render(width),
139
+ ];
140
+ },
141
+ invalidate() { settingsList?.invalidate(); },
142
+ handleInput(data: string) { settingsList?.handleInput?.(data); },
143
+ };
144
+ }
@@ -3,6 +3,7 @@ import type { TUI, EditorTheme } from "@earendil-works/pi-tui";
3
3
  import type { KeybindingsManager } from "@earendil-works/pi-coding-agent";
4
4
  import { visibleWidth, truncateToWidth } from "@earendil-works/pi-tui";
5
5
  import { CONFIG, COMPANION_PADDING, MIN_WIDTH_FOR_COMPANION } from "./config.js";
6
+ import { registerInputConfiguratorTab } from "./configure.js";
6
7
  import { resolveModeStyle, type ModeAppearance } from "./mode-style.js";
7
8
  import { applyColor, CompanionAnimator, COMPANION_TICK_MS, IDLE_REPAINT_MS, startRenderTimer } from "./utils.js";
8
9
 
@@ -13,6 +14,34 @@ function plainText(line: string): string {
13
14
  return line.replace(ANSI_RE, "");
14
15
  }
15
16
 
17
+ // ─── pi-tui private render format ─────────────────────────────────────────────
18
+ // The box renderers below must tell the editor's horizontal borders from its scroll
19
+ // indicator, and pi-tui exposes neither as an API nor as a stable class: it is plain text.
20
+ // These two markers are the whole contract, so they live here instead of being duplicated in
21
+ // each renderer, and tests/opl-input-pi-tui-markers.test.mjs asserts the installed pi-tui
22
+ // still emits them. Verified byte-identical in pi-tui 0.87.0, 0.99.1 and 1.1.0
23
+ // (dist/components/editor.js: `─── ${direction} ${hiddenLineCount} more `).
24
+ const BORDER_CHAR = "─";
25
+ const SCROLL_INDICATOR_RE = /((?:↑|↓)\s*\d+\s*more)/;
26
+
27
+ /** Solid border: after stripping ANSI every character is the box-drawing horizontal. */
28
+ export function isSolidBorder(line: string): boolean {
29
+ return plainText(line).replaceAll(BORDER_CHAR, "").length === 0;
30
+ }
31
+
32
+ /** The scroll indicator (`─── ↑ 3 more `) is border-like but carries text worth preserving. */
33
+ export function scrollIndicatorText(line: string): string | null {
34
+ const plain = plainText(line);
35
+ if (!plain.startsWith(BORDER_CHAR)) return null;
36
+ const match = plain.match(SCROLL_INDICATOR_RE);
37
+ return match ? match[1]! : null;
38
+ }
39
+
40
+ /** Anything that marks the top or bottom edge of the stock editor output. */
41
+ export function isBorderLike(line: string): boolean {
42
+ return isSolidBorder(line) || scrollIndicatorText(line) !== null;
43
+ }
44
+
16
45
  function activeMode(): { mode: string; appearance?: ModeAppearance } {
17
46
  const state = (globalThis as Record<string, unknown>).__agentMode as
18
47
  | { mode?: string; appearance?: ModeAppearance }
@@ -121,19 +150,6 @@ class ChatInput extends CustomEditor {
121
150
  ): string[] {
122
151
  const innerWidth = width - 2;
123
152
 
124
- // Solid border: after stripping ANSI every char is "─"
125
- const isSolidBorder = (line: string) => plainText(line).replace(/─/g, "").length === 0;
126
-
127
- // Scroll indicator: starts with "─" and contains ↑/↓ N more
128
- const getScrollText = (line: string): string | null => {
129
- const plain = plainText(line);
130
- if (!plain.startsWith("─")) return null;
131
- const m = plain.match(/((?:↑|↓)\s*\d+\s*more)/);
132
- return m ? m[1] : null;
133
- };
134
-
135
- const isBorderLike = (line: string) => isSolidBorder(line) || getScrollText(line) !== null;
136
-
137
153
  const firstIdx = stock.findIndex(isBorderLike);
138
154
  let lastIdx = -1;
139
155
  for (let i = stock.length - 1; i >= 0; i--) {
@@ -158,8 +174,8 @@ class ChatInput extends CustomEditor {
158
174
  return border("└") + border(mid) + border("─".repeat(remaining)) + border("┘");
159
175
  };
160
176
 
161
- const topScrollText = firstIdx !== -1 ? getScrollText(stock[firstIdx]!) : null;
162
- const bottomScrollText = lastIdx !== -1 && lastIdx !== firstIdx ? getScrollText(stock[lastIdx]!) : null;
177
+ const topScrollText = firstIdx !== -1 ? scrollIndicatorText(stock[firstIdx]!) : null;
178
+ const bottomScrollText = lastIdx !== -1 && lastIdx !== firstIdx ? scrollIndicatorText(stock[lastIdx]!) : null;
163
179
 
164
180
  const top = buildTop(topScrollText);
165
181
  const bottom = buildBottom(bottomScrollText);
@@ -207,19 +223,6 @@ class ChatInput extends CustomEditor {
207
223
  accent: (s: string) => string,
208
224
  prefix: string,
209
225
  ): string[] {
210
- // Solid border: after stripping ANSI every char is "─"
211
- const isSolidBorder = (line: string) => plainText(line).replace(/─/g, "").length === 0;
212
-
213
- // Scroll indicator: starts with "─" and contains ↑/↓ N more
214
- const getScrollText = (line: string): string | null => {
215
- const plain = plainText(line);
216
- if (!plain.startsWith("─")) return null;
217
- const m = plain.match(/((?:↑|↓)\s*\d+\s*more)/);
218
- return m ? m[1] : null;
219
- };
220
-
221
- const isBorderLike = (line: string) => isSolidBorder(line) || getScrollText(line) !== null;
222
-
223
226
  const firstIdx = stock.findIndex(isBorderLike);
224
227
  let lastIdx = -1;
225
228
  for (let i = stock.length - 1; i >= 0; i--) {
@@ -244,8 +247,8 @@ class ChatInput extends CustomEditor {
244
247
  return border(mid) + border("─".repeat(remaining));
245
248
  };
246
249
 
247
- const topScrollText = firstIdx !== -1 ? getScrollText(stock[firstIdx]!) : null;
248
- const bottomScrollText = lastIdx !== -1 && lastIdx !== firstIdx ? getScrollText(stock[lastIdx]!) : null;
250
+ const topScrollText = firstIdx !== -1 ? scrollIndicatorText(stock[firstIdx]!) : null;
251
+ const bottomScrollText = lastIdx !== -1 && lastIdx !== firstIdx ? scrollIndicatorText(stock[lastIdx]!) : null;
249
252
 
250
253
  const top = buildTop(topScrollText);
251
254
  const bottom = buildBottom(bottomScrollText);
@@ -304,4 +307,9 @@ export default function (pi: ExtensionAPI) {
304
307
  activeEditor?.dispose();
305
308
  activeEditor = null;
306
309
  });
310
+
311
+ // Expose the settings screen to opl-configurator's generic shell; the shell owns the
312
+ // /configurator command and the tab only writes opl-input.json (a /reload is still
313
+ // required before the running editor picks the change up).
314
+ registerInputConfiguratorTab();
307
315
  }
@@ -19,8 +19,12 @@ export interface ResolvedModeStyle {
19
19
  prefix: string;
20
20
  }
21
21
 
22
+ /** The baseline every unknown mode falls back to, kept as a constant so the fallback is a value
23
+ * rather than an index into a record that TypeScript must treat as possibly missing. */
24
+ const OFF_DEFAULT: Required<ModeAppearance> = { prefix: "❯", prefixColor: "accent", borderColor: "border" };
25
+
22
26
  const MODE_DEFAULTS: Record<string, Required<ModeAppearance>> = {
23
- off: { prefix: "❯", prefixColor: "accent", borderColor: "border" },
27
+ off: OFF_DEFAULT,
24
28
  chat: { prefix: "»", prefixColor: "borderAccent", borderColor: "borderAccent" },
25
29
  plan: { prefix: "⏸", prefixColor: "customMessageLabel", borderColor: "customMessageLabel" },
26
30
  execute: { prefix: "⏸", prefixColor: "customMessageLabel", borderColor: "customMessageLabel" },
@@ -28,7 +32,7 @@ const MODE_DEFAULTS: Record<string, Required<ModeAppearance>> = {
28
32
 
29
33
  /** Precedence: bash > active mode appearance > hardcoded mode fallback. */
30
34
  export function resolveModeStyle(state: ModeState): ResolvedModeStyle {
31
- const fallback = MODE_DEFAULTS[state.mode] ?? MODE_DEFAULTS.off;
35
+ const fallback = MODE_DEFAULTS[state.mode] ?? OFF_DEFAULT;
32
36
  if (state.bash) return { borderColor: "bashMode", prefixColor: "bashMode", prefix: fallback.prefix };
33
37
  return {
34
38
  borderColor: state.appearance?.borderColor ?? fallback.borderColor,
@@ -11,7 +11,7 @@ Unified mode manager for Pi. It provides normal, read-only chat, read-only plann
11
11
  - `--chat` and `--plan` start Pi in the corresponding read-only mode.
12
12
  - The configured cycle shortcut rotates through enabled visible modes. Execute mode is excluded from cycling because it requires an active plan. The default `ctrl+alt+m` does not collide with any built-in Pi keybinding; set `shortcuts.cycleMode` to rebind.
13
13
 
14
- Plans are Markdown files under `.pi/plans/` with the `plan-` filename prefix. A plan name must contain at least one letter or digit, so `/plan .` or `/plan --` is rejected instead of producing an untitled `plan-.md`. Loading a plan appends a TUI-only card (via `registerEntryRenderer`) so the plan text never enters the model context; the execute-mode system prompt is the single model-facing copy of the plan. `plan_complete` is available in execute mode and in any mode that sets `allowPlanComplete`. On completion, the plan file is deleted when `cleanup.cleanupOnComplete` is enabled. If execution ends without `plan_complete`, execute mode is exited automatically at Pi's `agent_before_settle` boundary (added in Pi 0.87.0) — but only when the run settled as `completed`: an aborted (ESC) or errored run keeps execute mode active so the plan can be resumed. On Pi < 0.87.0 this auto-exit is unavailable because the boundary is never emitted; `plan_complete` still exits the mode on any version.
14
+ Plans are Markdown files under `<session directory>/.pi/plans/` with the `plan-` filename prefix - the directory comes from `ExtensionContext.cwd`, captured at `session_start`, so a session opened in one project never reads or writes another project's plans. A plan name must contain at least one letter or digit, so `/plan .` or `/plan --` is rejected instead of producing an untitled `plan-.md`. Loading a plan appends a TUI-only card (via `registerEntryRenderer`) so the plan text never enters the model context; the execute-mode system prompt is the single model-facing copy of the plan, capped at `plan.maxInjectBytes` (default 24 KB), truncating on a line boundary and ending with a marker that names the plan file — the file on disk stays authoritative and is never rewritten. The card stores at most `plan.maxEntryBytes` (default 4 KB) and shows the same marker. `plan_complete` is available in execute mode and in any mode that sets `allowPlanComplete`. On completion, the plan file is deleted when `cleanup.cleanupOnComplete` is enabled. If execution ends without `plan_complete`, execute mode is exited automatically at Pi's `agent_before_settle` boundary (added in Pi 0.87.0) — but only when the run settled as `completed`: an aborted (ESC) or errored run keeps execute mode active so the plan can be resumed. On Pi < 0.87.0 this auto-exit is unavailable because the boundary is never emitted; `plan_complete` still exits the mode on any version.
15
15
 
16
16
  ## Extension features
17
17
 
@@ -35,7 +35,7 @@ A custom mode with no `tools` array inherits **all** tools, including `write` an
35
35
 
36
36
  `off` behaves the same way: it restores the tools that were active before the mode (never a wider set). Set `modes.off.tools` to pin the resting tool set instead — for example to keep `subagent`/`browser` out of normal mode entirely. Pinning replaces the snapshot taken on mode entry, so a later mode that lists no `tools` inherits the pinned set rather than the older, wider one. `load_tools` is offered in `off` because withheld lazy tools are otherwise unreachable there.
37
37
 
38
- Model overrides are resolved through Pi's model registry when entering a mode. A blank or partial `model` (for example `{ "provider": "", "id": "" }`) is treated as **no override**, so the mode keeps the current model instead of warning `Model not found: /`. The previously active model is captured once per mode and restored on exit; the restore point is persisted in the mode session entry, so `/reload` and `/resume` inside a mode no longer lose it. If the restore target is gone from the registry it is dropped immediately; if it exists but has no credentials (before `/login`, say) the restore is retried once and then released, so a stale point cannot block future captures indefinitely. `Pi.setModel` is session-scoped: it never rewrites your configured `defaultProvider`/`defaultModel`, but it does append a model change to the session transcript, which is why the restore point is persisted alongside the mode. Changes are serialized, so a rapid mode exit cannot leave a superseded mode model active. Note that setting `modes.off.model` makes OFF a pinned baseline: it is applied on every mode exit *and* at session start, overriding `--model` and the configured default. Use Pi theme color tokens for widget label colors.
38
+ Model overrides are resolved through Pi's model registry when entering a mode. A blank or partial `model` (for example `{ "provider": "", "id": "" }`) is treated as **no override**, so the mode keeps the current model instead of warning `Model not found: /`. The previously active model is captured once per mode and restored on exit; the restore point is persisted in the mode session entry, so `/reload` and `/resume` inside a mode no longer lose it. If the restore target is gone from the registry it is dropped immediately; if it exists but has no credentials (before `/login`, say) the restore is retried once and then released, so a stale point cannot block future captures indefinitely. `pi.setModel` is session-scoped: it never rewrites your configured `defaultProvider`/`defaultModel`, but it does append a model change to the session transcript, which is why the restore point is persisted alongside the mode. Changes are serialized, so a rapid mode exit cannot leave a superseded mode model active. Note that setting `modes.off.model` makes OFF a pinned baseline: it is applied on every mode exit *and* at session start, overriding `--model` and the configured default. Use Pi theme color tokens for widget label colors.
39
39
 
40
40
  Mode state is persisted in session entries and restored on session resume or branch changes. Pi ignores tool names it does not know, so a `tools` list with a typo would silently lose tools: every mode entry checks the list against the registered tools and warns once per unknown name per session. The `mode-switcher` entry type and legacy chat/plan event identifiers are compatibility contracts. On resume, an unknown mode name in a session entry is ignored (falling back to normal), and plan filenames containing path separators or `..` are dropped, so a stale or foreign branch cannot leave the session unrestricted or point plan reads outside `.pi/plans/`.
41
41
 
@@ -83,6 +83,8 @@ Create `~/.pi/agent/configs/opl-modes.json` or copy [`configs/opl-modes.json.sam
83
83
  | `ui.hideNotify` / `ui.hideWidget` | Suppress mode notifications or widgets. |
84
84
  | `shortcuts.cycleMode` | Keybinding for cycling enabled visible modes. |
85
85
  | `cleanup.cleanupOnComplete` | Delete the active plan after successful `plan_complete`. |
86
+ | `plan.maxInjectBytes` | Bytes of plan text allowed into the system prompt for execute or refine (default `24000`). The UTF-8 cap includes the marker and retains complete lines. If the source path cannot fit, the marker refers to the plan file; very small budgets use a shortened notice. The file itself is never modified. Configured ceilings must be positive integers; invalid values fall back to defaults. |
87
+ | `plan.maxEntryBytes` | Bytes of plan text stored in the `plan-mode` session entry (default `4096`). The cap includes the marker; the card retains the full file path separately. |
86
88
  | `defaultNotifyTemplate` | Notification template for custom modes; `{Name}` is capitalized mode name. |
87
89
  | `modes.chat.tools` / `modes.plan.tools` | Replace the respective built-in read-only tool lists. |
88
90
  | `chatAllowedTools` / `planAllowedTools` | **Deprecated compatibility aliases** for the built-in tool lists. They are used only when the corresponding `modes.<name>.tools` is omitted; migrate to `modes.chat.tools` or `modes.plan.tools`. |
@@ -113,8 +115,6 @@ Use `modes.<name>.appearance` to keep any mode's visual identity with its defini
113
115
 
114
116
  `prefix`, `prefixColor`, and `borderColor` style `opl-input`; omitted fields use its compiled mode defaults. `modeColor` styles the value in `opl-footer`'s `mode_switcher` segment; if omitted, the footer uses hardcoded `muted`. Colors accept Pi theme tokens, six-digit hex, or the three-digit `#abc` shorthand (expanded to `#aabbcc`).
115
117
 
116
- Model overrides are resolved through Pi's model registry when entering a mode and the previously active model is restored on exit when applicable. Changes are serialized, so a rapid mode exit cannot leave a superseded mode model active. Use Pi theme color tokens for widget label colors.
117
-
118
118
  ### Lazy tool loading
119
119
 
120
120
  `lazyTools` withholds heavy tool schemas from the active set until the model actually needs them, keeping the system-prompt prefix (and its prompt-cache write) smaller every session:
@@ -136,6 +136,18 @@ Behavior:
136
136
 
137
137
  Caching note: activating a lazy tool mid-session preserves the cached prefix on models with native deferred tool loading (Anthropic 4.5+, OpenAI gpt-5.4+) and otherwise triggers one prompt-cache rewrite from that point. It is most effective for tools you use occasionally (delegation, browser automation, benchmarking).
138
138
 
139
+ ## Cross-extension seams
140
+
141
+ Extensions install one directory at a time and load in any combination, so `globalThis` is
142
+ the only channel between them. `opl-modes` publishes `__agentMode`, `__planMode` and
143
+ `__chatMode`; `opl-footer` publishes `__footerRequestRender` while a footer is mounted.
144
+
145
+ Nothing may assume the other side is present or healthy. The render trigger is checked for
146
+ being a function and called inside a `try`: a footer whose TUI was already torn down must not
147
+ fail a mode transition. `session_start` re-publishes the three mode globals and
148
+ `session_shutdown` deletes them, so a later session in the same process never reads a mode
149
+ that belongs to a dead one.
150
+
139
151
  ## Architecture
140
152
 
141
153
  `config.ts` registers built-in modes, merges configured overrides, compiles Bash patterns, and publishes the registry. `state.ts` persists and restores mode/plan state (including the persisted model restore point) and is the single publisher of `globalThis.__agentMode`. `index.ts` wires commands, picker, lifecycle hooks, tool replacement, Bash interception, and `plan_complete`; `utils.ts` handles plan files, the per-segment Bash gate, and shared helpers.
@@ -1,8 +1,8 @@
1
1
  /** Config: tool allowlists, bash patterns, prompt templates, plan file constants, user config. */
2
2
 
3
3
  import { readFileSync } from "node:fs";
4
- import { homedir } from "node:os";
5
4
  import { join } from "node:path";
5
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
6
  import type { ModeSwitcherUserConfig, ModeDefinition, PartialModeDefinition, ModeModelConfig } from "./types.js";
7
7
 
8
8
  // ─── Plan File Constants ────────────────────────────────────────────────────
@@ -150,7 +150,8 @@ After listing all steps, stop and wait for the user to choose:
150
150
  Do NOT attempt to make any file changes, run destructive commands, or modify anything.`;
151
151
 
152
152
  /** System prompt injected when in EXECUTE mode. */
153
- export function buildExecutePrompt(planContent: string): string {
153
+ export function buildExecutePrompt(planContent: string, source?: string): string {
154
+ const capped = capPlan(planContent, USER_CONFIG.plan.maxInjectBytes, source);
154
155
  return `\
155
156
  You are in EXECUTE MODE. Execute the plan below step by step.
156
157
 
@@ -159,16 +160,17 @@ After completing ALL steps, call plan_complete() to signal that execution is fin
159
160
  If the \`todo\` tool is available, use it to track progress: add all plan steps at the start of execution, then toggle each one done as you complete it.
160
161
 
161
162
  Plan:
162
- ${planContent}`;
163
+ ${capped.text}`;
163
164
  }
164
165
 
165
166
  /** System prompt injected when refining a plan in PLAN mode. */
166
- export function buildRefinePrompt(planContent: string): string {
167
+ export function buildRefinePrompt(planContent: string, source?: string): string {
168
+ const capped = capPlan(planContent, USER_CONFIG.plan.maxInjectBytes, source);
167
169
  return `\
168
170
  You are in PLAN MODE (refining). The user wants to revise the current plan based on their feedback.
169
171
 
170
172
  Current plan:
171
- ${planContent}
173
+ ${capped.text}
172
174
 
173
175
  Each step MUST be self-contained — write it as if the executor has no memory of this conversation. Include enough context that it can be carried out with only the plan file and the codebase. Assume the executor will read the relevant files fresh — do not rely on findings you discovered during planning.
174
176
 
@@ -196,6 +198,10 @@ const DEFAULT_CONFIG = {
196
198
  HIDE_NOTIFY: false,
197
199
  HIDE_WIDGET: true,
198
200
  },
201
+ PLAN: {
202
+ MAX_INJECT_BYTES: 24000,
203
+ MAX_ENTRY_BYTES: 4096,
204
+ },
199
205
  SHORTCUTS: {
200
206
  CYCLE_MODE: "ctrl+alt+m",
201
207
  },
@@ -230,15 +236,18 @@ const DEFAULT_CONFIG = {
230
236
  },
231
237
  };
232
238
 
233
- const CONFIG_PATH = join(homedir(), ".pi", "agent", "configs", "opl-modes.json");
239
+ /** Resolved per call so a custom `PI_AGENT_DIR` (pi's own agent dir) is honoured. */
240
+ export function configPath(): string {
241
+ return join(getAgentDir(), "configs", "opl-modes.json");
242
+ }
234
243
 
235
244
  function isRecord(value: unknown): value is Record<string, unknown> {
236
245
  return typeof value === "object" && value !== null && !Array.isArray(value);
237
246
  }
238
247
 
239
- export function loadUserConfig(path = CONFIG_PATH): ModeSwitcherUserConfig {
248
+ export function loadUserConfig(file = configPath()): ModeSwitcherUserConfig {
240
249
  try {
241
- const parsed = JSON.parse(readFileSync(path, "utf8"));
250
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
242
251
  return isRecord(parsed) ? parsed as ModeSwitcherUserConfig : {};
243
252
  } catch {
244
253
  return {};
@@ -335,10 +344,55 @@ export function lazyToolsToEnable(
335
344
  return want.filter((n) => lazy.has(n) && (!allowed || allowed.has(n)) && !active.has(n));
336
345
  }
337
346
 
347
+ /** Positive integer from user config, else the default. Anything else is ignored rather than trusted. */
348
+ function positiveInt(value: unknown, fallback: number): number {
349
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback;
350
+ }
351
+
352
+ export interface CappedPlan {
353
+ text: string;
354
+ truncated: boolean;
355
+ omittedBytes: number;
356
+ }
357
+
358
+ /**
359
+ * Bound a plan body before it is injected into a prompt or stored as an entry.
360
+ * A plan file is user-authored and uncapped: injected whole it is re-sent on
361
+ * every provider request for the run, and stored whole it lives in the session
362
+ * file forever. Truncation is announced so the reader can open the file.
363
+ */
364
+ export function capPlan(plan: string, maxBytes: number, source?: string): CappedPlan {
365
+ const bytes = Buffer.byteLength(plan, "utf8");
366
+ if (!Number.isFinite(maxBytes) || maxBytes <= 0 || bytes <= maxBytes) {
367
+ return { text: plan, truncated: false, omittedBytes: 0 };
368
+ }
369
+ const budget = Math.floor(maxBytes);
370
+ const marker = (shown: number, location: string) =>
371
+ `\n\n[… plan truncated: showing ${shown} of ${bytes} bytes, ${bytes - shown} omitted - read ${location} for the rest …]`;
372
+ // Reserve the widest possible counts, including a UTF-8 source path.
373
+ const reserve = (location: string) => Buffer.byteLength(marker(0, location), "utf8") + String(bytes).length - 1;
374
+ let location = source ?? "the plan file";
375
+ if (reserve(location) > budget) location = "the plan file";
376
+ if (reserve(location) > budget) {
377
+ return { text: "[plan truncated]".slice(0, budget), truncated: true, omittedBytes: bytes };
378
+ }
379
+ const buffer = Buffer.from(plan, "utf8");
380
+ const limit = budget - reserve(location);
381
+ const newline = buffer.lastIndexOf(10, limit);
382
+ // Keep only complete lines. A single line that exceeds the budget stays in the file.
383
+ const head = newline < 0 ? "" : buffer.subarray(0, newline).toString("utf8");
384
+ const shown = Buffer.byteLength(head, "utf8");
385
+ return { text: head + marker(shown, location), truncated: true, omittedBytes: bytes - shown };
386
+ }
387
+
338
388
  export const USER_CONFIG = {
339
389
  cleanup: {
340
390
  cleanupOnComplete: userConfig.cleanup?.cleanupOnComplete ?? DEFAULT_CONFIG.CLEANUP.CLEANUP_ON_COMPLETE,
341
391
  },
392
+ plan: {
393
+ maxInjectBytes: positiveInt(userConfig.plan?.maxInjectBytes, DEFAULT_CONFIG.PLAN.MAX_INJECT_BYTES),
394
+ maxEntryBytes: positiveInt(userConfig.plan?.maxEntryBytes, DEFAULT_CONFIG.PLAN.MAX_ENTRY_BYTES),
395
+ },
342
396
  ui: {
343
397
  hideNotify: userConfig.ui?.hideNotify ?? DEFAULT_CONFIG.UI.HIDE_NOTIFY,
344
398
  hideWidget: userConfig.ui?.hideWidget ?? DEFAULT_CONFIG.UI.HIDE_WIDGET,