@openlines/opl-pi-sht 0.1.18 → 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 +14 -8
- package/configs/opl-footer.json.sample +29 -10
- package/configs/opl-modes.json.sample +6 -5
- package/extensions/opl-browser/README.md +5 -0
- package/extensions/opl-browser/browser.ts +4 -3
- package/extensions/opl-browser/validate.ts +29 -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 +8 -5
- package/extensions/opl-modes/config.ts +64 -25
- package/extensions/opl-modes/index.ts +17 -16
- package/extensions/opl-modes/state.ts +12 -3
- package/extensions/opl-modes/types.ts +2 -0
- package/extensions/opl-modes/utils.ts +20 -1
- package/extensions/opl-webaccess/README.md +1 -0
- package/extensions/opl-webaccess/extract.ts +38 -5
- package/extensions/opl-webaccess/providers/gemini.ts +2 -2
- package/extensions/opl-webaccess/utils.ts +14 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# opl-pi-sht
|
|
2
2
|
|
|
3
|
+
[](https://github.com/linellazatin/opl-pi-sht)
|
|
4
|
+
[](https://github.com/linellazatin/opl-pi-sht)
|
|
5
|
+
[](https://www.npmjs.com/package/@openlines/opl-pi-sht)
|
|
6
|
+
[](https://www.npmjs.com/package/@openlines/opl-pi-sht)
|
|
7
|
+
[](./LICENSE)
|
|
8
|
+
|
|
3
9
|
**Cut token cost, run the agent safely, and drop your MCP servers.**
|
|
4
10
|
|
|
5
11
|
A portable collection of various Pi coding agent extensions. Repository directories and config files use `opl-`; established Pi-facing commands and tool names stay compatible.
|
|
@@ -11,8 +17,8 @@ A portable collection of various Pi coding agent extensions. Repository director
|
|
|
11
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.
|
|
12
18
|
|
|
13
19
|
```bash
|
|
14
|
-
pi install npm:@openlines/opl-pi-sht
|
|
15
|
-
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>
|
|
16
22
|
```
|
|
17
23
|
|
|
18
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>`.
|
|
@@ -70,7 +76,7 @@ Copy mode overwrites matching destinations. Link mode skips existing destination
|
|
|
70
76
|
| [`opl-questionnaire`](extensions/opl-questionnaire/README.md) | Interactive structured-choice tool. | `questionnaire`; no config. |
|
|
71
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`. |
|
|
72
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`. |
|
|
73
|
-
| [`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`. |
|
|
74
80
|
|
|
75
81
|
## What you'll gain
|
|
76
82
|
|
|
@@ -94,7 +100,7 @@ Cold prompt-cache write, measured /init session (opl-modes lazy tools + MCP adap
|
|
|
94
100
|
|
|
95
101
|
### Run the agent without babysitting it
|
|
96
102
|
|
|
97
|
-
- **`opl-modes`** chat and plan modes swap the active toolset for read-only lists and gate Bash to safe inspection patterns, with destructive-pattern checks that fire even inside otherwise-safe commands. The plan to execute lifecycle keeps exploration and mutation cleanly separated. Add custom modes (like below) for your workflow needs.
|
|
103
|
+
- **`opl-modes`** chat and plan modes swap the active toolset for read-only lists and gate Bash to safe inspection patterns, with destructive-pattern checks that fire even inside otherwise-safe commands (also `find -delete`, `git clean`, `truncate`, `sudo`, and quote-obfuscated `r"m"`; `env`/`printenv` no longer safe-listed). The plan to execute lifecycle keeps exploration and mutation cleanly separated. Add custom modes (like below) for your workflow needs.
|
|
98
104
|

|
|
99
105
|
- `load_tools` activation is bounded by the current mode, so a read-only mode cannot be tricked into enabling a write-capable tool.
|
|
100
106
|
|
|
@@ -103,8 +109,8 @@ Cold prompt-cache write, measured /init session (opl-modes lazy tools + MCP adap
|
|
|
103
109
|
|
|
104
110
|
- **`opl-init`** writes a fingerprinted `AGENTS.md` so any agent starts already oriented in a repo. It writes a baseline before asking the model to refine the guide, so smaller models cannot leave the update only in chat; it re-runs only when the tree actually changes.
|
|
105
111
|

|
|
106
|
-
- **`opl-browser`** gives full Chromium automation (navigate, snapshot, extract rendered-page markdown, interact, screenshot, console/network capture, evaluate) through a single tool, with handle+preview output for large results.
|
|
107
|
-
- **`opl-webaccess`** adds provider-backed search plus readable URL and PDF extraction, with session recovery of earlier results.
|
|
112
|
+
- **`opl-browser`** gives full Chromium automation (navigate, snapshot, extract rendered-page markdown, interact, screenshot, console/network capture, evaluate) through a single tool, with handle+preview output for large results — navigation is http(s)-only and screenshots stay in the project directory.
|
|
113
|
+
- **`opl-webaccess`** adds provider-backed search plus readable URL and PDF extraction, with session recovery of earlier results, an http(s)-only fetch, a 10 MB response cap, and a 30s timeout.
|
|
108
114
|
- **`opl-simplebench`** benchmarks models on deterministic closed-answer contracts, instruction-following, and tool-call generation so you pick a model on evidence, not vibes.
|
|
109
115
|

|
|
110
116
|
- **`opl-todo`** tracks branch-aware tasks that persist across a session and reconstruct from history.
|
|
@@ -114,7 +120,7 @@ Cold prompt-cache write, measured /init session (opl-modes lazy tools + MCP adap
|
|
|
114
120
|
|
|
115
121
|
### See what the agent is doing
|
|
116
122
|
|
|
117
|
-
- **`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.
|
|
118
124
|
- **`opl-input`** is a configurable editor that reflects the active mode's identity, so you always know which mode you are typing into.
|
|
119
125
|

|
|
120
126
|
|
|
@@ -180,7 +186,7 @@ Copy applicable files from [`configs/`](configs/) to `~/.pi/agent/configs/`. For
|
|
|
180
186
|
- `opl-init` and `opl-questionnaire` have no external configuration.
|
|
181
187
|
- Config files must be valid JSON, with no comments or trailing commas beyond deliberate `_comment` keys.
|
|
182
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.
|
|
183
|
-
- `opl-modes.bashPatterns` is the shared read-only Bash policy
|
|
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.
|
|
184
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.
|
|
185
191
|
|
|
186
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": {
|
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
"_comment_lazyTools": "Tool names withheld from the active set at rest and enabled on demand via the load_tools tool. Only affects modes that would otherwise include them (off/execute and custom modes that list them). Core built-ins, plan_complete, and load_tools are protected and ignored. Omit or leave empty to keep all tools always active.",
|
|
18
18
|
"lazyTools": [],
|
|
19
19
|
"bashPatterns": {
|
|
20
|
-
"_comment_safe": "Replace-only: providing safePatterns replaces
|
|
21
|
-
"_comment_destructive": "Replace-only: providing destructivePatterns replaces
|
|
20
|
+
"_comment_safe": "Replace-only: providing bashPatterns.safePatterns replaces the built-in safe list. This is the default safe allowlist for every mode; a mode overrides it with its own safePatterns or opts out of Bash gating with unrestrictedBash. Shown here with the built-in list.",
|
|
21
|
+
"_comment_destructive": "Replace-only: providing bashPatterns.destructivePatterns replaces the built-in destructive list. This is the default destructive blocklist for every mode; a mode overrides it with its own destructivePatterns or opts out of Bash gating with unrestrictedBash. Shown here with the built-in list.",
|
|
22
22
|
"safePatterns": [
|
|
23
23
|
"^\\s*cat\\b",
|
|
24
24
|
"^\\s*head\\b",
|
|
@@ -43,8 +43,6 @@
|
|
|
43
43
|
"^\\s*which\\b",
|
|
44
44
|
"^\\s*whereis\\b",
|
|
45
45
|
"^\\s*type\\b",
|
|
46
|
-
"^\\s*env\\b",
|
|
47
|
-
"^\\s*printenv\\b",
|
|
48
46
|
"^\\s*uname\\b",
|
|
49
47
|
"^\\s*whoami\\b",
|
|
50
48
|
"^\\s*date\\b",
|
|
@@ -74,12 +72,14 @@
|
|
|
74
72
|
"\\btee\\b",
|
|
75
73
|
"\\bdd\\b",
|
|
76
74
|
"\\bshred\\b",
|
|
75
|
+
"\\btruncate\\b",
|
|
76
|
+
"\\s-(delete|exec|execdir)\\b",
|
|
77
77
|
"(^|[^<])>(?!>|&)",
|
|
78
78
|
">>",
|
|
79
79
|
"\\bnpm\\s+(install|uninstall|update|ci)",
|
|
80
80
|
"\\byarn\\s+(add|remove|install)",
|
|
81
81
|
"\\bpip\\s+(install|uninstall)",
|
|
82
|
-
"\\bgit\\s+(add|commit|push|merge|rebase|reset|checkout|branch\\s
|
|
82
|
+
"\\bgit\\s+(add|commit|push|merge|rebase|reset|checkout|branch\\s+-|clean|update-ref|tag\\s+-|cherry-pick|revert|am|apply)",
|
|
83
83
|
"\\bsudo\\b",
|
|
84
84
|
"\\bsu\\b",
|
|
85
85
|
"\\bkill\\b",
|
|
@@ -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.",
|
|
@@ -47,6 +47,9 @@ Full action set: `navigate` (url, or `back`/`forward`/`reload`), `snapshot`,
|
|
|
47
47
|
a whole-document heuristic and mispicks inside small subtrees.
|
|
48
48
|
- **Real Chromium via Playwright.** Navigation with `domcontentloaded` waits,
|
|
49
49
|
CSS-selector interaction, viewport control, multi-page management.
|
|
50
|
+
- **Scheme + path guards.** `navigate`/`new_page` accept http(s) only; `file:`,
|
|
51
|
+
`data:`, and `javascript:` are rejected. Screenshot paths are confined to the
|
|
52
|
+
project directory, so a bad `path` cannot overwrite arbitrary files.
|
|
50
53
|
- **Per-page capture.** Console messages and network requests are buffered per
|
|
51
54
|
page as they occur; `console` and `network` actions return the active page's
|
|
52
55
|
buffer.
|
|
@@ -82,6 +85,8 @@ index.ts Pi wiring: registers the single `browser` tool, TTL result store,
|
|
|
82
85
|
preview/handle logic, and session_shutdown cleanup.
|
|
83
86
|
browser.ts Playwright driver: browser/context/page lifecycle, per-page console
|
|
84
87
|
and network buffers, and the action switch.
|
|
88
|
+
validate.ts URL/path guards: http/https-only navigation, screenshot path confined
|
|
89
|
+
to the project directory.
|
|
85
90
|
extract.ts Pure rendered-HTML → markdown pipeline (linkedom + Readability +
|
|
86
91
|
turndown), duplicated from opl-webaccess to keep installs independent.
|
|
87
92
|
config.ts DEFAULT_CONFIG + loadUserConfig (user overrides win via ??).
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { chromium, type Browser, type BrowserContext, type Page, type ConsoleMessage } from "playwright";
|
|
2
2
|
import { extractMarkdown } from "./extract.js";
|
|
3
|
+
import { assertHttpUrl, safeScreenshotPath } from "./validate.js";
|
|
3
4
|
import type { BrowserConfig } from "./config.js";
|
|
4
5
|
|
|
5
6
|
// ponytail: single module-level Chromium instance reused across tool calls.
|
|
@@ -80,7 +81,7 @@ export async function runAction(p: BrowserParams, cfg: BrowserConfig): Promise<B
|
|
|
80
81
|
if (url === "back") { await page().goBack(); }
|
|
81
82
|
else if (url === "forward") { await page().goForward(); }
|
|
82
83
|
else if (url === "reload") { await page().reload(); }
|
|
83
|
-
else if (url) { await page().goto(url, { waitUntil: "domcontentloaded" }); }
|
|
84
|
+
else if (url) { await page().goto(assertHttpUrl(url), { waitUntil: "domcontentloaded" }); }
|
|
84
85
|
else throw new Error("navigate requires url (or back|forward|reload)");
|
|
85
86
|
return { text: `${page().url()} — ${await page().title()}` };
|
|
86
87
|
}
|
|
@@ -97,7 +98,7 @@ export async function runAction(p: BrowserParams, cfg: BrowserConfig): Promise<B
|
|
|
97
98
|
return { text: markdown || "(empty extraction)" };
|
|
98
99
|
}
|
|
99
100
|
case "screenshot": {
|
|
100
|
-
const file = p.path ?? `opl-browser-${Date.now()}.png
|
|
101
|
+
const file = safeScreenshotPath(p.path ?? `opl-browser-${Date.now()}.png`);
|
|
101
102
|
await page().screenshot({ path: file, fullPage: p.fullPage ?? false });
|
|
102
103
|
return { text: `Screenshot saved to ${file}`, file };
|
|
103
104
|
}
|
|
@@ -154,7 +155,7 @@ export async function runAction(p: BrowserParams, cfg: BrowserConfig): Promise<B
|
|
|
154
155
|
const pg = await ctx.newPage();
|
|
155
156
|
track(pg, cfg);
|
|
156
157
|
activeIndex = ctx.pages().length - 1;
|
|
157
|
-
if (p.url) await pg.goto(p.url, { waitUntil: "domcontentloaded" });
|
|
158
|
+
if (p.url) await pg.goto(assertHttpUrl(p.url), { waitUntil: "domcontentloaded" });
|
|
158
159
|
return { text: `Opened page [${activeIndex}] ${pg.url()}` };
|
|
159
160
|
}
|
|
160
161
|
case "select_page": {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Pure URL/path guards for the browser tool. No Playwright import so tests load it directly.
|
|
2
|
+
|
|
3
|
+
import { resolve, sep } from "node:path";
|
|
4
|
+
|
|
5
|
+
/** Normalize a URL, allowing only http/https so file:/data:/javascript: URLs can't
|
|
6
|
+
* be navigated (file:// + evaluate = local file bytes returned into model context). */
|
|
7
|
+
export function assertHttpUrl(url: string): string {
|
|
8
|
+
let parsed: URL;
|
|
9
|
+
try {
|
|
10
|
+
parsed = new URL(url);
|
|
11
|
+
} catch {
|
|
12
|
+
throw new Error(`invalid URL: ${url}`);
|
|
13
|
+
}
|
|
14
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
15
|
+
throw new Error(`only http/https URLs are allowed, got ${parsed.protocol}//`);
|
|
16
|
+
}
|
|
17
|
+
return parsed.href;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Require a screenshot path to stay within the project directory (guard against
|
|
21
|
+
* overwriting absolute or parent-relative user files with PNG bytes). */
|
|
22
|
+
export function safeScreenshotPath(file: string, cwd: string = process.cwd()): string {
|
|
23
|
+
const root = resolve(cwd);
|
|
24
|
+
const abs = resolve(cwd, file);
|
|
25
|
+
if (abs !== root && !abs.startsWith(root + sep)) {
|
|
26
|
+
throw new Error(`screenshot path must stay within the project directory: ${file}`);
|
|
27
|
+
}
|
|
28
|
+
return file;
|
|
29
|
+
}
|
|
@@ -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
|
+
}
|