@openlines/opl-pi-sht 0.1.19 → 0.1.20
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 +5 -5
- package/configs/opl-footer.json.sample +29 -10
- package/configs/opl-modes.json.sample +1 -0
- package/extensions/opl-footer/README.md +9 -4
- package/extensions/opl-footer/config.ts +131 -8
- package/extensions/opl-footer/configure-navigation.ts +13 -0
- package/extensions/opl-footer/configure.ts +157 -0
- package/extensions/opl-footer/index.ts +20 -0
- package/extensions/opl-footer/segments/index.ts +2 -0
- package/extensions/opl-footer/segments/status.ts +37 -0
- package/extensions/opl-footer/segments/tokens.ts +2 -2
- package/extensions/opl-footer/types.ts +4 -0
- package/extensions/opl-input/README.md +1 -1
- package/extensions/opl-input/index.ts +22 -6
- package/extensions/opl-input/utils.ts +5 -0
- package/extensions/opl-modes/README.md +2 -2
- package/extensions/opl-modes/config.ts +43 -22
- package/extensions/opl-modes/index.ts +13 -13
- package/extensions/opl-modes/utils.ts +12 -0
- package/package.json +2 -2
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
|
|
21
|
-
pi install git:github.com/linellazatin/opl-pi-sht
|
|
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).  | 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). |
|
|
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
|

|
|
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`
|
|
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": [
|
|
4
|
-
|
|
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": [
|
|
11
|
-
|
|
12
|
-
|
|
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": "
|
|
15
|
-
"folder": "
|
|
16
|
-
"branch": "
|
|
17
|
-
"git": "
|
|
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
|
-
|
|
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
|
|
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
|
|
79
|
-
row1RightSegments: userConfig
|
|
80
|
-
row2LeftSegments: userConfig
|
|
81
|
-
row2RightSegments: userConfig
|
|
82
|
-
row3LeftSegments: userConfig
|
|
83
|
-
row3RightSegments: userConfig
|
|
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
|
|
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.
|
|
46
|
+
this.stopCompanionTimer = startRenderTimer(() => {
|
|
47
47
|
this.animator.tick(Date.now());
|
|
48
48
|
this.tui.requestRender();
|
|
49
|
-
}
|
|
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
|
-
|
|
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.
|
|
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
|
|
214
|
-
return
|
|
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
|
-
/**
|
|
361
|
-
function compilePatterns(patterns:
|
|
362
|
-
if (!patterns) return undefined;
|
|
363
|
-
|
|
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?:
|
|
372
|
-
destructivePatterns?:
|
|
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:
|
|
378
|
-
destructive:
|
|
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,
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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))
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openlines/opl-pi-sht",
|
|
3
|
-
"version": "0.1.
|
|
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.20",
|
|
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"
|