@openlines/opl-pi-sht 0.1.19 → 0.1.21

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
@@ -17,8 +17,8 @@ A portable collection of various Pi coding agent extensions. Repository director
17
17
  Install a versioned release from npm or GitHub. Both ship identical content; pick one source per machine, because Pi treats the npm and Git entries as separate packages and installing both loads every extension twice.
18
18
 
19
19
  ```bash
20
- pi install npm:@openlines/opl-pi-sht@0.1.18
21
- pi install git:github.com/linellazatin/opl-pi-sht@v0.1.18
20
+ pi install npm:@openlines/opl-pi-sht@<version>
21
+ pi install git:github.com/linellazatin/opl-pi-sht@<version.tag>
22
22
  ```
23
23
 
24
24
  > Omitting the version on the npm source tracks the latest published release; Git refs stay pinned, so move them with `pi install ...@v<new>`.
@@ -76,7 +76,7 @@ Copy mode overwrites matching destinations. Link mode skips existing destination
76
76
  | [`opl-questionnaire`](extensions/opl-questionnaire/README.md) | Interactive structured-choice tool. | `questionnaire`; no config. |
77
77
  | [`opl-input`](extensions/opl-input/README.md) | Configurable replacement editor - enhanced [pikit chat-input](https://github.com/adrianapan/pikit) (because pet is life, and configurable). ![pet](images/ss-input-pet.png) | No commands/tools;`opl-input.json`. |
78
78
  | [`opl-modes`](extensions/opl-modes/README.md) | Mode, plan, tool-safety, lazy-tool-loading, and active-appearance manager - highly-modified, configrable and enhanced mode-switcher. | `/mode`, `/chat`, `/plan`, `/execute`, `plan_complete`, `load_tools`; `opl-modes.json`. |
79
- | [`opl-footer`](extensions/opl-footer/README.md) | Configurable multi-row status footer - highly-specialized, and enhanced [pikit footer](https://github.com/adrianapan/pikit). | No commands/tools;`opl-footer.json`. |
79
+ | [`opl-footer`](extensions/opl-footer/README.md) | Configurable multi-row status footer - highly-specialized, and enhanced [pikit footer](https://github.com/adrianapan/pikit). | `/configure-opl`; `opl-footer.json`. |
80
80
 
81
81
  ## What you'll gain
82
82
 
@@ -120,7 +120,7 @@ Cold prompt-cache write, measured /init session (opl-modes lazy tools + MCP adap
120
120
 
121
121
  ### See what the agent is doing
122
122
 
123
- - **`opl-footer`** surfaces model, cost, token and cache activity, git state, and per-turn timing in a configurable multi-row footer.
123
+ - **`opl-footer`** surfaces model, cost, token and cache activity, git state, agent status, and per-turn timing in a configurable multi-row footer; `/configure-opl` edits and reorders its layout interactively.
124
124
  - **`opl-input`** is a configurable editor that reflects the active mode's identity, so you always know which mode you are typing into.
125
125
  ![input-footer](images/ss-input-footer.png)
126
126
 
@@ -186,7 +186,7 @@ Copy applicable files from [`configs/`](configs/) to `~/.pi/agent/configs/`. For
186
186
  - `opl-init` and `opl-questionnaire` have no external configuration.
187
187
  - Config files must be valid JSON, with no comments or trailing commas beyond deliberate `_comment` keys.
188
188
  - `opl-modes` owns active-mode appearance. Each mode's `appearance.prefix`, `prefixColor`, and `borderColor` style `opl-input`; `appearance.modeColor` styles `opl-footer`'s unified mode label. Renderers retain hardcoded fallbacks.
189
- - `opl-modes.bashPatterns` is the shared read-only Bash policy now applied to every mode by default; a mode overrides it with its own `safePatterns`/`destructivePatterns` or disables it with `unrestrictedBash: true`.
189
+ - `opl-modes.bashPatterns` is the shared read-only Bash policy now applied to every mode by default; a mode overrides it with its own valid `safePatterns`/`destructivePatterns` array, an empty array explicitly removes that policy, or `unrestrictedBash: true` disables both gates. Malformed per-mode arrays retain the existing policy.
190
190
  - `opl-modes.lazyTools` withholds heavy tool schemas (e.g. `subagent`, `browser`, `simplebench`) from the resting prefix and enables them on demand via `load_tools`, shrinking the per-session prompt-cache write.
191
191
 
192
192
  See each extension README for commands, behavior, configuration fields, runtime constraints, and architecture.
@@ -1,20 +1,39 @@
1
1
  {
2
- "_comment": "Available segment IDs: pi, model, path, git, thinking, caveman, plan_mode, chat_mode, mode_switcher, separator, token_in, token_out, token_total, cache_read, cache_write, cost, context_pct, context_total, session_stats, perf_stats, text:<literal>",
3
- "row1LeftSegments": ["pi", "separator", "model", "separator", "path", "git"],
4
- "row1RightSegments": ["context_pct"],
2
+ "_comment": "Available segment IDs: pi, model, path, git, thinking, caveman, plan_mode, chat_mode, mode_switcher, separator, token_in, token_out, token_total, cache_read, cache_write, cost, context_pct, context_total, session_stats, perf_stats, status, text:<literal>",
3
+ "row1LeftSegments": [
4
+ "pi",
5
+ "separator",
6
+ "model",
7
+ "separator",
8
+ "path",
9
+ "git"
10
+ ],
11
+ "row1RightSegments": [
12
+ "context_pct"
13
+ ],
5
14
  "row2LeftSegments": [
6
15
  "thinking",
7
16
  "separator",
8
17
  "mode_switcher"
9
18
  ],
10
- "row2RightSegments": ["token_total", "separator", "cost"],
11
- "row3LeftSegments": ["session_stats"],
12
- "row3RightSegments": ["perf_stats"],
19
+ "row2RightSegments": [
20
+ "token_total",
21
+ "separator",
22
+ "cost"
23
+ ],
24
+ "row3LeftSegments": [
25
+ "session_stats"
26
+ ],
27
+ "row3RightSegments": [
28
+ "perf_stats",
29
+ "cache_read",
30
+ "cache_write"
31
+ ],
13
32
  "icons": {
14
- "pi": "\ue22c",
15
- "folder": "\uf115",
16
- "branch": "\uf126",
17
- "git": "\uf1d3",
33
+ "pi": "󰏿",
34
+ "folder": "",
35
+ "branch": "",
36
+ "git": "",
18
37
  "separator": "|"
19
38
  },
20
39
  "colors": {
@@ -91,6 +91,7 @@
91
91
  "_comment_tools": "Set modes.chat.tools or modes.plan.tools to replace their default tools. The legacy top-level chatAllowedTools and planAllowedTools keys remain supported but are deprecated.",
92
92
  "_comment_widgetColor": "available colors - accent, border, borderAccent, borderMuted, success, error, warning, muted, dim, text, thinkingText, searchMatchText, userMessageText, customMessageText, customMessageLabel, toolTitle, toolOutput, mdHeading, mdLink, mdLinkUrl, mdCode, mdCodeBlock, mdCodeBlockBorder, mdQuote, mdQuoteBorder, mdHr, mdListBullet, toolDiffAdded, toolDiffRemoved, toolDiffContext, syntaxComment, syntaxKeyword, syntaxFunction, syntaxVariable, syntaxString, syntaxNumber, syntaxType, syntaxOperator, syntaxPunctuation, thinkingOff, thinkingMinimal, thinkingLow, thinkingMedium, thinkingHigh, thinkingXhigh, thinkingMax, bashMode",
93
93
  "_comment_appearance": "Every mode owns appearance here. prefix/prefixColor/borderColor style opl-input; modeColor styles opl-footer's Mode value. All color values accept Pi theme tokens or #RRGGBB.",
94
+ "_comment_modePatterns": "Per-mode safePatterns/destructivePatterns must be arrays of valid regex strings. [] explicitly removes that policy; any malformed non-empty override is ignored and inherits the existing policy.",
94
95
  "modes": {
95
96
  "off": {
96
97
  "_comment": "Built-in mode — no restrictions, normal agent behavior.",
@@ -1,10 +1,10 @@
1
1
  # opl-footer
2
2
 
3
- A customizable three-row footer for the Pi coding agent. It shows model, path, Git, context, thinking, mode, token, cost, session, and performance information in configurable left/right segments.
3
+ A customizable three-row footer for the Pi coding agent. It shows model, path, Git, context, thinking, mode, token, cost, agent status, session, and performance information in configurable left/right segments.
4
4
 
5
5
  ## Commands, flags, and shortcuts
6
6
 
7
- No commands, flags, or shortcuts. Nerd Font detection can be overridden with `FOOTER_NERD_FONTS=1` or `FOOTER_NERD_FONTS=0`.
7
+ `/configure-opl` provides six row/side tabs to toggle or reorder standard segments and their trailing separators, applying changes immediately. Press `r` for reorder view, then `,`/`.` to move the selected segment. The `status` segment shows `Working` during agent execution, `Waiting` while Pi tools run, and `Ready` when the agent settles. Colors, icons, literal text, and other options remain JSON-only. Nerd Font detection can be overridden with `FOOTER_NERD_FONTS=1` or `FOOTER_NERD_FONTS=0`.
8
8
 
9
9
  ## Extension features
10
10
 
@@ -33,7 +33,7 @@ The third row is populated after the first completed turn. Its session and perfo
33
33
  - **Token and cost tracking**: total, cache, input/output, and accumulated cost segments
34
34
  - **Session statistics**: prompt, API-call, and model tool-call counts
35
35
  - **Performance statistics**: cumulative LLM/tool duration, most recent user-prompt-to-completion turnaround time, average time to first token, output rate, and cache-hit percentage
36
- - **Thinking and mode indicators**: thinking-level colors plus caveman, plan, chat, or unified mode segments when available
36
+ - **Thinking, mode, and status indicators**: thinking-level colors plus caveman, plan, chat, unified mode, and `Working`/`Waiting`/`Ready` status segments
37
37
  - **Nerd Font support**: automatic detection with plain-icon fallbacks
38
38
  - **Live updates**: branch changes and session events request footer re-rendering
39
39
 
@@ -64,7 +64,7 @@ See the tracked [`configs/opl-footer.json.sample`](../../configs/opl-footer.json
64
64
 
65
65
  | Segment | Description | Notes |
66
66
  |---------|-------------|-------|
67
- | `pi` | π symbol in accent blue | — |
67
+ | `pi` | π symbol in accent blue | `pi` icon can be modified in config file |
68
68
  | `model` | Model name in pink + `(provider)` in dim | No icon; provider omitted if unavailable |
69
69
  | `path` | Current working directory | `segmentOptions.path.mode`: `"full"` (default) · `"abbreviated"` · `"basename"` |
70
70
  | `git` | Git branch and dirty indicators | `showBranch`, `showStaged`, `showUnstaged`, `showUntracked` (all bool) |
@@ -81,6 +81,9 @@ See the tracked [`configs/opl-footer.json.sample`](../../configs/opl-footer.json
81
81
  | `cache_read` | Cache read tokens (hidden if zero) | — |
82
82
  | `cache_write` | Cache write tokens (hidden if zero) | — |
83
83
  | `context_total` | Total context window size | — |
84
+ | `session_stats` | Prompt, API-call, and tool-call counts | Hidden until the first prompt |
85
+ | `perf_stats` | LLM/tool timing, TTFT, output rate, and cache-hit percentage | Hidden until the first prompt |
86
+ | `status` | `Working`, `Waiting`, or `Ready` | `accent`, `warning`, and `success` theme colors respectively; optional and hidden by default |
84
87
  | `separator` | `\|` divider | Coloured via `separator` in `colors` |
85
88
  | `text:...` | Literal text, e.g. `text:⚡` | — |
86
89
 
@@ -156,6 +159,8 @@ The `session_stats` segment shows prompt, API-call, and model tool-call counts f
156
159
 
157
160
  The `perf_stats` segment shows cumulative session LLM and tool time, average time to first token, output tokens/sec, and cache-hit percentage. The `LLM` figure is followed by the most recent user-prompt-to-completion turnaround time in parentheses, e.g. `LLM 18m 22s (2m 13s)`. This turnaround reflects only fully-settled turns (the `agent_settled` signal, after any retries or compaction), so it stays blank until the first turn completes. All `perf_stats` timing values are ephemeral — they reset each session and are not reconstructed from branch history.
158
161
 
162
+ The `status` segment is `Working` while the agent runs, `Waiting` while one or more Pi tools execute, and `Ready` only after `agent_settled`.
163
+
159
164
  ## Icons
160
165
 
161
166
  Nerd Font icons are auto-detected from your terminal. Ghostty, WezTerm, Kitty, iTerm2, Alacritty, Foot, Rio, and Contour are recognised automatically — everything else falls back to plain Unicode symbols. If detection gets it wrong (e.g. when running inside tmux), override it:
@@ -1,5 +1,5 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
3
  import type { FooterUserConfig, StatusLineSegmentId, ColorScheme, StatusLineSegmentOptions } from "./types.js";
4
4
  import { getDefaultColors } from "./theme.js";
5
5
  import type { IconSet } from "./icons.js";
@@ -12,6 +12,33 @@ const DEFAULT_ROW2_RIGHT: StatusLineSegmentId[] = ["token_total", "separator", "
12
12
  const DEFAULT_ROW3_LEFT: StatusLineSegmentId[] = ["session_stats"];
13
13
  const DEFAULT_ROW3_RIGHT: StatusLineSegmentId[] = ["perf_stats"];
14
14
 
15
+ export type FooterLayoutKey =
16
+ | "row1LeftSegments" | "row1RightSegments"
17
+ | "row2LeftSegments" | "row2RightSegments"
18
+ | "row3LeftSegments" | "row3RightSegments";
19
+
20
+ export const FOOTER_LAYOUT_KEYS: FooterLayoutKey[] = [
21
+ "row1LeftSegments", "row1RightSegments",
22
+ "row2LeftSegments", "row2RightSegments",
23
+ "row3LeftSegments", "row3RightSegments",
24
+ ];
25
+
26
+ export const CONFIGURABLE_SEGMENTS: StatusLineSegmentId[] = [
27
+ "pi", "model", "path", "git", "thinking", "caveman", "plan_mode",
28
+ "chat_mode", "mode_switcher", "token_in", "token_out", "token_total",
29
+ "cache_read", "cache_write", "cost", "context_pct", "context_total",
30
+ "session_stats", "perf_stats", "status",
31
+ ];
32
+
33
+ const DEFAULT_LAYOUTS: Record<FooterLayoutKey, StatusLineSegmentId[]> = {
34
+ row1LeftSegments: DEFAULT_ROW1_LEFT,
35
+ row1RightSegments: DEFAULT_ROW1_RIGHT,
36
+ row2LeftSegments: DEFAULT_ROW2_LEFT,
37
+ row2RightSegments: DEFAULT_ROW2_RIGHT,
38
+ row3LeftSegments: DEFAULT_ROW3_LEFT,
39
+ row3RightSegments: DEFAULT_ROW3_RIGHT,
40
+ };
41
+
15
42
  const DEFAULT_SEGMENT_OPTIONS: StatusLineSegmentOptions = {
16
43
  path: { mode: "full" },
17
44
  git: {
@@ -61,6 +88,102 @@ export function clearUserConfigCache(): void {
61
88
  userConfigCacheTime = 0;
62
89
  }
63
90
 
91
+ export function getLayoutSegments(config: FooterUserConfig, key: FooterLayoutKey): StatusLineSegmentId[] {
92
+ const segments = config[key];
93
+ return Array.isArray(segments) && segments.every((segment) => typeof segment === "string")
94
+ ? segments
95
+ : DEFAULT_LAYOUTS[key];
96
+ }
97
+
98
+ export function hasSegmentSeparator(
99
+ config: FooterUserConfig,
100
+ key: FooterLayoutKey,
101
+ segment: StatusLineSegmentId,
102
+ ): boolean {
103
+ const layout = getLayoutSegments(config, key);
104
+ const index = layout.indexOf(segment);
105
+ return index !== -1 && layout[index + 1] === "separator";
106
+ }
107
+
108
+ export function setLayoutSegment(
109
+ config: FooterUserConfig,
110
+ key: FooterLayoutKey,
111
+ segment: StatusLineSegmentId,
112
+ shown: boolean,
113
+ ): FooterUserConfig {
114
+ const current = getLayoutSegments(config, key);
115
+ const index = current.indexOf(segment);
116
+ if (shown && index !== -1) return config;
117
+
118
+ const next = [...current];
119
+ if (!shown) {
120
+ if (index === -1) return config;
121
+ next.splice(index, 1);
122
+ if (next[index] === "separator") next.splice(index, 1);
123
+ return { ...config, [key]: next };
124
+ }
125
+
126
+ const order = CONFIGURABLE_SEGMENTS.indexOf(segment);
127
+ const insertAt = next.findIndex((item) => CONFIGURABLE_SEGMENTS.indexOf(item) > order);
128
+ next.splice(insertAt === -1 ? next.length : insertAt, 0, segment);
129
+ return { ...config, [key]: next };
130
+ }
131
+
132
+ export function moveLayoutSegment(
133
+ config: FooterUserConfig,
134
+ key: FooterLayoutKey,
135
+ segment: StatusLineSegmentId,
136
+ direction: "up" | "down",
137
+ ): FooterUserConfig {
138
+ const current = getLayoutSegments(config, key);
139
+ const start = current.indexOf(segment);
140
+ if (start === -1) return config;
141
+
142
+ const end = start + (current[start + 1] === "separator" ? 2 : 1);
143
+ if (direction === "up") {
144
+ let previousStart = start - 1;
145
+ if (current[previousStart] === "separator") previousStart--;
146
+ if (previousStart < 0 || !CONFIGURABLE_SEGMENTS.includes(current[previousStart]!)) return config;
147
+ return {
148
+ ...config,
149
+ [key]: [...current.slice(0, previousStart), ...current.slice(start, end), ...current.slice(previousStart, start), ...current.slice(end)],
150
+ };
151
+ }
152
+
153
+ const nextStart = end;
154
+ if (!CONFIGURABLE_SEGMENTS.includes(current[nextStart]!)) return config;
155
+ const nextEnd = nextStart + (current[nextStart + 1] === "separator" ? 2 : 1);
156
+ return {
157
+ ...config,
158
+ [key]: [...current.slice(0, start), ...current.slice(nextStart, nextEnd), ...current.slice(start, end), ...current.slice(nextEnd)],
159
+ };
160
+ }
161
+
162
+ export function setSegmentSeparator(
163
+ config: FooterUserConfig,
164
+ key: FooterLayoutKey,
165
+ segment: StatusLineSegmentId,
166
+ shown: boolean,
167
+ ): FooterUserConfig {
168
+ const current = getLayoutSegments(config, key);
169
+ const index = current.indexOf(segment);
170
+ if (index === -1) return config;
171
+ const hasSeparator = current[index + 1] === "separator";
172
+ if (shown === hasSeparator) return config;
173
+
174
+ const next = [...current];
175
+ if (shown) next.splice(index + 1, 0, "separator");
176
+ else next.splice(index + 1, 1);
177
+ return { ...config, [key]: next };
178
+ }
179
+
180
+ export function saveUserConfig(config: FooterUserConfig): void {
181
+ const configPath = getConfigPath();
182
+ mkdirSync(dirname(configPath), { recursive: true });
183
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
184
+ clearUserConfigCache();
185
+ }
186
+
64
187
  export function getEffectiveConfig(): {
65
188
  row1LeftSegments: StatusLineSegmentId[];
66
189
  row1RightSegments: StatusLineSegmentId[];
@@ -75,12 +198,12 @@ export function getEffectiveConfig(): {
75
198
  const userConfig = loadUserConfig();
76
199
 
77
200
  return {
78
- row1LeftSegments: userConfig?.row1LeftSegments ?? DEFAULT_ROW1_LEFT,
79
- row1RightSegments: userConfig?.row1RightSegments ?? DEFAULT_ROW1_RIGHT,
80
- row2LeftSegments: userConfig?.row2LeftSegments ?? DEFAULT_ROW2_LEFT,
81
- row2RightSegments: userConfig?.row2RightSegments ?? DEFAULT_ROW2_RIGHT,
82
- row3LeftSegments: userConfig?.row3LeftSegments ?? DEFAULT_ROW3_LEFT,
83
- row3RightSegments: userConfig?.row3RightSegments ?? DEFAULT_ROW3_RIGHT,
201
+ row1LeftSegments: getLayoutSegments(userConfig ?? {}, "row1LeftSegments"),
202
+ row1RightSegments: getLayoutSegments(userConfig ?? {}, "row1RightSegments"),
203
+ row2LeftSegments: getLayoutSegments(userConfig ?? {}, "row2LeftSegments"),
204
+ row2RightSegments: getLayoutSegments(userConfig ?? {}, "row2RightSegments"),
205
+ row3LeftSegments: getLayoutSegments(userConfig ?? {}, "row3LeftSegments"),
206
+ row3RightSegments: getLayoutSegments(userConfig ?? {}, "row3RightSegments"),
84
207
  colors: userConfig?.colors ?? getDefaultColors(),
85
208
  segmentOptions: {
86
209
  ...DEFAULT_SEGMENT_OPTIONS,
@@ -0,0 +1,13 @@
1
+ export function nextTabIndex(activeTab: number, direction: "left" | "right", count: number): number {
2
+ return direction === "left"
3
+ ? (activeTab + count - 1) % count
4
+ : (activeTab + 1) % count;
5
+ }
6
+
7
+ export function restoreSelectedItem(
8
+ lists: Array<{ selectItem(id: string): void }>,
9
+ activeTab: number,
10
+ id: string,
11
+ ): void {
12
+ lists[activeTab]?.selectItem(id);
13
+ }
@@ -0,0 +1,157 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
3
+ import { Key, matchesKey, SettingsList, truncateToWidth, type SettingItem } from "@earendil-works/pi-tui";
4
+
5
+ import {
6
+ CONFIGURABLE_SEGMENTS,
7
+ FOOTER_LAYOUT_KEYS,
8
+ getLayoutSegments,
9
+ hasSegmentSeparator,
10
+ loadUserConfig,
11
+ moveLayoutSegment,
12
+ saveUserConfig,
13
+ setLayoutSegment,
14
+ setSegmentSeparator,
15
+ type FooterLayoutKey,
16
+ } from "./config.js";
17
+ import { nextTabIndex, restoreSelectedItem } from "./configure-navigation.js";
18
+
19
+ const LAYOUT_LABELS: Record<FooterLayoutKey, string> = {
20
+ row1LeftSegments: "Row 1 left",
21
+ row1RightSegments: "Row 1 right",
22
+ row2LeftSegments: "Row 2 left",
23
+ row2RightSegments: "Row 2 right",
24
+ row3LeftSegments: "Row 3 left",
25
+ row3RightSegments: "Row 3 right",
26
+ };
27
+
28
+ function segmentLabel(segment: string): string {
29
+ return segment.replaceAll("_", " ");
30
+ }
31
+
32
+ export async function showFooterConfigurator(ctx: ExtensionContext, onSaved: () => void): Promise<void> {
33
+ if (ctx.mode !== "tui") {
34
+ ctx.ui.notify("/configure-opl requires TUI mode", "error");
35
+ return;
36
+ }
37
+
38
+ let config = loadUserConfig() ?? {};
39
+ await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
40
+ let activeTab = 0;
41
+ let view: "settings" | "reorder" = "settings";
42
+ let reorderIndex = 0;
43
+ let settingsLists: SettingsList[] = [];
44
+
45
+ const persist = (next: typeof config, selectedId?: string): boolean => {
46
+ try {
47
+ saveUserConfig(next);
48
+ config = next;
49
+ settingsLists = createSettingsLists();
50
+ if (selectedId) restoreSelectedItem(settingsLists, activeTab, selectedId);
51
+ onSaved();
52
+ return true;
53
+ } catch (error) {
54
+ const message = error instanceof Error ? error.message : String(error);
55
+ ctx.ui.notify(`Could not save footer config: ${message}`, "error");
56
+ done(undefined);
57
+ return false;
58
+ }
59
+ };
60
+
61
+ const save = (id: string, value: string) => {
62
+ const [key, segment, kind] = id.split(":") as [FooterLayoutKey, typeof CONFIGURABLE_SEGMENTS[number], string];
63
+ const next = kind === "separator"
64
+ ? setSegmentSeparator(config, key, segment, value === "shown")
65
+ : setLayoutSegment(config, key, segment, value === "shown");
66
+ persist(next, id);
67
+ };
68
+
69
+ const createSettingsLists = (): SettingsList[] => FOOTER_LAYOUT_KEYS.map((key) => {
70
+ const layout = getLayoutSegments(config, key);
71
+ const items: SettingItem[] = CONFIGURABLE_SEGMENTS.flatMap((segment) => {
72
+ const index = layout.indexOf(segment);
73
+ return [
74
+ {
75
+ id: `${key}:${segment}:segment`,
76
+ label: segmentLabel(segment),
77
+ currentValue: index === -1 ? "hidden" : "shown",
78
+ values: ["shown", "hidden"],
79
+ },
80
+ {
81
+ id: `${key}:${segment}:separator`,
82
+ label: `${segmentLabel(segment)} separator`,
83
+ currentValue: hasSegmentSeparator(config, key, segment) ? "shown" : "hidden",
84
+ values: ["shown", "hidden"],
85
+ },
86
+ ];
87
+ });
88
+ return new SettingsList(items, 15, getSettingsListTheme(), save, () => done(undefined));
89
+ });
90
+
91
+ const visibleSegments = (): typeof CONFIGURABLE_SEGMENTS[number][] =>
92
+ getLayoutSegments(config, FOOTER_LAYOUT_KEYS[activeTab]!).filter((segment) => CONFIGURABLE_SEGMENTS.includes(segment));
93
+
94
+ settingsLists = createSettingsLists();
95
+
96
+ return {
97
+ render(width: number) {
98
+ const tabs = FOOTER_LAYOUT_KEYS.map((key, index) =>
99
+ theme.fg(index === activeTab ? "accent" : "dim", `[${LAYOUT_LABELS[key]}]`),
100
+ ).join(" ");
101
+ const header = [
102
+ truncateToWidth(theme.bold("Configure OPL Footer"), width),
103
+ truncateToWidth(tabs, width),
104
+ ];
105
+ if (view === "settings") {
106
+ return [
107
+ ...header,
108
+ truncateToWidth(theme.fg("dim", "←/→ switch layout · r reorder · Changes apply immediately"), width),
109
+ ...settingsLists[activeTab]!.render(width),
110
+ ];
111
+ }
112
+
113
+ const segments = visibleSegments();
114
+ reorderIndex = Math.min(reorderIndex, Math.max(0, segments.length - 1));
115
+ const rows = segments.length === 0
116
+ ? [theme.fg("dim", "No visible standard segments.")]
117
+ : segments.map((segment, index) => {
118
+ const text = `${index === reorderIndex ? "›" : " "} ${segmentLabel(segment)}${hasSegmentSeparator(config, FOOTER_LAYOUT_KEYS[activeTab]!, segment) ? " + separator" : ""}`;
119
+ return index === reorderIndex ? theme.fg("accent", text) : text;
120
+ });
121
+ return [
122
+ ...header,
123
+ truncateToWidth(theme.fg("dim", "↑/↓ select · ,/. move · r settings · esc close"), width),
124
+ ...rows.map((row) => truncateToWidth(row, width)),
125
+ ];
126
+ },
127
+ invalidate() { settingsLists.forEach((list) => list.invalidate()); },
128
+ handleInput(data: string) {
129
+ if (matchesKey(data, Key.left)) {
130
+ activeTab = nextTabIndex(activeTab, "left", FOOTER_LAYOUT_KEYS.length);
131
+ reorderIndex = 0;
132
+ } else if (matchesKey(data, Key.right)) {
133
+ activeTab = nextTabIndex(activeTab, "right", FOOTER_LAYOUT_KEYS.length);
134
+ reorderIndex = 0;
135
+ } else if (view === "reorder") {
136
+ const segments = visibleSegments();
137
+ if (data === "r") view = "settings";
138
+ else if (matchesKey(data, Key.escape)) done(undefined);
139
+ else if (matchesKey(data, Key.up) && segments.length > 0) reorderIndex = (reorderIndex + segments.length - 1) % segments.length;
140
+ else if (matchesKey(data, Key.down) && segments.length > 0) reorderIndex = (reorderIndex + 1) % segments.length;
141
+ else if ((data === "," || data === ".") && segments[reorderIndex]) {
142
+ const next = moveLayoutSegment(config, FOOTER_LAYOUT_KEYS[activeTab]!, segments[reorderIndex]!, data === "," ? "up" : "down");
143
+ if (next !== config && persist(next)) {
144
+ reorderIndex = data === "," ? Math.max(0, reorderIndex - 1) : Math.min(segments.length - 1, reorderIndex + 1);
145
+ }
146
+ }
147
+ } else if (data === "r") {
148
+ view = "reorder";
149
+ reorderIndex = 0;
150
+ } else {
151
+ settingsLists[activeTab]!.handleInput?.(data);
152
+ }
153
+ tui.requestRender();
154
+ },
155
+ };
156
+ });
157
+ }
@@ -5,10 +5,12 @@ import type { TUI } from "@earendil-works/pi-tui";
5
5
 
6
6
  import type { SegmentContext, StatusLineSegmentId, UsageStats, SessionStats, SessionEvent, ThinkingLevelEvent, AssistantMessageEvent, ToolResultEvent, UserBashEvent } from "./types.js";
7
7
  import { renderSegment } from "./segments/index.js";
8
+ import { createAgentStatusTracker } from "./segments/status.js";
8
9
  import { getGitStatus, invalidateGitStatus, invalidateGitBranch } from "./git-status.js";
9
10
  import { getEffectiveConfig } from "./config.js";
10
11
  import { getIcons } from "./icons.js";
11
12
  import { getDefaultColors, fg } from "./theme.js";
13
+ import { showFooterConfigurator } from "./configure.js";
12
14
 
13
15
  const GIT_BRANCH_PATTERNS: RegExp[] = [
14
16
  /\bgit\s+(checkout|switch|branch\s+-[dDmM]|merge|rebase|pull|reset|worktree)/,
@@ -112,8 +114,16 @@ export default function footer(pi: ExtensionAPI) {
112
114
  let ttftRecordedThisTurn = false;
113
115
  let agentStartMs = 0;
114
116
  let lastTurnaroundMs = 0;
117
+ let statusTracker = createAgentStatusTracker();
115
118
  const toolStartTimes = new Map<string, number>();
116
119
 
120
+ pi.registerCommand("configure-opl", {
121
+ description: "Interactively configure the OPL footer layout",
122
+ handler: async (_args, ctx) => {
123
+ await showFooterConfigurator(ctx, () => tuiRef?.requestRender());
124
+ },
125
+ });
126
+
117
127
  // Track session start
118
128
  pi.on("session_start", async (_event: unknown, ctx: ExtensionContext) => {
119
129
  sessionStartTime = Date.now();
@@ -128,6 +138,7 @@ export default function footer(pi: ExtensionAPI) {
128
138
  ttftRecordedThisTurn = false;
129
139
  agentStartMs = 0;
130
140
  lastTurnaroundMs = 0;
141
+ statusTracker = createAgentStatusTracker();
131
142
  toolStartTimes.clear();
132
143
 
133
144
  if (ctx.hasUI) {
@@ -141,6 +152,8 @@ export default function footer(pi: ExtensionAPI) {
141
152
  // authoritative "fully done, no auto-continuation" signal.
142
153
  pi.on("agent_start", async (_event: unknown, _ctx: ExtensionContext) => {
143
154
  if (agentStartMs === 0) agentStartMs = Date.now();
155
+ statusTracker.agentStarted();
156
+ tuiRef?.requestRender();
144
157
  });
145
158
 
146
159
  pi.on("agent_settled", async (_event: unknown, _ctx: ExtensionContext) => {
@@ -148,6 +161,8 @@ export default function footer(pi: ExtensionAPI) {
148
161
  lastTurnaroundMs = Date.now() - agentStartMs;
149
162
  agentStartMs = 0;
150
163
  }
164
+ statusTracker.agentSettled();
165
+ tuiRef?.requestRender();
151
166
  });
152
167
 
153
168
  pi.on("turn_start", async (_event: unknown, _ctx: ExtensionContext) => {
@@ -163,6 +178,8 @@ export default function footer(pi: ExtensionAPI) {
163
178
 
164
179
  pi.on("tool_execution_start", async (event: { toolCallId: string }, _ctx: ExtensionContext) => {
165
180
  toolStartTimes.set(event.toolCallId, Date.now());
181
+ statusTracker.toolStarted(event.toolCallId);
182
+ tuiRef?.requestRender();
166
183
  });
167
184
 
168
185
  pi.on("tool_execution_end", async (event: { toolCallId: string }, _ctx: ExtensionContext) => {
@@ -173,6 +190,8 @@ export default function footer(pi: ExtensionAPI) {
173
190
  toolMsThisTurn += elapsed;
174
191
  toolStartTimes.delete(event.toolCallId);
175
192
  }
193
+ statusTracker.toolEnded(event.toolCallId);
194
+ tuiRef?.requestRender();
176
195
  });
177
196
 
178
197
  pi.on("message_update", async (_event: unknown, _ctx: ExtensionContext) => {
@@ -298,6 +317,7 @@ export default function footer(pi: ExtensionAPI) {
298
317
  colors,
299
318
  icons: getIcons(effectiveConfig.icons),
300
319
  sessionStats: { prompts: branchPrompts, apiCalls: branchApiCalls, toolCalls: branchToolCalls, llmMs, toolMs, ttftSamples, lastTurnaroundMs },
320
+ agentStatus: statusTracker.status(),
301
321
  };
302
322
  }
303
323
 
@@ -13,6 +13,7 @@ import { planModeSegment } from "./plan-mode.js";
13
13
  import { chatModeSegment } from "./chat-mode.js";
14
14
  import { modeSwitcherSegment } from "./mode-switcher.js";
15
15
  import { sessionStatsSegment, perfStatsSegment } from "./session-stats.js";
16
+ import { statusSegment } from "./status.js";
16
17
 
17
18
  const SEGMENTS = {
18
19
  pi: piSegment,
@@ -35,6 +36,7 @@ const SEGMENTS = {
35
36
  mode_switcher: modeSwitcherSegment,
36
37
  session_stats: sessionStatsSegment,
37
38
  perf_stats: perfStatsSegment,
39
+ status: statusSegment,
38
40
  };
39
41
 
40
42
  export function renderSegment(id: StatusLineSegmentId, ctx: SegmentContext): RenderedSegment {
@@ -0,0 +1,37 @@
1
+ import type { AgentStatus, RenderedSegment, SegmentContext } from "../types.js";
2
+ import { applyColor } from "../theme.js";
3
+
4
+ export function deriveAgentStatus(agentActive: boolean, activeToolCount: number): AgentStatus {
5
+ if (!agentActive) return "ready";
6
+ return activeToolCount > 0 ? "waiting" : "working";
7
+ }
8
+
9
+ export function createAgentStatusTracker() {
10
+ let agentActive = false;
11
+ const activeToolIds = new Set<string>();
12
+
13
+ return {
14
+ agentStarted() { agentActive = true; },
15
+ agentSettled() {
16
+ agentActive = false;
17
+ activeToolIds.clear();
18
+ },
19
+ toolStarted(id: string) { activeToolIds.add(id); },
20
+ toolEnded(id: string) { activeToolIds.delete(id); },
21
+ status() { return deriveAgentStatus(agentActive, activeToolIds.size); },
22
+ };
23
+ }
24
+
25
+ const STATUS_DISPLAY = {
26
+ working: ["Working", "accent"],
27
+ waiting: ["Waiting", "warning"],
28
+ ready: ["Ready", "success"],
29
+ } as const;
30
+
31
+ export const statusSegment = {
32
+ id: "status" as const,
33
+ render(ctx: SegmentContext): RenderedSegment {
34
+ const [label, color] = STATUS_DISPLAY[ctx.agentStatus];
35
+ return { content: applyColor(ctx.theme, color, label), visible: true };
36
+ },
37
+ };
@@ -52,7 +52,7 @@ export const cacheReadSegment = {
52
52
  render(ctx: SegmentContext): RenderedSegment {
53
53
  const { cacheRead } = ctx.usageStats;
54
54
  if (!cacheRead) return { content: "", visible: false };
55
- return { content: val(ctx, formatTokens(cacheRead)), visible: true };
55
+ return { content: lbl(ctx, "Read ") + val(ctx, formatTokens(cacheRead)), visible: true };
56
56
  },
57
57
  };
58
58
 
@@ -61,6 +61,6 @@ export const cacheWriteSegment = {
61
61
  render(ctx: SegmentContext): RenderedSegment {
62
62
  const { cacheWrite } = ctx.usageStats;
63
63
  if (!cacheWrite) return { content: "", visible: false };
64
- return { content: val(ctx, formatTokens(cacheWrite)), visible: true };
64
+ return { content: lbl(ctx, "Write ") + val(ctx, formatTokens(cacheWrite)), visible: true };
65
65
  },
66
66
  };
@@ -53,6 +53,7 @@ export type StatusLineSegmentId =
53
53
  | "mode_switcher"
54
54
  | "session_stats"
55
55
  | "perf_stats"
56
+ | "status"
56
57
  | "separator"
57
58
  | `text:${string}`;
58
59
 
@@ -109,6 +110,8 @@ export interface SessionStats {
109
110
  }
110
111
 
111
112
  // Context passed to segment render functions
113
+ export type AgentStatus = "working" | "waiting" | "ready";
114
+
112
115
  export interface SegmentContext {
113
116
  model: { id: string; name?: string; reasoning?: boolean; contextWindow?: number; provider?: string; baseUrl?: string } | undefined;
114
117
  isLocalModel: boolean;
@@ -126,6 +129,7 @@ export interface SegmentContext {
126
129
  colors: ColorScheme;
127
130
  icons: IconSet;
128
131
  sessionStats: SessionStats;
132
+ agentStatus: AgentStatus;
129
133
  }
130
134
 
131
135
  // Minimal event shapes used by the opl-footer handlers
@@ -80,4 +80,4 @@ Invalid theme tokens fall back to the theme's `border` token; invalid hex render
80
80
 
81
81
  ## Architecture
82
82
 
83
- `index.ts` installs the editor integration; `mode-style.ts` resolves Bash > published mode appearance > compiled fallback; `config.ts` loads editor and companion settings; and `utils.ts` handles color and rendering helpers. `opl-modes` is the sole publisher of active mode appearance through `globalThis.__agentMode`.
83
+ `index.ts` installs the editor integration and disposes companion render timers on editor replacement or session shutdown; `mode-style.ts` resolves Bash > published mode appearance > compiled fallback; `config.ts` loads editor and companion settings; and `utils.ts` handles color and rendering helpers. `opl-modes` is the sole publisher of active mode appearance through `globalThis.__agentMode`.
@@ -4,7 +4,7 @@ import type { KeybindingsManager } from "@earendil-works/pi-coding-agent";
4
4
  import { visibleWidth } from "@earendil-works/pi-tui";
5
5
  import { CONFIG, COMPANION_PADDING, MIN_WIDTH_FOR_COMPANION } from "./config.js";
6
6
  import { resolveModeStyle, type ModeAppearance } from "./mode-style.js";
7
- import { applyColor, CompanionAnimator } from "./utils.js";
7
+ import { applyColor, CompanionAnimator, startRenderTimer } from "./utils.js";
8
8
 
9
9
  // ─── Helpers ──────────────────────────────────────────────────────────────
10
10
  const ANSI_RE = /\x1b\[[0-9;]*m|\x1b\[0?m/g;
@@ -28,7 +28,7 @@ class ChatInput extends CustomEditor {
28
28
  private uiTheme: Theme;
29
29
  private inputTheme: EditorTheme;
30
30
  private animator = new CompanionAnimator();
31
- private companionTimer: ReturnType<typeof setInterval> | null = null;
31
+ private stopCompanionTimer: (() => void) | null = null;
32
32
 
33
33
  constructor(
34
34
  tui: TUI,
@@ -43,10 +43,15 @@ class ChatInput extends CustomEditor {
43
43
  this.inputTheme = theme;
44
44
 
45
45
  // Animate companion even when idle — tick drives state machine
46
- this.companionTimer = setInterval(() => {
46
+ this.stopCompanionTimer = startRenderTimer(() => {
47
47
  this.animator.tick(Date.now());
48
48
  this.tui.requestRender();
49
- }, 100);
49
+ });
50
+ }
51
+
52
+ dispose(): void {
53
+ this.stopCompanionTimer?.();
54
+ this.stopCompanionTimer = null;
50
55
  }
51
56
 
52
57
  private isBashMode(): boolean {
@@ -275,10 +280,21 @@ class ChatInput extends CustomEditor {
275
280
 
276
281
  // ─── Extension entry ──────────────────────────────────────────────────────
277
282
  export default function (pi: ExtensionAPI) {
283
+ let activeEditor: ChatInput | null = null;
284
+
278
285
  pi.on("session_start", async (_event, ctx) => {
286
+ activeEditor?.dispose();
287
+ activeEditor = null;
279
288
  ctx.ui.setEditorComponent((tui: TUI, theme: EditorTheme, kb: KeybindingsManager) => {
289
+ activeEditor?.dispose();
280
290
  const companionColorFn = (s: string) => applyColor(ctx.ui.theme, CONFIG.COMPANION_COLOR, s);
281
- return new ChatInput(tui, theme, kb, companionColorFn, ctx.ui.theme);
291
+ activeEditor = new ChatInput(tui, theme, kb, companionColorFn, ctx.ui.theme);
292
+ return activeEditor;
282
293
  });
283
294
  });
284
- }
295
+
296
+ pi.on("session_shutdown", async () => {
297
+ activeEditor?.dispose();
298
+ activeEditor = null;
299
+ });
300
+ }
@@ -56,6 +56,11 @@ function hexToAnsi(hex: string): string {
56
56
  return `\x1b[38;2;${r};${g};${b}m`;
57
57
  }
58
58
 
59
+ export function startRenderTimer(render: () => void): () => void {
60
+ const timer = setInterval(render, 100);
61
+ return () => clearInterval(timer);
62
+ }
63
+
59
64
  export function applyColor(theme: Theme, color: string, text: string): string {
60
65
  if (isHexColor(color)) {
61
66
  return `${hexToAnsi(color)}${text}\x1b[0m`;
@@ -17,7 +17,7 @@ Plans are Markdown files under `.pi/plans/` with the `plan-` filename prefix. `p
17
17
 
18
18
  ### Mode behavior
19
19
 
20
- Chat and plan modes replace the active tools with configurable read-only tool lists and restrict Bash to safe inspection patterns. Destructive patterns are checked even when a command matches a safe pattern. User-provided safe and destructive pattern arrays replace the built-in lists, rather than extending them.
20
+ Chat and plan modes replace the active tools with configurable read-only tool lists and restrict Bash to safe inspection patterns. Destructive patterns are checked even when a command matches a safe pattern. Valid user-provided safe and destructive pattern arrays replace the built-in lists, rather than extending them. An explicit empty per-mode array replaces that policy with none; a malformed non-empty per-mode array is ignored and inherits the existing policy.
21
21
 
22
22
  The shared destructive base also blocks file mutation primitives (`rm`, `find -delete`/`-exec`, `truncate`, `git clean`/`update-ref`/`tag -d`, `sudo`, shell spawns, and output redirects). `env` and `printenv` are no longer safe-listed, since they could dump provider API keys into the model context. Destructive matching also runs against a quote/backslash-stripped command skeleton, so `r"m"` cannot dodge `\brm\b`; this is still a heuristic backstop, not a process-isolation boundary.
23
23
 
@@ -108,7 +108,7 @@ Use `modes.<name>.appearance` to keep any mode's visual identity with its defini
108
108
 
109
109
  `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 or six-digit hex strings.
110
110
 
111
- Model overrides are resolved through Pi's model registry when entering a mode and the previously active model is restored on exit when applicable. Use Pi theme color tokens for widget label colors.
111
+ 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.
112
112
 
113
113
  ### Lazy tool loading
114
114
 
@@ -3,7 +3,7 @@
3
3
  import { readFileSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
- import type { ModeSwitcherUserConfig, ModeDefinition } from "./types.js";
6
+ import type { ModeSwitcherUserConfig, ModeDefinition, PartialModeDefinition } from "./types.js";
7
7
 
8
8
  // ─── Plan File Constants ────────────────────────────────────────────────────
9
9
 
@@ -208,10 +208,14 @@ const DEFAULT_CONFIG = {
208
208
 
209
209
  const CONFIG_PATH = join(homedir(), ".pi", "agent", "configs", "opl-modes.json");
210
210
 
211
+ function isRecord(value: unknown): value is Record<string, unknown> {
212
+ return typeof value === "object" && value !== null && !Array.isArray(value);
213
+ }
214
+
211
215
  export function loadUserConfig(path = CONFIG_PATH): ModeSwitcherUserConfig {
212
216
  try {
213
- const raw = readFileSync(path, "utf8");
214
- return JSON.parse(raw) as ModeSwitcherUserConfig;
217
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
218
+ return isRecord(parsed) ? parsed as ModeSwitcherUserConfig : {};
215
219
  } catch {
216
220
  return {};
217
221
  }
@@ -357,10 +361,21 @@ export const USER_CONFIG = {
357
361
  /** Mode Registry — stores all registered mode definitions. */
358
362
  export const MODE_REGISTRY = new Map<string, ModeDefinition>();
359
363
 
360
- /** Helper to convert string patterns to RegExp. */
361
- function compilePatterns(patterns: string[] | undefined): RegExp[] | undefined {
362
- if (!patterns) return undefined;
363
- return patterns.map((p) => new RegExp(p, "i"));
364
+ /** Compile a valid pattern array. Undefined means malformed input or no usable patterns. */
365
+ export function compilePatterns(patterns: unknown): RegExp[] | undefined {
366
+ if (!Array.isArray(patterns)) return undefined;
367
+ if (patterns.length === 0) return [];
368
+ const compiled: RegExp[] = [];
369
+ for (const pattern of patterns) {
370
+ if (typeof pattern !== "string") return undefined;
371
+ try {
372
+ compiled.push(new RegExp(pattern, "i"));
373
+ } catch {
374
+ console.warn(`[opl-modes] Invalid pattern: "${pattern}" — ignoring the override`);
375
+ return undefined;
376
+ }
377
+ }
378
+ return compiled;
364
379
  }
365
380
 
366
381
  /** Resolve the Bash pattern pair for a newly registered custom mode from the shared
@@ -368,14 +383,26 @@ function compilePatterns(patterns: string[] | undefined): RegExp[] | undefined {
368
383
  * Omitted components inherit; explicit arrays (even empty) replace; `unrestrictedBash`
369
384
  * disables Bash gating entirely for that mode. */
370
385
  export function resolveCustomPatterns(def: {
371
- safePatterns?: string[];
372
- destructivePatterns?: string[];
386
+ safePatterns?: unknown;
387
+ destructivePatterns?: unknown;
373
388
  unrestrictedBash?: boolean;
374
389
  }): { safe?: RegExp[]; destructive?: RegExp[] } {
375
390
  if (def.unrestrictedBash) return { safe: undefined, destructive: undefined };
376
391
  return {
377
- safe: def.safePatterns !== undefined ? compilePatterns(def.safePatterns) : SAFE_COMMAND_PATTERNS,
378
- destructive: def.destructivePatterns !== undefined ? compilePatterns(def.destructivePatterns) : DESTRUCTIVE_PATTERNS,
392
+ safe: compilePatterns(def.safePatterns) ?? SAFE_COMMAND_PATTERNS,
393
+ destructive: compilePatterns(def.destructivePatterns) ?? DESTRUCTIVE_PATTERNS,
394
+ };
395
+ }
396
+
397
+ export function mergeModeDefinition(existing: ModeDefinition, def: PartialModeDefinition): ModeDefinition {
398
+ return {
399
+ ...existing,
400
+ ...def,
401
+ tools: def.tools ?? existing.tools,
402
+ safePatterns: compilePatterns(def.safePatterns) ?? existing.safePatterns,
403
+ destructivePatterns: compilePatterns(def.destructivePatterns) ?? existing.destructivePatterns,
404
+ labels: { ...existing.labels, ...def.labels },
405
+ appearance: def.appearance ?? existing.appearance,
379
406
  };
380
407
  }
381
408
 
@@ -450,19 +477,13 @@ function initModeRegistry(): void {
450
477
  });
451
478
 
452
479
  // Merge user-defined modes from config (can override built-ins or add new ones).
453
- if (userConfig.modes) {
454
- for (const [name, def] of Object.entries(userConfig.modes)) {
480
+ if (isRecord(userConfig.modes)) {
481
+ for (const [name, rawDefinition] of Object.entries(userConfig.modes)) {
482
+ if (!isRecord(rawDefinition)) continue;
483
+ const def = rawDefinition as PartialModeDefinition;
455
484
  const existing = MODE_REGISTRY.get(name);
456
485
  if (existing) {
457
- registerMode(name, {
458
- ...existing,
459
- ...def,
460
- tools: def.tools ?? existing.tools,
461
- safePatterns: def.safePatterns ? compilePatterns(def.safePatterns) : existing.safePatterns,
462
- destructivePatterns: def.destructivePatterns ? compilePatterns(def.destructivePatterns) : existing.destructivePatterns,
463
- labels: { ...existing.labels, ...def.labels },
464
- appearance: def.appearance ?? existing.appearance,
465
- });
486
+ registerMode(name, mergeModeDefinition(existing, def));
466
487
  } else {
467
488
  const patterns = resolveCustomPatterns(def);
468
489
  registerMode(name, {
@@ -61,6 +61,7 @@ import {
61
61
  sanitizePlanName,
62
62
  extractTextFromMessage,
63
63
  applyLabelColor,
64
+ createLatestModelQueue,
64
65
  } from "./utils.js";
65
66
  import {
66
67
  getMode,
@@ -118,6 +119,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
118
119
  let savedToolNames: string[] | null = null;
119
120
  // ─── Saved model for restoring ─────────────────────────────────────────────
120
121
  let savedModel: Model<any> | null = null;
122
+ const queueModelChange = createLatestModelQueue();
121
123
  const MAX_REFINE_CYCLES = 5;
122
124
 
123
125
  function saveAndSetActiveTools(toolNames: string[]): void {
@@ -127,22 +129,22 @@ export default function modeSwitcher(pi: ExtensionAPI) {
127
129
  pi.setActiveTools(applyLazyPolicy(toolNames));
128
130
  }
129
131
 
130
- /**
131
- * Resolve and switch to a specific model, notifying on failure, then refresh
132
- * the footer via updateStatus. No savedModel bookkeeping — callers that need
133
- * restore semantics use applyModeModel instead.
134
- */
132
+ /** Queue model changes so a superseded async switch cannot finish after its restore. */
133
+ function setQueuedModel(ctx: ExtensionContext, model: Model<any>, label: string): Promise<void> {
134
+ return queueModelChange(async () => {
135
+ const success = await pi.setModel(model);
136
+ if (!success && ctx.hasUI) ctx.ui.notify(`[mode-switcher] No API key for model ${label}`, "error");
137
+ updateStatus(ctx);
138
+ }).then(() => undefined);
139
+ }
140
+
135
141
  async function switchToModel(ctx: ExtensionContext, modelRef: { provider: string; id: string }): Promise<void> {
136
142
  const resolved = ctx.modelRegistry.find(modelRef.provider, modelRef.id);
137
143
  if (!resolved) {
138
144
  if (ctx.hasUI) ctx.ui.notify(`[mode-switcher] Model not found: ${modelRef.provider}/${modelRef.id}`, "warning");
139
145
  return;
140
146
  }
141
- const success = await pi.setModel(resolved);
142
- if (!success && ctx.hasUI) {
143
- ctx.ui.notify(`[mode-switcher] No API key for model ${modelRef.provider}/${modelRef.id}`, "error");
144
- }
145
- updateStatus(ctx);
147
+ await setQueuedModel(ctx, resolved, `${modelRef.provider}/${modelRef.id}`);
146
148
  }
147
149
 
148
150
  /**
@@ -167,9 +169,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
167
169
  if (savedModel === null) return;
168
170
  const toRestore = savedModel;
169
171
  savedModel = null;
170
- pi.setModel(toRestore).then(() => {
171
- updateStatus(ctx);
172
- });
172
+ void setQueuedModel(ctx, toRestore, `${toRestore.provider}/${toRestore.id}`);
173
173
  }
174
174
 
175
175
  /**
@@ -7,6 +7,18 @@ import { join } from "node:path";
7
7
  import { SAFE_COMMAND_PATTERNS, DESTRUCTIVE_PATTERNS, PLAN_DIR, PLAN_FILE_PREFIX } from "./config.js";
8
8
  import type { PlanFileSummary } from "./types.js";
9
9
 
10
+ /** Serialize async model changes so the final requested model wins. */
11
+ export function createLatestModelQueue() {
12
+ let latest = 0;
13
+ let tail = Promise.resolve();
14
+
15
+ return <T>(change: () => Promise<T>): Promise<T | undefined> => {
16
+ const request = ++latest;
17
+ tail = tail.catch(() => undefined).then(() => request === latest ? change() : undefined);
18
+ return tail as Promise<T | undefined>;
19
+ };
20
+ }
21
+
10
22
  /** Check if command matches safe patterns and not destructive patterns. */
11
23
  export function isSafeCommand(command: string): boolean {
12
24
  return SAFE_COMMAND_PATTERNS.some((p) => p.test(command))
@@ -198,7 +198,8 @@ export default function (pi: ExtensionAPI) {
198
198
  const msg = entry.message;
199
199
  if (msg.role !== "toolResult" || msg.toolName !== "todo") continue;
200
200
  const details = msg.details as TodoDetails | undefined;
201
- if (details) {
201
+ // Malformed results exist in real sessions (e.g. schema-rejected calls leave details: {}).
202
+ if (details && Array.isArray(details.todos) && typeof details.nextId === "number") {
202
203
  todos.length = 0;
203
204
  todos.push(...details.todos);
204
205
  nextId = details.nextId;
@@ -353,16 +354,17 @@ export default function (pi: ExtensionAPI) {
353
354
  },
354
355
 
355
356
  renderCall(args, theme, _context) {
356
- let text = theme.fg("toolTitle", theme.bold("todo ")) + theme.fg("muted", args.action);
357
- if (args.text) text += ` ${theme.fg("dim", `"${args.text}"`)}`;
358
- if (args.id !== undefined) text += ` ${theme.fg("accent", `#${args.id}`)}`;
357
+ // args can arrive partial (streaming) or malformed (schema-rejected calls still render).
358
+ let text = theme.fg("toolTitle", theme.bold("todo ")) + theme.fg("muted", String(args?.action ?? ""));
359
+ if (args?.text) text += ` ${theme.fg("dim", `"${args.text}"`)}`;
360
+ if (args?.id !== undefined) text += ` ${theme.fg("accent", `#${args.id}`)}`;
359
361
  return new Text(text, 0, 0);
360
362
  },
361
363
 
362
364
  renderResult(result, { expanded }, theme, _context) {
363
365
  const details = result.details as TodoDetails | undefined;
364
- if (!details) {
365
- const text = result.content[0];
366
+ if (!details || !Array.isArray(details.todos)) {
367
+ const text = Array.isArray(result.content) ? result.content[0] : undefined;
366
368
  return new Text(text?.type === "text" ? text.text : "", 0, 0);
367
369
  }
368
370
  if (details.error) return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
@@ -383,6 +385,7 @@ export default function (pi: ExtensionAPI) {
383
385
  }
384
386
  case "add": {
385
387
  const added = todoList[todoList.length - 1];
388
+ if (!added) return new Text(theme.fg("error", "todo add returned an empty list"), 0, 0);
386
389
  return new Text(
387
390
  theme.fg("success", "✓ Added ") + theme.fg("accent", `#${added.id}`) + " " + theme.fg("muted", added.text),
388
391
  0, 0,
@@ -396,6 +399,10 @@ export default function (pi: ExtensionAPI) {
396
399
  case "clear":
397
400
  return new Text(theme.fg("success", "✓ ") + theme.fg("muted", "Cleared all todos"), 0, 0);
398
401
  }
402
+ // Fallback: switch above must never fall through. Returning undefined here
403
+ // makes pi wrap it in a MouseRegion with an undefined child and crashes the TUI.
404
+ const fb = result.content[0];
405
+ return new Text(theme.fg("error", String(fb?.type === "text" ? fb.text : "Unknown todo result")), 0, 0);
399
406
  },
400
407
  });
401
408
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@openlines/opl-pi-sht",
3
- "version": "0.1.19",
4
- "description": "Pi coding-agent extension kit: token-efficient context, safe modes, fingerprinted /init, model benchmarking, web access, and browser automation.",
3
+ "version": "0.1.21",
4
+ "description": "opl-pi-sht (shouldHaveThat) Pi coding-agent extension kit: token-efficient context, safe modes, fingerprinted /init, model benchmarking, web access, and browser automation.",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/linellazatin/opl-pi-sht.git"
@@ -47,7 +47,7 @@
47
47
  "test:opl-input": "bun test tests/opl-input-style.test.mjs && OPL_EXTENSION=opl-input bun test tests/extension-smoke.test.mjs",
48
48
  "test:opl-modes": "bun test tests/opl-modes-helpers.test.mjs && OPL_EXTENSION=opl-modes bun test tests/extension-smoke.test.mjs",
49
49
  "test:opl-questionnaire": "OPL_EXTENSION=opl-questionnaire bun test tests/extension-smoke.test.mjs",
50
- "test:opl-todo": "bun test tests/opl-todo-config.test.mjs && OPL_EXTENSION=opl-todo bun test tests/extension-smoke.test.mjs",
50
+ "test:opl-todo": "bun test tests/opl-todo-config.test.mjs tests/opl-todo-render.test.mjs && OPL_EXTENSION=opl-todo bun test tests/extension-smoke.test.mjs",
51
51
  "test:opl-webaccess": "bun test tests/opl-webaccess-helpers.test.mjs && OPL_EXTENSION=opl-webaccess bun test tests/extension-smoke.test.mjs",
52
52
  "test:opl-simplebench": "bun test tests/opl-simplebench.test.ts && OPL_EXTENSION=opl-simplebench bun test tests/extension-smoke.test.mjs",
53
53
  "test:opl-ctxtrim": "bun test tests/opl-ctxtrim.test.ts && OPL_EXTENSION=opl-ctxtrim bun test tests/extension-smoke.test.mjs"