@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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # opl-pi-sht
2
2
 
3
+ [![gh stars](https://img.shields.io/github/stars/linellazatin/opl-pi-sht?logo=github&color=ffffe0)](https://github.com/linellazatin/opl-pi-sht)
4
+ [![gh release](https://img.shields.io/github/v/release/linellazatin/opl-pi-sht?display_name=release&logo=github&color=ffffe0)](https://github.com/linellazatin/opl-pi-sht)
5
+ [![npm version](https://img.shields.io/npm/v/%40openlines%2Fopl-pi-sht?logo=npm&color=cb3837)](https://www.npmjs.com/package/@openlines/opl-pi-sht)
6
+ [![npm downloads](https://img.shields.io/npm/dm/@openlines/opl-pi-sht?logo=npm&color=cb3837)](https://www.npmjs.com/package/@openlines/opl-pi-sht)
7
+ [![license](https://img.shields.io/npm/l/@openlines/opl-pi-sht)](./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@0.1.18
15
- pi install git:github.com/linellazatin/opl-pi-sht@v0.1.18
20
+ pi install npm:@openlines/opl-pi-sht@<version>
21
+ pi install git:github.com/linellazatin/opl-pi-sht@<version.tag>
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). ![pet](images/ss-input-pet.png) | 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). | No commands/tools;`opl-footer.json`. |
79
+ | [`opl-footer`](extensions/opl-footer/README.md) | Configurable multi-row status footer - highly-specialized, and enhanced [pikit footer](https://github.com/adrianapan/pikit). | `/configure-opl`; `opl-footer.json`. |
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
  ![custom mode sample](images/ss-mode-custom.png)
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
  ![init](images/ss-init.png)
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
  ![simplebench](images/ss-simplebench.png)
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
  ![input-footer](images/ss-input-footer.png)
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 for chat and plan. Use per-mode pattern fields only when those modes intentionally need different policies.
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": ["pi", "separator", "model", "separator", "path", "git"],
4
- "row1RightSegments": ["context_pct"],
2
+ "_comment": "Available segment IDs: pi, model, path, git, thinking, caveman, plan_mode, chat_mode, mode_switcher, separator, token_in, token_out, token_total, cache_read, cache_write, cost, context_pct, context_total, session_stats, perf_stats, status, text:<literal>",
3
+ "row1LeftSegments": [
4
+ "pi",
5
+ "separator",
6
+ "model",
7
+ "separator",
8
+ "path",
9
+ "git"
10
+ ],
11
+ "row1RightSegments": [
12
+ "context_pct"
13
+ ],
5
14
  "row2LeftSegments": [
6
15
  "thinking",
7
16
  "separator",
8
17
  "mode_switcher"
9
18
  ],
10
- "row2RightSegments": ["token_total", "separator", "cost"],
11
- "row3LeftSegments": ["session_stats"],
12
- "row3RightSegments": ["perf_stats"],
19
+ "row2RightSegments": [
20
+ "token_total",
21
+ "separator",
22
+ "cost"
23
+ ],
24
+ "row3LeftSegments": [
25
+ "session_stats"
26
+ ],
27
+ "row3RightSegments": [
28
+ "perf_stats",
29
+ "cache_read",
30
+ "cache_write"
31
+ ],
13
32
  "icons": {
14
- "pi": "\ue22c",
15
- "folder": "\uf115",
16
- "branch": "\uf126",
17
- "git": "\uf1d3",
33
+ "pi": "󰏿",
34
+ "folder": "",
35
+ "branch": "",
36
+ "git": "",
18
37
  "separator": "|"
19
38
  },
20
39
  "colors": {
@@ -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 ALL defaults for both built-in chat/plan modes (shared base). Omit to keep built-in safe patterns. Shown here with the built-in list.",
21
- "_comment_destructive": "Replace-only: providing destructivePatterns replaces ALL defaults for both built-in chat/plan modes (shared base). Omit to keep built-in destructive patterns. Shown here with the built-in list.",
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
- No commands, flags, or shortcuts. Nerd Font detection can be overridden with `FOOTER_NERD_FONTS=1` or `FOOTER_NERD_FONTS=0`.
7
+ `/configure-opl` provides six row/side tabs to toggle or reorder standard segments and their trailing separators, applying changes immediately. Press `r` for reorder view, then `,`/`.` to move the selected segment. The `status` segment shows `Working` during agent execution, `Waiting` while Pi tools run, and `Ready` when the agent settles. Colors, icons, literal text, and other options remain JSON-only. Nerd Font detection can be overridden with `FOOTER_NERD_FONTS=1` or `FOOTER_NERD_FONTS=0`.
8
8
 
9
9
  ## Extension features
10
10
 
@@ -33,7 +33,7 @@ The third row is populated after the first completed turn. Its session and perfo
33
33
  - **Token and cost tracking**: total, cache, input/output, and accumulated cost segments
34
34
  - **Session statistics**: prompt, API-call, and model tool-call counts
35
35
  - **Performance statistics**: cumulative LLM/tool duration, most recent user-prompt-to-completion turnaround time, average time to first token, output rate, and cache-hit percentage
36
- - **Thinking and mode indicators**: thinking-level colors plus caveman, plan, chat, or unified mode segments when available
36
+ - **Thinking, mode, and status indicators**: thinking-level colors plus caveman, plan, chat, unified mode, and `Working`/`Waiting`/`Ready` status segments
37
37
  - **Nerd Font support**: automatic detection with plain-icon fallbacks
38
38
  - **Live updates**: branch changes and session events request footer re-rendering
39
39
 
@@ -64,7 +64,7 @@ See the tracked [`configs/opl-footer.json.sample`](../../configs/opl-footer.json
64
64
 
65
65
  | Segment | Description | Notes |
66
66
  |---------|-------------|-------|
67
- | `pi` | π symbol in accent blue | — |
67
+ | `pi` | π symbol in accent blue | `pi` icon can be modified in config file |
68
68
  | `model` | Model name in pink + `(provider)` in dim | No icon; provider omitted if unavailable |
69
69
  | `path` | Current working directory | `segmentOptions.path.mode`: `"full"` (default) · `"abbreviated"` · `"basename"` |
70
70
  | `git` | Git branch and dirty indicators | `showBranch`, `showStaged`, `showUnstaged`, `showUntracked` (all bool) |
@@ -81,6 +81,9 @@ See the tracked [`configs/opl-footer.json.sample`](../../configs/opl-footer.json
81
81
  | `cache_read` | Cache read tokens (hidden if zero) | — |
82
82
  | `cache_write` | Cache write tokens (hidden if zero) | — |
83
83
  | `context_total` | Total context window size | — |
84
+ | `session_stats` | Prompt, API-call, and tool-call counts | Hidden until the first prompt |
85
+ | `perf_stats` | LLM/tool timing, TTFT, output rate, and cache-hit percentage | Hidden until the first prompt |
86
+ | `status` | `Working`, `Waiting`, or `Ready` | `accent`, `warning`, and `success` theme colors respectively; optional and hidden by default |
84
87
  | `separator` | `\|` divider | Coloured via `separator` in `colors` |
85
88
  | `text:...` | Literal text, e.g. `text:⚡` | — |
86
89
 
@@ -156,6 +159,8 @@ The `session_stats` segment shows prompt, API-call, and model tool-call counts f
156
159
 
157
160
  The `perf_stats` segment shows cumulative session LLM and tool time, average time to first token, output tokens/sec, and cache-hit percentage. The `LLM` figure is followed by the most recent user-prompt-to-completion turnaround time in parentheses, e.g. `LLM 18m 22s (2m 13s)`. This turnaround reflects only fully-settled turns (the `agent_settled` signal, after any retries or compaction), so it stays blank until the first turn completes. All `perf_stats` timing values are ephemeral — they reset each session and are not reconstructed from branch history.
158
161
 
162
+ The `status` segment is `Working` while the agent runs, `Waiting` while one or more Pi tools execute, and `Ready` only after `agent_settled`.
163
+
159
164
  ## Icons
160
165
 
161
166
  Nerd Font icons are auto-detected from your terminal. Ghostty, WezTerm, Kitty, iTerm2, Alacritty, Foot, Rio, and Contour are recognised automatically — everything else falls back to plain Unicode symbols. If detection gets it wrong (e.g. when running inside tmux), override it:
@@ -1,5 +1,5 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
3
  import type { FooterUserConfig, StatusLineSegmentId, ColorScheme, StatusLineSegmentOptions } from "./types.js";
4
4
  import { getDefaultColors } from "./theme.js";
5
5
  import type { IconSet } from "./icons.js";
@@ -12,6 +12,33 @@ const DEFAULT_ROW2_RIGHT: StatusLineSegmentId[] = ["token_total", "separator", "
12
12
  const DEFAULT_ROW3_LEFT: StatusLineSegmentId[] = ["session_stats"];
13
13
  const DEFAULT_ROW3_RIGHT: StatusLineSegmentId[] = ["perf_stats"];
14
14
 
15
+ export type FooterLayoutKey =
16
+ | "row1LeftSegments" | "row1RightSegments"
17
+ | "row2LeftSegments" | "row2RightSegments"
18
+ | "row3LeftSegments" | "row3RightSegments";
19
+
20
+ export const FOOTER_LAYOUT_KEYS: FooterLayoutKey[] = [
21
+ "row1LeftSegments", "row1RightSegments",
22
+ "row2LeftSegments", "row2RightSegments",
23
+ "row3LeftSegments", "row3RightSegments",
24
+ ];
25
+
26
+ export const CONFIGURABLE_SEGMENTS: StatusLineSegmentId[] = [
27
+ "pi", "model", "path", "git", "thinking", "caveman", "plan_mode",
28
+ "chat_mode", "mode_switcher", "token_in", "token_out", "token_total",
29
+ "cache_read", "cache_write", "cost", "context_pct", "context_total",
30
+ "session_stats", "perf_stats", "status",
31
+ ];
32
+
33
+ const DEFAULT_LAYOUTS: Record<FooterLayoutKey, StatusLineSegmentId[]> = {
34
+ row1LeftSegments: DEFAULT_ROW1_LEFT,
35
+ row1RightSegments: DEFAULT_ROW1_RIGHT,
36
+ row2LeftSegments: DEFAULT_ROW2_LEFT,
37
+ row2RightSegments: DEFAULT_ROW2_RIGHT,
38
+ row3LeftSegments: DEFAULT_ROW3_LEFT,
39
+ row3RightSegments: DEFAULT_ROW3_RIGHT,
40
+ };
41
+
15
42
  const DEFAULT_SEGMENT_OPTIONS: StatusLineSegmentOptions = {
16
43
  path: { mode: "full" },
17
44
  git: {
@@ -61,6 +88,102 @@ export function clearUserConfigCache(): void {
61
88
  userConfigCacheTime = 0;
62
89
  }
63
90
 
91
+ export function getLayoutSegments(config: FooterUserConfig, key: FooterLayoutKey): StatusLineSegmentId[] {
92
+ const segments = config[key];
93
+ return Array.isArray(segments) && segments.every((segment) => typeof segment === "string")
94
+ ? segments
95
+ : DEFAULT_LAYOUTS[key];
96
+ }
97
+
98
+ export function hasSegmentSeparator(
99
+ config: FooterUserConfig,
100
+ key: FooterLayoutKey,
101
+ segment: StatusLineSegmentId,
102
+ ): boolean {
103
+ const layout = getLayoutSegments(config, key);
104
+ const index = layout.indexOf(segment);
105
+ return index !== -1 && layout[index + 1] === "separator";
106
+ }
107
+
108
+ export function setLayoutSegment(
109
+ config: FooterUserConfig,
110
+ key: FooterLayoutKey,
111
+ segment: StatusLineSegmentId,
112
+ shown: boolean,
113
+ ): FooterUserConfig {
114
+ const current = getLayoutSegments(config, key);
115
+ const index = current.indexOf(segment);
116
+ if (shown && index !== -1) return config;
117
+
118
+ const next = [...current];
119
+ if (!shown) {
120
+ if (index === -1) return config;
121
+ next.splice(index, 1);
122
+ if (next[index] === "separator") next.splice(index, 1);
123
+ return { ...config, [key]: next };
124
+ }
125
+
126
+ const order = CONFIGURABLE_SEGMENTS.indexOf(segment);
127
+ const insertAt = next.findIndex((item) => CONFIGURABLE_SEGMENTS.indexOf(item) > order);
128
+ next.splice(insertAt === -1 ? next.length : insertAt, 0, segment);
129
+ return { ...config, [key]: next };
130
+ }
131
+
132
+ export function moveLayoutSegment(
133
+ config: FooterUserConfig,
134
+ key: FooterLayoutKey,
135
+ segment: StatusLineSegmentId,
136
+ direction: "up" | "down",
137
+ ): FooterUserConfig {
138
+ const current = getLayoutSegments(config, key);
139
+ const start = current.indexOf(segment);
140
+ if (start === -1) return config;
141
+
142
+ const end = start + (current[start + 1] === "separator" ? 2 : 1);
143
+ if (direction === "up") {
144
+ let previousStart = start - 1;
145
+ if (current[previousStart] === "separator") previousStart--;
146
+ if (previousStart < 0 || !CONFIGURABLE_SEGMENTS.includes(current[previousStart]!)) return config;
147
+ return {
148
+ ...config,
149
+ [key]: [...current.slice(0, previousStart), ...current.slice(start, end), ...current.slice(previousStart, start), ...current.slice(end)],
150
+ };
151
+ }
152
+
153
+ const nextStart = end;
154
+ if (!CONFIGURABLE_SEGMENTS.includes(current[nextStart]!)) return config;
155
+ const nextEnd = nextStart + (current[nextStart + 1] === "separator" ? 2 : 1);
156
+ return {
157
+ ...config,
158
+ [key]: [...current.slice(0, start), ...current.slice(nextStart, nextEnd), ...current.slice(start, end), ...current.slice(nextEnd)],
159
+ };
160
+ }
161
+
162
+ export function setSegmentSeparator(
163
+ config: FooterUserConfig,
164
+ key: FooterLayoutKey,
165
+ segment: StatusLineSegmentId,
166
+ shown: boolean,
167
+ ): FooterUserConfig {
168
+ const current = getLayoutSegments(config, key);
169
+ const index = current.indexOf(segment);
170
+ if (index === -1) return config;
171
+ const hasSeparator = current[index + 1] === "separator";
172
+ if (shown === hasSeparator) return config;
173
+
174
+ const next = [...current];
175
+ if (shown) next.splice(index + 1, 0, "separator");
176
+ else next.splice(index + 1, 1);
177
+ return { ...config, [key]: next };
178
+ }
179
+
180
+ export function saveUserConfig(config: FooterUserConfig): void {
181
+ const configPath = getConfigPath();
182
+ mkdirSync(dirname(configPath), { recursive: true });
183
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
184
+ clearUserConfigCache();
185
+ }
186
+
64
187
  export function getEffectiveConfig(): {
65
188
  row1LeftSegments: StatusLineSegmentId[];
66
189
  row1RightSegments: StatusLineSegmentId[];
@@ -75,12 +198,12 @@ export function getEffectiveConfig(): {
75
198
  const userConfig = loadUserConfig();
76
199
 
77
200
  return {
78
- row1LeftSegments: userConfig?.row1LeftSegments ?? DEFAULT_ROW1_LEFT,
79
- row1RightSegments: userConfig?.row1RightSegments ?? DEFAULT_ROW1_RIGHT,
80
- row2LeftSegments: userConfig?.row2LeftSegments ?? DEFAULT_ROW2_LEFT,
81
- row2RightSegments: userConfig?.row2RightSegments ?? DEFAULT_ROW2_RIGHT,
82
- row3LeftSegments: userConfig?.row3LeftSegments ?? DEFAULT_ROW3_LEFT,
83
- row3RightSegments: userConfig?.row3RightSegments ?? DEFAULT_ROW3_RIGHT,
201
+ row1LeftSegments: getLayoutSegments(userConfig ?? {}, "row1LeftSegments"),
202
+ row1RightSegments: getLayoutSegments(userConfig ?? {}, "row1RightSegments"),
203
+ row2LeftSegments: getLayoutSegments(userConfig ?? {}, "row2LeftSegments"),
204
+ row2RightSegments: getLayoutSegments(userConfig ?? {}, "row2RightSegments"),
205
+ row3LeftSegments: getLayoutSegments(userConfig ?? {}, "row3LeftSegments"),
206
+ row3RightSegments: getLayoutSegments(userConfig ?? {}, "row3RightSegments"),
84
207
  colors: userConfig?.colors ?? getDefaultColors(),
85
208
  segmentOptions: {
86
209
  ...DEFAULT_SEGMENT_OPTIONS,
@@ -0,0 +1,13 @@
1
+ export function nextTabIndex(activeTab: number, direction: "left" | "right", count: number): number {
2
+ return direction === "left"
3
+ ? (activeTab + count - 1) % count
4
+ : (activeTab + 1) % count;
5
+ }
6
+
7
+ export function restoreSelectedItem(
8
+ lists: Array<{ selectItem(id: string): void }>,
9
+ activeTab: number,
10
+ id: string,
11
+ ): void {
12
+ lists[activeTab]?.selectItem(id);
13
+ }
@@ -0,0 +1,157 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
3
+ import { Key, matchesKey, SettingsList, truncateToWidth, type SettingItem } from "@earendil-works/pi-tui";
4
+
5
+ import {
6
+ CONFIGURABLE_SEGMENTS,
7
+ FOOTER_LAYOUT_KEYS,
8
+ getLayoutSegments,
9
+ hasSegmentSeparator,
10
+ loadUserConfig,
11
+ moveLayoutSegment,
12
+ saveUserConfig,
13
+ setLayoutSegment,
14
+ setSegmentSeparator,
15
+ type FooterLayoutKey,
16
+ } from "./config.js";
17
+ import { nextTabIndex, restoreSelectedItem } from "./configure-navigation.js";
18
+
19
+ const LAYOUT_LABELS: Record<FooterLayoutKey, string> = {
20
+ row1LeftSegments: "Row 1 left",
21
+ row1RightSegments: "Row 1 right",
22
+ row2LeftSegments: "Row 2 left",
23
+ row2RightSegments: "Row 2 right",
24
+ row3LeftSegments: "Row 3 left",
25
+ row3RightSegments: "Row 3 right",
26
+ };
27
+
28
+ function segmentLabel(segment: string): string {
29
+ return segment.replaceAll("_", " ");
30
+ }
31
+
32
+ export async function showFooterConfigurator(ctx: ExtensionContext, onSaved: () => void): Promise<void> {
33
+ if (ctx.mode !== "tui") {
34
+ ctx.ui.notify("/configure-opl requires TUI mode", "error");
35
+ return;
36
+ }
37
+
38
+ let config = loadUserConfig() ?? {};
39
+ await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
40
+ let activeTab = 0;
41
+ let view: "settings" | "reorder" = "settings";
42
+ let reorderIndex = 0;
43
+ let settingsLists: SettingsList[] = [];
44
+
45
+ const persist = (next: typeof config, selectedId?: string): boolean => {
46
+ try {
47
+ saveUserConfig(next);
48
+ config = next;
49
+ settingsLists = createSettingsLists();
50
+ if (selectedId) restoreSelectedItem(settingsLists, activeTab, selectedId);
51
+ onSaved();
52
+ return true;
53
+ } catch (error) {
54
+ const message = error instanceof Error ? error.message : String(error);
55
+ ctx.ui.notify(`Could not save footer config: ${message}`, "error");
56
+ done(undefined);
57
+ return false;
58
+ }
59
+ };
60
+
61
+ const save = (id: string, value: string) => {
62
+ const [key, segment, kind] = id.split(":") as [FooterLayoutKey, typeof CONFIGURABLE_SEGMENTS[number], string];
63
+ const next = kind === "separator"
64
+ ? setSegmentSeparator(config, key, segment, value === "shown")
65
+ : setLayoutSegment(config, key, segment, value === "shown");
66
+ persist(next, id);
67
+ };
68
+
69
+ const createSettingsLists = (): SettingsList[] => FOOTER_LAYOUT_KEYS.map((key) => {
70
+ const layout = getLayoutSegments(config, key);
71
+ const items: SettingItem[] = CONFIGURABLE_SEGMENTS.flatMap((segment) => {
72
+ const index = layout.indexOf(segment);
73
+ return [
74
+ {
75
+ id: `${key}:${segment}:segment`,
76
+ label: segmentLabel(segment),
77
+ currentValue: index === -1 ? "hidden" : "shown",
78
+ values: ["shown", "hidden"],
79
+ },
80
+ {
81
+ id: `${key}:${segment}:separator`,
82
+ label: `${segmentLabel(segment)} separator`,
83
+ currentValue: hasSegmentSeparator(config, key, segment) ? "shown" : "hidden",
84
+ values: ["shown", "hidden"],
85
+ },
86
+ ];
87
+ });
88
+ return new SettingsList(items, 15, getSettingsListTheme(), save, () => done(undefined));
89
+ });
90
+
91
+ const visibleSegments = (): typeof CONFIGURABLE_SEGMENTS[number][] =>
92
+ getLayoutSegments(config, FOOTER_LAYOUT_KEYS[activeTab]!).filter((segment) => CONFIGURABLE_SEGMENTS.includes(segment));
93
+
94
+ settingsLists = createSettingsLists();
95
+
96
+ return {
97
+ render(width: number) {
98
+ const tabs = FOOTER_LAYOUT_KEYS.map((key, index) =>
99
+ theme.fg(index === activeTab ? "accent" : "dim", `[${LAYOUT_LABELS[key]}]`),
100
+ ).join(" ");
101
+ const header = [
102
+ truncateToWidth(theme.bold("Configure OPL Footer"), width),
103
+ truncateToWidth(tabs, width),
104
+ ];
105
+ if (view === "settings") {
106
+ return [
107
+ ...header,
108
+ truncateToWidth(theme.fg("dim", "←/→ switch layout · r reorder · Changes apply immediately"), width),
109
+ ...settingsLists[activeTab]!.render(width),
110
+ ];
111
+ }
112
+
113
+ const segments = visibleSegments();
114
+ reorderIndex = Math.min(reorderIndex, Math.max(0, segments.length - 1));
115
+ const rows = segments.length === 0
116
+ ? [theme.fg("dim", "No visible standard segments.")]
117
+ : segments.map((segment, index) => {
118
+ const text = `${index === reorderIndex ? "›" : " "} ${segmentLabel(segment)}${hasSegmentSeparator(config, FOOTER_LAYOUT_KEYS[activeTab]!, segment) ? " + separator" : ""}`;
119
+ return index === reorderIndex ? theme.fg("accent", text) : text;
120
+ });
121
+ return [
122
+ ...header,
123
+ truncateToWidth(theme.fg("dim", "↑/↓ select · ,/. move · r settings · esc close"), width),
124
+ ...rows.map((row) => truncateToWidth(row, width)),
125
+ ];
126
+ },
127
+ invalidate() { settingsLists.forEach((list) => list.invalidate()); },
128
+ handleInput(data: string) {
129
+ if (matchesKey(data, Key.left)) {
130
+ activeTab = nextTabIndex(activeTab, "left", FOOTER_LAYOUT_KEYS.length);
131
+ reorderIndex = 0;
132
+ } else if (matchesKey(data, Key.right)) {
133
+ activeTab = nextTabIndex(activeTab, "right", FOOTER_LAYOUT_KEYS.length);
134
+ reorderIndex = 0;
135
+ } else if (view === "reorder") {
136
+ const segments = visibleSegments();
137
+ if (data === "r") view = "settings";
138
+ else if (matchesKey(data, Key.escape)) done(undefined);
139
+ else if (matchesKey(data, Key.up) && segments.length > 0) reorderIndex = (reorderIndex + segments.length - 1) % segments.length;
140
+ else if (matchesKey(data, Key.down) && segments.length > 0) reorderIndex = (reorderIndex + 1) % segments.length;
141
+ else if ((data === "," || data === ".") && segments[reorderIndex]) {
142
+ const next = moveLayoutSegment(config, FOOTER_LAYOUT_KEYS[activeTab]!, segments[reorderIndex]!, data === "," ? "up" : "down");
143
+ if (next !== config && persist(next)) {
144
+ reorderIndex = data === "," ? Math.max(0, reorderIndex - 1) : Math.min(segments.length - 1, reorderIndex + 1);
145
+ }
146
+ }
147
+ } else if (data === "r") {
148
+ view = "reorder";
149
+ reorderIndex = 0;
150
+ } else {
151
+ settingsLists[activeTab]!.handleInput?.(data);
152
+ }
153
+ tui.requestRender();
154
+ },
155
+ };
156
+ });
157
+ }