@openlines/opl-pi-sht 0.1.14
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/LICENSE +21 -0
- package/README.md +191 -0
- package/configs/opl-browser.json.sample +8 -0
- package/configs/opl-footer.json +48 -0
- package/configs/opl-footer.json.sample +48 -0
- package/configs/opl-input.json +22 -0
- package/configs/opl-input.json.sample +22 -0
- package/configs/opl-modes.json +291 -0
- package/configs/opl-modes.json.sample +285 -0
- package/configs/opl-simplebench.json +8 -0
- package/configs/opl-simplebench.json.sample +8 -0
- package/configs/opl-todo.json +12 -0
- package/configs/opl-todo.json.sample +12 -0
- package/configs/opl-webaccess.json +31 -0
- package/configs/opl-webaccess.json.sample +31 -0
- package/extensions/opl-browser/README.md +83 -0
- package/extensions/opl-browser/browser.ts +185 -0
- package/extensions/opl-browser/config.ts +37 -0
- package/extensions/opl-browser/index.ts +89 -0
- package/extensions/opl-browser/package-lock.json +60 -0
- package/extensions/opl-browser/package.json +10 -0
- package/extensions/opl-ctxtrim/README.md +78 -0
- package/extensions/opl-ctxtrim/index.ts +151 -0
- package/extensions/opl-footer/README.md +193 -0
- package/extensions/opl-footer/config.ts +91 -0
- package/extensions/opl-footer/git-status.ts +170 -0
- package/extensions/opl-footer/icons.ts +49 -0
- package/extensions/opl-footer/index.ts +368 -0
- package/extensions/opl-footer/segments/caveman.ts +25 -0
- package/extensions/opl-footer/segments/chat-mode.ts +24 -0
- package/extensions/opl-footer/segments/context.ts +92 -0
- package/extensions/opl-footer/segments/cost.ts +25 -0
- package/extensions/opl-footer/segments/git.ts +47 -0
- package/extensions/opl-footer/segments/helpers.ts +18 -0
- package/extensions/opl-footer/segments/index.ts +51 -0
- package/extensions/opl-footer/segments/mode-switcher.ts +24 -0
- package/extensions/opl-footer/segments/model.ts +22 -0
- package/extensions/opl-footer/segments/path.ts +34 -0
- package/extensions/opl-footer/segments/pi.ts +11 -0
- package/extensions/opl-footer/segments/plan-mode.ts +24 -0
- package/extensions/opl-footer/segments/separator.ts +9 -0
- package/extensions/opl-footer/segments/session-stats.ts +82 -0
- package/extensions/opl-footer/segments/thinking.ts +51 -0
- package/extensions/opl-footer/segments/tokens.ts +66 -0
- package/extensions/opl-footer/theme.ts +113 -0
- package/extensions/opl-footer/types.ts +172 -0
- package/extensions/opl-init/README.md +23 -0
- package/extensions/opl-init/index.ts +451 -0
- package/extensions/opl-input/README.md +83 -0
- package/extensions/opl-input/config.ts +137 -0
- package/extensions/opl-input/index.ts +284 -0
- package/extensions/opl-input/mode-style.ts +38 -0
- package/extensions/opl-input/utils.ts +378 -0
- package/extensions/opl-modes/README.md +132 -0
- package/extensions/opl-modes/config.ts +473 -0
- package/extensions/opl-modes/index.ts +1081 -0
- package/extensions/opl-modes/menus.ts +60 -0
- package/extensions/opl-modes/state.ts +152 -0
- package/extensions/opl-modes/types.ts +164 -0
- package/extensions/opl-modes/utils.ts +138 -0
- package/extensions/opl-questionnaire/README.md +26 -0
- package/extensions/opl-questionnaire/index.ts +445 -0
- package/extensions/opl-questionnaire/types.ts +55 -0
- package/extensions/opl-simplebench/README.md +242 -0
- package/extensions/opl-simplebench/artifact.ts +41 -0
- package/extensions/opl-simplebench/benchmark.ts +873 -0
- package/extensions/opl-simplebench/coding.ts +282 -0
- package/extensions/opl-simplebench/index.ts +203 -0
- package/extensions/opl-simplebench/llama-server.ts +290 -0
- package/extensions/opl-simplebench/metrics.ts +73 -0
- package/extensions/opl-simplebench/report.ts +46 -0
- package/extensions/opl-simplebench/research.ts +200 -0
- package/extensions/opl-simplebench/scoring.ts +48 -0
- package/extensions/opl-simplebench/tests.ts +39 -0
- package/extensions/opl-simplebench/types.ts +6 -0
- package/extensions/opl-simplebench/util/config.ts +170 -0
- package/extensions/opl-simplebench/util/debug.ts +32 -0
- package/extensions/opl-simplebench/util/format.ts +402 -0
- package/extensions/opl-simplebench/util/providers.ts +841 -0
- package/extensions/opl-todo/README.md +43 -0
- package/extensions/opl-todo/config.ts +71 -0
- package/extensions/opl-todo/index.ts +415 -0
- package/extensions/opl-webaccess/README.md +47 -0
- package/extensions/opl-webaccess/config.ts +55 -0
- package/extensions/opl-webaccess/extract.ts +83 -0
- package/extensions/opl-webaccess/index.ts +232 -0
- package/extensions/opl-webaccess/package-lock.json +345 -0
- package/extensions/opl-webaccess/package.json +18 -0
- package/extensions/opl-webaccess/pdf.ts +8 -0
- package/extensions/opl-webaccess/providers/ddgs.ts +72 -0
- package/extensions/opl-webaccess/providers/exa.ts +74 -0
- package/extensions/opl-webaccess/providers/gemini.ts +60 -0
- package/extensions/opl-webaccess/providers/searxng.ts +86 -0
- package/extensions/opl-webaccess/providers/tavily.ts +60 -0
- package/extensions/opl-webaccess/search.ts +36 -0
- package/extensions/opl-webaccess/storage.ts +51 -0
- package/extensions/opl-webaccess/turndown.d.ts +1 -0
- package/extensions/opl-webaccess/types.ts +26 -0
- package/extensions/opl-webaccess/utils.ts +28 -0
- package/package.json +55 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
// opl-ctxtrim trims the verbose descriptions that the third-party context-mode
|
|
4
|
+
// MCP bridge attaches to its ctx_* tools. It edits only the serialized provider
|
|
5
|
+
// payload in the documented `before_provider_request` hook, so the installed
|
|
6
|
+
// context-mode package (and its tool execution) is never modified. Tool names,
|
|
7
|
+
// schema structure, required fields, enums, defaults, bounds, and strict flags
|
|
8
|
+
// are preserved; only human-readable `description` prose is shortened.
|
|
9
|
+
|
|
10
|
+
// Concise top-level descriptions for the eleven ctx_* tools shipped by
|
|
11
|
+
// context-mode v1.0.169. Any ctx_* tool NOT listed here is left unchanged so a
|
|
12
|
+
// future context-mode release cannot have its semantics silently rewritten.
|
|
13
|
+
export const CTX_DESCRIPTIONS: Record<string, string> = {
|
|
14
|
+
ctx_execute:
|
|
15
|
+
"Run code in a sandbox; only what you print (console.log/print/echo) enters context. Use to derive answers from large data (filter, parse, aggregate, transform) without loading raw bytes. Pass background:true to keep a server/daemon alive past the timeout; pass intent to auto-index large output for ctx_search.",
|
|
16
|
+
ctx_execute_file:
|
|
17
|
+
"Run code over one file loaded as FILE_CONTENT in a sandbox; only printed output enters context. Use to analyze a large file (line counts, pattern matches, structure, aggregates) without reading raw bytes. Use the Read tool instead when you intend to edit the file.",
|
|
18
|
+
ctx_index:
|
|
19
|
+
"Store text/markdown, a file, or a directory into the searchable FTS5 knowledge base for later ctx_search. Prefer `path` over `content` so bytes never pass through context. Not for logs, test output, or single-use data.",
|
|
20
|
+
ctx_search:
|
|
21
|
+
"Search the indexed knowledge base and captured session memory (BM25 + trigram, typo-corrected, ranked). Batch every question into one `queries` array; scope with `source`; use sort:\"timeline\" for chronological recall and contentType to filter code vs prose. Content must be indexed first.",
|
|
22
|
+
ctx_fetch_and_index:
|
|
23
|
+
"Fetch URL(s) (HTML->markdown, JSON, or text), index them, and return a small preview; raw page bytes never enter context. Use `requests` with concurrency 2-8 for batches; `force`/`ttl` control the 24h cache. Retrieve content with ctx_search.",
|
|
24
|
+
ctx_batch_execute:
|
|
25
|
+
"Run multiple shell commands in one call, auto-index their output, and return the sections matching `queries` in the same round trip. Use concurrency 2-8 for I/O-bound commands; keep concurrency 1 for CPU-bound or stateful commands. Put all questions in `queries`.",
|
|
26
|
+
ctx_stats:
|
|
27
|
+
"Show this session's context-consumption statistics: bytes returned, per-tool breakdown, estimated tokens, and savings ratio. Read-only.",
|
|
28
|
+
ctx_doctor:
|
|
29
|
+
"Diagnose the context-mode install; returns [OK]/[WARN]/[FAIL] checks for runtimes, storage, server, FTS5/SQLite, and hooks.",
|
|
30
|
+
ctx_upgrade:
|
|
31
|
+
"Return the shell command that upgrades context-mode. Run it with your shell tool, show the result as a checklist, and tell the user to restart the session.",
|
|
32
|
+
ctx_purge:
|
|
33
|
+
"DESTRUCTIVE and irreversible. Requires confirm:true and exactly one scope: `sessionId` wipes a single session; scope:\"project\" wipes the whole project (FTS5 + every session + stats). Never combine `sessionId` with scope:\"project\".",
|
|
34
|
+
ctx_insight:
|
|
35
|
+
"Open the context-mode Insight dashboard in the browser. Not a query engine; use ctx_search for indexed content.",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Shorten a nested JSON Schema parameter description to its first sentence or
|
|
39
|
+
// line. Only `description` text is touched, never validation keywords.
|
|
40
|
+
export function shortenParamDescription(text: string): string {
|
|
41
|
+
const firstLine = text.split("\n", 1)[0].trim();
|
|
42
|
+
const sentenceEnd = firstLine.search(/\.\s|\.$/);
|
|
43
|
+
const clipped = sentenceEnd >= 0 ? firstLine.slice(0, sentenceEnd + 1) : firstLine;
|
|
44
|
+
return clipped.length > 160 ? `${clipped.slice(0, 157).trimEnd()}...` : clipped;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Recursively shorten every `description` string inside a parameters schema.
|
|
48
|
+
function shortenSchemaDescriptions(node: unknown): void {
|
|
49
|
+
if (Array.isArray(node)) {
|
|
50
|
+
for (const item of node) shortenSchemaDescriptions(item);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (!node || typeof node !== "object") return;
|
|
54
|
+
const record = node as Record<string, unknown>;
|
|
55
|
+
for (const [key, value] of Object.entries(record)) {
|
|
56
|
+
if (key === "description" && typeof value === "string") {
|
|
57
|
+
record[key] = shortenParamDescription(value);
|
|
58
|
+
} else if (value && typeof value === "object") {
|
|
59
|
+
shortenSchemaDescriptions(value);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// A tool entry across the three supported provider shapes exposes a name, a
|
|
65
|
+
// holder object carrying `description`, and (optionally) a parameters schema.
|
|
66
|
+
interface ToolView {
|
|
67
|
+
name: string;
|
|
68
|
+
descriptionHolder: Record<string, unknown>;
|
|
69
|
+
parameters?: unknown;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Resolve a single serialized tool object to a common view, or null when the
|
|
73
|
+
// shape is unrecognized (fail open: leave it untouched).
|
|
74
|
+
function viewTool(tool: unknown): ToolView | null {
|
|
75
|
+
if (!tool || typeof tool !== "object") return null;
|
|
76
|
+
const record = tool as Record<string, unknown>;
|
|
77
|
+
|
|
78
|
+
// OpenAI Chat Completions / Mistral: { type:"function", function:{ name, description, parameters } }
|
|
79
|
+
if (record.function && typeof record.function === "object") {
|
|
80
|
+
const fn = record.function as Record<string, unknown>;
|
|
81
|
+
if (typeof fn.name === "string") {
|
|
82
|
+
return { name: fn.name, descriptionHolder: fn, parameters: fn.parameters };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Bedrock Converse: { toolSpec:{ name, description, inputSchema:{ json } } }
|
|
87
|
+
if (record.toolSpec && typeof record.toolSpec === "object") {
|
|
88
|
+
const spec = record.toolSpec as Record<string, unknown>;
|
|
89
|
+
if (typeof spec.name === "string") {
|
|
90
|
+
const inputSchema = spec.inputSchema as Record<string, unknown> | undefined;
|
|
91
|
+
return { name: spec.name, descriptionHolder: spec, parameters: inputSchema?.json };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// OpenAI Responses / Anthropic / Google: { name, description, parameters|input_schema }
|
|
96
|
+
if (typeof record.name === "string") {
|
|
97
|
+
return {
|
|
98
|
+
name: record.name,
|
|
99
|
+
descriptionHolder: record,
|
|
100
|
+
parameters: record.parameters ?? record.input_schema,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Trim a single tool view in place when it is a known ctx_* tool.
|
|
108
|
+
function trimToolView(view: ToolView): boolean {
|
|
109
|
+
const concise = CTX_DESCRIPTIONS[view.name];
|
|
110
|
+
if (!concise) return false;
|
|
111
|
+
if (typeof view.descriptionHolder.description === "string") {
|
|
112
|
+
view.descriptionHolder.description = concise;
|
|
113
|
+
}
|
|
114
|
+
if (view.parameters && typeof view.parameters === "object") {
|
|
115
|
+
shortenSchemaDescriptions(view.parameters);
|
|
116
|
+
}
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Locate the tool arrays inside a provider payload across supported shapes.
|
|
121
|
+
function toolArrays(payload: Record<string, unknown>): unknown[][] {
|
|
122
|
+
const arrays: unknown[][] = [];
|
|
123
|
+
if (Array.isArray(payload.tools)) arrays.push(payload.tools);
|
|
124
|
+
const toolConfig = payload.toolConfig as Record<string, unknown> | undefined;
|
|
125
|
+
if (toolConfig && Array.isArray(toolConfig.tools)) arrays.push(toolConfig.tools);
|
|
126
|
+
return arrays;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Return a trimmed copy of a provider payload, or the original when nothing
|
|
130
|
+
// matched. Never mutates the input.
|
|
131
|
+
export function trimPayload<T>(payload: T): T {
|
|
132
|
+
if (!payload || typeof payload !== "object") return payload;
|
|
133
|
+
const clone = structuredClone(payload) as Record<string, unknown>;
|
|
134
|
+
const arrays = toolArrays(clone);
|
|
135
|
+
if (arrays.length === 0) return payload;
|
|
136
|
+
let changed = false;
|
|
137
|
+
for (const tools of arrays) {
|
|
138
|
+
for (const tool of tools) {
|
|
139
|
+
const view = viewTool(tool);
|
|
140
|
+
if (view && trimToolView(view)) changed = true;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return changed ? (clone as T) : payload;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export default function (pi: ExtensionAPI) {
|
|
147
|
+
pi.on("before_provider_request", (event) => {
|
|
148
|
+
const trimmed = trimPayload(event.payload);
|
|
149
|
+
if (trimmed !== event.payload) return trimmed;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# opl-footer
|
|
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.
|
|
4
|
+
|
|
5
|
+
## Commands, flags, and shortcuts
|
|
6
|
+
|
|
7
|
+
No commands, flags, or shortcuts. Nerd Font detection can be overridden with `FOOTER_NERD_FONTS=1` or `FOOTER_NERD_FONTS=0`.
|
|
8
|
+
|
|
9
|
+
## Extension features
|
|
10
|
+
|
|
11
|
+
### Layout
|
|
12
|
+
|
|
13
|
+
The default footer has three content rows separated by horizontal dividers:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
Row 1 left: π | <model name> (<provider>) | <folder> <path> <branch> <dirty>
|
|
17
|
+
Row 1 right: <context bar> <pct%> / <max tokens>
|
|
18
|
+
|
|
19
|
+
Row 2 left: Thinking: <LEVEL> | <active mode>
|
|
20
|
+
Row 2 right: T: <total> (<cached> cached) ↑ <in> ↓ <out> | $<cost>
|
|
21
|
+
|
|
22
|
+
Row 3 left: <turns> turns · <steps> steps · <model requests> mreq · <tool calls> mtool
|
|
23
|
+
Row 3 right: LLM <time> (<last-turn TAT>) · Tool <time> | TTFT <time> · <tokens>/s | Cache <percent>%
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The third row is populated after the first completed turn. Its session and performance values are reconstructed from the current session branch where possible; timing values are process-local.
|
|
27
|
+
|
|
28
|
+
## Features
|
|
29
|
+
|
|
30
|
+
- **Three-row layout**: independent left and right segment lists for model/context, mode/usage, and session/performance data
|
|
31
|
+
- **Context bar**: configurable gradient bar with percentage and context-window size
|
|
32
|
+
- **Git integration**: branch plus staged, unstaged, and untracked counts, with invalidation after relevant file and Git commands
|
|
33
|
+
- **Token and cost tracking**: total, cache, input/output, and accumulated cost segments
|
|
34
|
+
- **Session statistics**: turns, tool steps, model requests, and model tool calls
|
|
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
|
|
37
|
+
- **Nerd Font support**: automatic detection with plain-icon fallbacks
|
|
38
|
+
- **Live updates**: branch changes and session events request footer re-rendering
|
|
39
|
+
|
|
40
|
+
## Configuration
|
|
41
|
+
|
|
42
|
+
Create `~/.pi/agent/configs/opl-footer.json` or copy the tracked example from [`configs/opl-footer.json.sample`](../../configs/opl-footer.json.sample). Configure all six row-side arrays (`row1LeftSegments` through `row3RightSegments`) to change the layout. Colors, icons, path/Git display, and the context bar are also configurable. Unknown or invalid JSON uses the extension defaults.
|
|
43
|
+
|
|
44
|
+
The config is cached for five seconds. Changes normally appear automatically; use `/reload` or restart Pi if needed.
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"row1LeftSegments": ["pi", "separator", "model", "separator", "path", "git"],
|
|
49
|
+
"row1RightSegments": ["context_pct"],
|
|
50
|
+
"row2LeftSegments": ["thinking", "separator", "mode_switcher"],
|
|
51
|
+
"row2RightSegments": ["token_total", "separator", "cost"],
|
|
52
|
+
"row3LeftSegments": ["session_stats"],
|
|
53
|
+
"row3RightSegments": ["perf_stats"]
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
See the tracked [`configs/opl-footer.json.sample`](../../configs/opl-footer.json.sample) for a complete example. Segment IDs, color fields, context-bar options, thinking-level colors, and icon overrides are documented below. Colors accept Pi theme tokens or hex strings.
|
|
58
|
+
|
|
59
|
+
## Architecture
|
|
60
|
+
|
|
61
|
+
`index.ts` installs the footer and lifecycle tracking. `segments/` renders configurable values; `theme.ts`, `types.ts`, and `config.ts` provide styling and JSON configuration; session and performance statistics are collected in process memory and reconstructed from session history where possible.
|
|
62
|
+
|
|
63
|
+
## Available Segments
|
|
64
|
+
|
|
65
|
+
| Segment | Description | Notes |
|
|
66
|
+
|---------|-------------|-------|
|
|
67
|
+
| `pi` | π symbol in accent blue | — |
|
|
68
|
+
| `model` | Model name in pink + `(provider)` in dim | No icon; provider omitted if unavailable |
|
|
69
|
+
| `path` | Current working directory | `segmentOptions.path.mode`: `"full"` (default) · `"abbreviated"` · `"basename"` |
|
|
70
|
+
| `git` | Git branch and dirty indicators | `showBranch`, `showStaged`, `showUnstaged`, `showUntracked` (all bool) |
|
|
71
|
+
| `context_pct` | Gradient bar + `X.X%` + max tokens | Bar fully configurable via `segmentOptions.contextBar` (see below). % and max tokens use `contextLabel` colour. Max tokens formatted with K/M suffix (e.g. `128k`, `2M`). Set `DEBUG_PCT` in `context.ts` to a number (0–100) to pin the bar at a fixed value for visual testing. |
|
|
72
|
+
| `cost` | `$<amount>` | `$` dim, amount in `cost` colour (`muted` by default). Shows dim `(no pricing)` when the session total is zero on a non-local model (provider has no pricing configured), and `(local model)` for local models |
|
|
73
|
+
| `thinking` | `Thinking: <LEVEL>` | Dim label, CAPS level with per-level colour; always visible |
|
|
74
|
+
| `mode_switcher` | Unified active mode label | Reads state published by `opl-modes`; `appearance.modeColor` controls the mode value, with hardcoded `muted` fallback |
|
|
75
|
+
| `caveman` | `Caveman mode: <MODE>` | Hidden when caveman extension not loaded |
|
|
76
|
+
| `plan_mode` | `Plan mode: <MODE>` | Hidden when plan-mode extension not loaded |
|
|
77
|
+
| `chat_mode` | `Chat mode: <MODE>` | Hidden when chat-mode extension not loaded |
|
|
78
|
+
| `token_total` | `T: <total> (<cached> cached) ↑ <in> ↓ <out>` | Labels dim, numbers in `tokens` colour (`muted` by default) |
|
|
79
|
+
| `token_in` | Input tokens | Available for custom layouts |
|
|
80
|
+
| `token_out` | Output tokens | Available for custom layouts |
|
|
81
|
+
| `cache_read` | Cache read tokens (hidden if zero) | — |
|
|
82
|
+
| `cache_write` | Cache write tokens (hidden if zero) | — |
|
|
83
|
+
| `context_total` | Total context window size | — |
|
|
84
|
+
| `separator` | `\|` divider | Coloured via `separator` in `colors` |
|
|
85
|
+
| `text:...` | Literal text, e.g. `text:⚡` | — |
|
|
86
|
+
|
|
87
|
+
## Context Bar
|
|
88
|
+
|
|
89
|
+
The `context_pct` segment's bar is fully configurable via `segmentOptions.contextBar`:
|
|
90
|
+
|
|
91
|
+
| Key | Type | Default | Description |
|
|
92
|
+
|-----|------|---------|-------------|
|
|
93
|
+
| `barWidth` | number | `18` | Number of characters wide |
|
|
94
|
+
| `filledChar` | string | `"▋"` | Character used for the filled portion |
|
|
95
|
+
| `unfilledChar` | string | `"▋"` | Character used for the unfilled portion |
|
|
96
|
+
| `unfilledColor` | color | `"#4e4c49"` | Color of the unfilled portion — hex or pi theme token |
|
|
97
|
+
| `gradientStart` | color | `"#f29373"` | Gradient color at the left/empty end — hex or pi theme token |
|
|
98
|
+
| `gradientMid` | color | `"#d67858"` | Gradient midpoint color — hex or pi theme token |
|
|
99
|
+
| `gradientEnd` | color | `"#ae4f2f"` | Gradient color at the right/full end — hex or pi theme token |
|
|
100
|
+
| `gradientMidPoint` | number | `0.55` | Where `gradientMid` sits along the bar (0–1). Below this fraction the gradient runs start→mid; above it mid→end |
|
|
101
|
+
|
|
102
|
+
All color fields accept either a hex string (e.g. `"#ff6347"`) or a pi theme token (e.g. `"accent"`, `"warning"`, `"dim"`).
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{
|
|
106
|
+
"segmentOptions": {
|
|
107
|
+
"contextBar": {
|
|
108
|
+
"barWidth": 20,
|
|
109
|
+
"unfilledColor": "dim",
|
|
110
|
+
"gradientStart": "#56b6c2",
|
|
111
|
+
"gradientMid": "#61afef",
|
|
112
|
+
"gradientEnd": "#c678dd",
|
|
113
|
+
"gradientMidPoint": 0.4
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Thinking Levels
|
|
120
|
+
|
|
121
|
+
The `thinking` segment shows per-level colours:
|
|
122
|
+
|
|
123
|
+
| Level | Display | Default colour |
|
|
124
|
+
|-------|---------|---------------|
|
|
125
|
+
| `off` | `OFF` | dim |
|
|
126
|
+
| `minimal` | `MINIMAL` | muted |
|
|
127
|
+
| `low` | `LOW` | warning |
|
|
128
|
+
| `medium` | `MEDIUM` | success |
|
|
129
|
+
| `high` | `HIGH` | `#afb9fe` |
|
|
130
|
+
| `xhigh` | `EXTRA HIGH` | rainbow gradient |
|
|
131
|
+
| `max` | `MAX` | rainbow gradient |
|
|
132
|
+
|
|
133
|
+
Override any level colour via the corresponding key in `colors`. Setting `thinkingXhigh` or `thinkingMax` replaces the rainbow gradient with a solid colour:
|
|
134
|
+
|
|
135
|
+
```json
|
|
136
|
+
{
|
|
137
|
+
"colors": {
|
|
138
|
+
"thinkingXhigh": "#9575cd",
|
|
139
|
+
"thinkingMax": "#ce93d8"
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
## Git Status Indicators
|
|
146
|
+
|
|
147
|
+
The `git` segment shows:
|
|
148
|
+
- Branch name coloured green (clean) or amber (dirty)
|
|
149
|
+
- `*N` — unstaged changes
|
|
150
|
+
- `+N` — staged changes
|
|
151
|
+
- `?N` — untracked files
|
|
152
|
+
|
|
153
|
+
## Session and Performance Statistics
|
|
154
|
+
|
|
155
|
+
The `session_stats` segment shows turns, tool steps, and (when available) model requests and model tool calls for the current branch; turns and steps are reconstructed from session history so they survive quit/resume.
|
|
156
|
+
|
|
157
|
+
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
|
+
|
|
159
|
+
## Icons
|
|
160
|
+
|
|
161
|
+
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:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
export FOOTER_NERD_FONTS=1 # force Nerd Fonts on
|
|
165
|
+
export FOOTER_NERD_FONTS=0 # force plain icons
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Installing a Nerd Font (macOS)
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
brew install --cask font-jetbrains-mono-nerd-font
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Other fonts available via `brew search nerd-font`.
|
|
175
|
+
|
|
176
|
+
### Configuring iTerm2
|
|
177
|
+
|
|
178
|
+
1. Open **Settings → Profiles → Text**
|
|
179
|
+
2. Set **Font** to `JetBrainsMonoNL Nerd Font Propo`, size `10` (recommended)
|
|
180
|
+
3. Enable **Use a different font for non-ASCII text** and set the same font there — required for icons to render correctly
|
|
181
|
+
|
|
182
|
+
### Custom Icons
|
|
183
|
+
|
|
184
|
+
To swap out any icon, add an `icons` key to your `~/.pi/agent/configs/opl-footer.json`. Browse available Nerd Font glyphs at [nerdfonts.com/cheat-sheet](https://www.nerdfonts.com/cheat-sheet):
|
|
185
|
+
|
|
186
|
+
```json
|
|
187
|
+
{
|
|
188
|
+
"icons": {
|
|
189
|
+
"branch": "",
|
|
190
|
+
"separator": "|"
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
```
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { FooterUserConfig, StatusLineSegmentId, ColorScheme, StatusLineSegmentOptions } from "./types.js";
|
|
4
|
+
import { getDefaultColors } from "./theme.js";
|
|
5
|
+
import type { IconSet } from "./icons.js";
|
|
6
|
+
|
|
7
|
+
// Default segment configuration — two rows
|
|
8
|
+
const DEFAULT_ROW1_LEFT: StatusLineSegmentId[] = ["pi", "separator", "model", "separator", "path", "git"];
|
|
9
|
+
const DEFAULT_ROW1_RIGHT: StatusLineSegmentId[] = ["context_pct"];
|
|
10
|
+
const DEFAULT_ROW2_LEFT: StatusLineSegmentId[] = ["thinking", "separator", "caveman", "separator", "mode_switcher"];
|
|
11
|
+
const DEFAULT_ROW2_RIGHT: StatusLineSegmentId[] = ["token_total", "separator", "cost"];
|
|
12
|
+
const DEFAULT_ROW3_LEFT: StatusLineSegmentId[] = ["session_stats"];
|
|
13
|
+
const DEFAULT_ROW3_RIGHT: StatusLineSegmentId[] = ["perf_stats"];
|
|
14
|
+
|
|
15
|
+
const DEFAULT_SEGMENT_OPTIONS: StatusLineSegmentOptions = {
|
|
16
|
+
path: { mode: "full" },
|
|
17
|
+
git: {
|
|
18
|
+
showBranch: true,
|
|
19
|
+
showStaged: true,
|
|
20
|
+
showUnstaged: true,
|
|
21
|
+
showUntracked: true,
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Cache for user config
|
|
26
|
+
let userConfigCache: FooterUserConfig | null = null;
|
|
27
|
+
let userConfigCacheTime = 0;
|
|
28
|
+
const CACHE_TTL = 5000; // 5 seconds
|
|
29
|
+
|
|
30
|
+
function getConfigPath(): string {
|
|
31
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE || "";
|
|
32
|
+
return join(homeDir, ".pi", "agent", "configs", "opl-footer.json");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function loadUserConfig(): FooterUserConfig | null {
|
|
36
|
+
const now = Date.now();
|
|
37
|
+
if (userConfigCache && now - userConfigCacheTime < CACHE_TTL) {
|
|
38
|
+
return userConfigCache;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const configPath = getConfigPath();
|
|
42
|
+
try {
|
|
43
|
+
if (existsSync(configPath)) {
|
|
44
|
+
const content = readFileSync(configPath, "utf-8");
|
|
45
|
+
const parsed = JSON.parse(content);
|
|
46
|
+
userConfigCache = parsed as FooterUserConfig;
|
|
47
|
+
userConfigCacheTime = now;
|
|
48
|
+
return userConfigCache;
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
// Ignore errors, return null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
userConfigCache = null;
|
|
55
|
+
userConfigCacheTime = now;
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function clearUserConfigCache(): void {
|
|
60
|
+
userConfigCache = null;
|
|
61
|
+
userConfigCacheTime = 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function getEffectiveConfig(): {
|
|
65
|
+
row1LeftSegments: StatusLineSegmentId[];
|
|
66
|
+
row1RightSegments: StatusLineSegmentId[];
|
|
67
|
+
row2LeftSegments: StatusLineSegmentId[];
|
|
68
|
+
row2RightSegments: StatusLineSegmentId[];
|
|
69
|
+
row3LeftSegments: StatusLineSegmentId[];
|
|
70
|
+
row3RightSegments: StatusLineSegmentId[];
|
|
71
|
+
colors: ColorScheme;
|
|
72
|
+
segmentOptions: StatusLineSegmentOptions;
|
|
73
|
+
icons: Partial<IconSet>;
|
|
74
|
+
} {
|
|
75
|
+
const userConfig = loadUserConfig();
|
|
76
|
+
|
|
77
|
+
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,
|
|
84
|
+
colors: userConfig?.colors ?? getDefaultColors(),
|
|
85
|
+
segmentOptions: {
|
|
86
|
+
...DEFAULT_SEGMENT_OPTIONS,
|
|
87
|
+
...userConfig?.segmentOptions,
|
|
88
|
+
},
|
|
89
|
+
icons: userConfig?.icons ?? {},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import type { GitStatus } from "./types.js";
|
|
3
|
+
|
|
4
|
+
interface CachedGitStatus {
|
|
5
|
+
staged: number;
|
|
6
|
+
unstaged: number;
|
|
7
|
+
untracked: number;
|
|
8
|
+
timestamp: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface CachedBranch {
|
|
12
|
+
branch: string | null;
|
|
13
|
+
timestamp: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const CACHE_TTL_MS = 1000;
|
|
17
|
+
const BRANCH_TTL_MS = 500;
|
|
18
|
+
let cachedStatus: CachedGitStatus | null = null;
|
|
19
|
+
let cachedBranch: CachedBranch | null = null;
|
|
20
|
+
let pendingFetch: Promise<void> | null = null;
|
|
21
|
+
let pendingBranchFetch: Promise<void> | null = null;
|
|
22
|
+
let invalidationCounter = 0;
|
|
23
|
+
let branchInvalidationCounter = 0;
|
|
24
|
+
|
|
25
|
+
function parseGitStatusOutput(output: string): { staged: number; unstaged: number; untracked: number } {
|
|
26
|
+
let staged = 0;
|
|
27
|
+
let unstaged = 0;
|
|
28
|
+
let untracked = 0;
|
|
29
|
+
|
|
30
|
+
for (const line of output.split("\n")) {
|
|
31
|
+
if (!line) continue;
|
|
32
|
+
const x = line[0];
|
|
33
|
+
const y = line[1];
|
|
34
|
+
|
|
35
|
+
if (x === "?" && y === "?") {
|
|
36
|
+
untracked++;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (x && x !== " " && x !== "?") {
|
|
41
|
+
staged++;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (y && y !== " ") {
|
|
45
|
+
unstaged++;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { staged, unstaged, untracked };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function runGit(args: string[], timeoutMs = 200): Promise<string | null> {
|
|
53
|
+
return new Promise((resolve) => {
|
|
54
|
+
const proc = spawn("git", args, {
|
|
55
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
let stdout = "";
|
|
59
|
+
let resolved = false;
|
|
60
|
+
|
|
61
|
+
const finish = (result: string | null) => {
|
|
62
|
+
if (resolved) return;
|
|
63
|
+
resolved = true;
|
|
64
|
+
clearTimeout(timeoutId);
|
|
65
|
+
resolve(result);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
proc.stdout.on("data", (data) => {
|
|
69
|
+
stdout += data.toString();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
proc.on("close", (code) => {
|
|
73
|
+
finish(code === 0 ? stdout.trim() : null);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
proc.on("error", () => {
|
|
77
|
+
finish(null);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const timeoutId = setTimeout(() => {
|
|
81
|
+
proc.kill();
|
|
82
|
+
finish(null);
|
|
83
|
+
}, timeoutMs);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function fetchGitBranch(): Promise<string | null> {
|
|
88
|
+
const branch = await runGit(["branch", "--show-current"]);
|
|
89
|
+
if (branch === null) return null;
|
|
90
|
+
if (branch) return branch;
|
|
91
|
+
|
|
92
|
+
const sha = await runGit(["rev-parse", "--short", "HEAD"]);
|
|
93
|
+
return sha ? `${sha} (detached)` : "detached";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function fetchGitStatus(): Promise<{ staged: number; unstaged: number; untracked: number } | null> {
|
|
97
|
+
const output = await runGit(["status", "--porcelain"], 500);
|
|
98
|
+
if (output === null) return null;
|
|
99
|
+
return parseGitStatusOutput(output);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function getCurrentBranch(providerBranch: string | null): string | null {
|
|
103
|
+
const now = Date.now();
|
|
104
|
+
|
|
105
|
+
if (cachedBranch && now - cachedBranch.timestamp < BRANCH_TTL_MS) {
|
|
106
|
+
return cachedBranch.branch;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!pendingBranchFetch) {
|
|
110
|
+
const fetchId = branchInvalidationCounter;
|
|
111
|
+
pendingBranchFetch = fetchGitBranch().then((result) => {
|
|
112
|
+
if (fetchId === branchInvalidationCounter) {
|
|
113
|
+
cachedBranch = {
|
|
114
|
+
branch: result,
|
|
115
|
+
timestamp: Date.now(),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
pendingBranchFetch = null;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return cachedBranch ? cachedBranch.branch : providerBranch;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function getGitStatus(providerBranch: string | null): GitStatus {
|
|
126
|
+
const now = Date.now();
|
|
127
|
+
const branch = getCurrentBranch(providerBranch);
|
|
128
|
+
|
|
129
|
+
if (cachedStatus && now - cachedStatus.timestamp < CACHE_TTL_MS) {
|
|
130
|
+
return {
|
|
131
|
+
branch,
|
|
132
|
+
staged: cachedStatus.staged,
|
|
133
|
+
unstaged: cachedStatus.unstaged,
|
|
134
|
+
untracked: cachedStatus.untracked,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!pendingFetch) {
|
|
139
|
+
const fetchId = invalidationCounter;
|
|
140
|
+
pendingFetch = fetchGitStatus().then((result) => {
|
|
141
|
+
if (fetchId === invalidationCounter) {
|
|
142
|
+
cachedStatus = result
|
|
143
|
+
? { staged: result.staged, unstaged: result.unstaged, untracked: result.untracked, timestamp: Date.now() }
|
|
144
|
+
: { staged: 0, unstaged: 0, untracked: 0, timestamp: Date.now() };
|
|
145
|
+
}
|
|
146
|
+
pendingFetch = null;
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (cachedStatus) {
|
|
151
|
+
return {
|
|
152
|
+
branch,
|
|
153
|
+
staged: cachedStatus.staged,
|
|
154
|
+
unstaged: cachedStatus.unstaged,
|
|
155
|
+
untracked: cachedStatus.untracked,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { branch, staged: 0, unstaged: 0, untracked: 0 };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function invalidateGitStatus(): void {
|
|
163
|
+
cachedStatus = null;
|
|
164
|
+
invalidationCounter++;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function invalidateGitBranch(): void {
|
|
168
|
+
cachedBranch = null;
|
|
169
|
+
branchInvalidationCounter++;
|
|
170
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export interface IconSet {
|
|
2
|
+
pi: string;
|
|
3
|
+
folder: string;
|
|
4
|
+
branch: string;
|
|
5
|
+
git: string;
|
|
6
|
+
separator: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Nerd Font icons
|
|
10
|
+
export const NERD_ICONS: IconSet = {
|
|
11
|
+
pi: "\uE22C", // nf-oct-pi
|
|
12
|
+
folder: "\uF115", // nf-fa-folder_open
|
|
13
|
+
branch: "\uF126", // nf-fa-code_fork
|
|
14
|
+
git: "\uF1D3", // nf-fa-git
|
|
15
|
+
separator: "|",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// ASCII/Unicode fallback icons
|
|
19
|
+
export const ASCII_ICONS: IconSet = {
|
|
20
|
+
pi: "π",
|
|
21
|
+
folder: "📂",
|
|
22
|
+
branch: "⎇",
|
|
23
|
+
git: "⎇",
|
|
24
|
+
separator: "|",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Detect Nerd Font support
|
|
28
|
+
export function hasNerdFonts(): boolean {
|
|
29
|
+
if (process.env.FOOTER_NERD_FONTS === "1") return true;
|
|
30
|
+
if (process.env.FOOTER_NERD_FONTS === "0") return false;
|
|
31
|
+
|
|
32
|
+
if (process.env.GHOSTTY_RESOURCES_DIR) return true;
|
|
33
|
+
|
|
34
|
+
const termProg = (process.env.TERM_PROGRAM || "").toLowerCase();
|
|
35
|
+
const nerdTerms = ["iterm", "wezterm", "kitty", "ghostty", "alacritty", "foot", "rio", "contour"];
|
|
36
|
+
if (nerdTerms.some(t => termProg.includes(t))) return true;
|
|
37
|
+
|
|
38
|
+
const term = (process.env.TERM || "").toLowerCase();
|
|
39
|
+
const nerdTermVars = ["xterm-kitty", "xterm-ghostty", "alacritty", "foot", "rio", "contour"];
|
|
40
|
+
return nerdTermVars.some(t => term.includes(t));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function getIcons(customIcons?: Partial<IconSet>): IconSet {
|
|
44
|
+
const baseIcons = hasNerdFonts() ? NERD_ICONS : ASCII_ICONS;
|
|
45
|
+
if (!customIcons || Object.keys(customIcons).length === 0) {
|
|
46
|
+
return baseIcons;
|
|
47
|
+
}
|
|
48
|
+
return { ...baseIcons, ...customIcons };
|
|
49
|
+
}
|