@underactive/pi-topping-statusline 0.1.0
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 +23 -0
- package/README.md +208 -0
- package/index.ts +355 -0
- package/package.json +54 -0
- package/src/context-graph.ts +48 -0
- package/src/context-thresholds.ts +84 -0
- package/src/context.ts +387 -0
- package/src/layout.ts +164 -0
- package/src/menu.ts +687 -0
- package/src/rainbow.ts +98 -0
- package/src/segments.ts +349 -0
- package/src/separators.ts +22 -0
- package/src/settings-menu.ts +370 -0
- package/src/settings.ts +169 -0
- package/src/tests/context-thresholds.test.ts +50 -0
- package/src/tests/feeds.test.ts +368 -0
- package/src/tests/menu.test.ts +109 -0
- package/src/tests/rainbow.test.ts +133 -0
- package/src/tests/token-rate-monitor.test.ts +87 -0
- package/src/tests/token-rate.test.ts +75 -0
- package/src/tests/ts-resolve.ts +20 -0
- package/src/theme.ts +454 -0
- package/src/token-rate.ts +242 -0
- package/src/types.ts +166 -0
- package/src/utils.ts +161 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Mario Zechner
|
|
4
|
+
Copyright (c) 2025-2026 Can Bölük
|
|
5
|
+
Copyright (c) 2026 Eric Sison
|
|
6
|
+
|
|
7
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
8
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
9
|
+
in the Software without restriction, including without limitation the rights
|
|
10
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
11
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
12
|
+
furnished to do so, subject to the following conditions:
|
|
13
|
+
|
|
14
|
+
The above copyright notice and this permission notice shall be included in all
|
|
15
|
+
copies or substantial portions of the Software.
|
|
16
|
+
|
|
17
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
18
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
19
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
20
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
21
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
22
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
23
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# pi-topping-statusline
|
|
2
|
+
|
|
3
|
+
[OMP](https://github.com/can1357/oh-my-pi)'s powerline statusline, ported as a Pi extension with modifications.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
The bar renders into the editor's
|
|
8
|
+
**top border**: `π > model · thinking level > path > git > PR` (with the default
|
|
9
|
+
powerline-thin separator), with the session name (hue hashed from the name) right-aligned
|
|
10
|
+
over a border-colored fill. The box's **bottom border** carries configurable left/right
|
|
11
|
+
segment groups — by default a scroll hint on the left, and pi's footer stats plus a context
|
|
12
|
+
usage graph on the right.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pi install npm:@underactive/pi-topping-statusline
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Then `/reload` (or restart pi). Requirements: a [Nerd Font](https://www.nerdfonts.com) and a
|
|
21
|
+
truecolor terminal for the default look — the Symbols setting in
|
|
22
|
+
`/topping-statusline-settings` (`unicode` or `ascii`) drops the font requirement.
|
|
23
|
+
|
|
24
|
+
## Configuration — `/topping-statusline-settings`
|
|
25
|
+
|
|
26
|
+
`/topping-statusline-settings` opens a settings TUI with a live preview of the box's top and
|
|
27
|
+
bottom bars. Requires TUI mode. Settings persist to `~/.pi/agent/pi-topping-statusline/settings.json`
|
|
28
|
+
and apply live.
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
╔═[ Pi Topping Statusline: Settings ]════════════════════════════╗
|
|
32
|
+
╟─ Preview ──────────────────────────────────────────────────────╢
|
|
33
|
+
║ ║
|
|
34
|
+
║ ╭── π > ⬢ Sonnet · ◉ max > pi/pi-topping > main ── session ──╮ ║
|
|
35
|
+
║ │ │ ║
|
|
36
|
+
║ ╰── ↑ 3 more ── ↑ 12.4K ↓ 3.1K R 148K W 12K 92.3% $0.42 42%──╯ ║
|
|
37
|
+
║ ║
|
|
38
|
+
╟─ Global ───────────────────────────────────────────────────────╢
|
|
39
|
+
║ ▸ [■] Transparent Segments ON ║
|
|
40
|
+
║ Separator ‹ powerline-thin › ║
|
|
41
|
+
║ Symbols ‹ nerdfont › ║
|
|
42
|
+
║ Border style ‹ rounded › ║
|
|
43
|
+
║ [■] Rainbow border on max thinking ON ║
|
|
44
|
+
║ ║
|
|
45
|
+
╟─ Top Left Segment Group ───────────────────────────────────────╢
|
|
46
|
+
║ [■] Pi symbol ON ║
|
|
47
|
+
║ [■] Model ON ║
|
|
48
|
+
║ [ ] Provider OFF ║
|
|
49
|
+
║ [■] Thinking level ON ║
|
|
50
|
+
║ [■] Path ON ║
|
|
51
|
+
║ [■] Git ON ║
|
|
52
|
+
║ [■] PR ON ║
|
|
53
|
+
║ ║
|
|
54
|
+
╟─ Top Right Segment Group ──────────────────────────────────────╢
|
|
55
|
+
║ [ ] Token rate OFF ║
|
|
56
|
+
║ [■] Session name ON ║
|
|
57
|
+
║ … Bottom Right / Bottom Left groups — 8 more toggles ║
|
|
58
|
+
║ ║
|
|
59
|
+
╟─ Feeds ────────────────────────────────────────────────────────╢
|
|
60
|
+
║ 1. type pi-prompt-cache/savings ║
|
|
61
|
+
║ 1. field savedUsd ║
|
|
62
|
+
║ 1. prefix CS ║
|
|
63
|
+
║ 1. format ‹ currency › ║
|
|
64
|
+
║ 1. remove this feed ║
|
|
65
|
+
║ + add feed ║
|
|
66
|
+
║ ║
|
|
67
|
+
║ ↑↓ move ←→ cycle ␣ toggle ⏎ apply/edit esc cancel ║
|
|
68
|
+
╚════════════════════════════════════════════════════════[ 1/28 ]╝
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
| Section | Settings |
|
|
72
|
+
| --- | --- |
|
|
73
|
+
| Global | Transparent Segments · Separator (`powerline` `powerline-thin` `slash` `pipe` `ascii`) · Symbols (`nerdfont` `unicode` `ascii` — stored in settings.json as `nerd`/`unicode`/`ascii`) · Border style (`rounded` `heavy` `double` `single`) · Rainbow border on max thinking |
|
|
74
|
+
| Top Left Segment Group | Pi symbol · Model · Provider · Thinking level · Path · Git · PR |
|
|
75
|
+
| Top Right Segment Group | Token rate · Session name |
|
|
76
|
+
| Bottom Right Segment Group | Feeds · Token rate · Pi stats · Context bar · Context stats |
|
|
77
|
+
| Bottom Left Segment Group | Scroll hint · Feeds · Token rate |
|
|
78
|
+
| Feeds | One subscription per row: type · field · prefix · format, plus add/remove |
|
|
79
|
+
| Defaults | Transparent on · Separator `powerline-thin` · Symbols `nerdfont` · Border style `rounded` · Rainbow border on |
|
|
80
|
+
|
|
81
|
+
With **Rainbow border on max thinking** on (the default), cycling the thinking level to `max`
|
|
82
|
+
replaces the border's fixed theme color with an animated rainbow: a full hue cycle distributed
|
|
83
|
+
around the box perimeter that slowly flows around the border (~14s per rotation), like Apple
|
|
84
|
+
Intelligence's screen border. Any other thinking level keeps the normal theme border color.
|
|
85
|
+
The settings preview animates while the toggle is on; the effect repaints the border about
|
|
86
|
+
20 times a second, so switch it off over slow SSH links or in terminals with expensive
|
|
87
|
+
redraws.
|
|
88
|
+
|
|
89
|
+
## Segments
|
|
90
|
+
|
|
91
|
+
Ported with pi data: `pi`, `model` (model · provider · thinking level), `path`
|
|
92
|
+
(worktree/scratch-dir aware; always strips `~/Projects` and `/work` prefixes — not exposed in
|
|
93
|
+
the settings TUI), `git` (branch + `*unstaged +staged ?untracked`, HEAD
|
|
94
|
+
fs-watch), `pr` (via `gh`, hidden if missing), `session_name`, `token_rate` (live tok/s —
|
|
95
|
+
accent while active, held 1.5s, faded 0.5s, then a dim `--- tok/s` placeholder; estimate
|
|
96
|
+
pipelined from pi-topping's word-count EMA; available in the top-right, bottom-left, and
|
|
97
|
+
bottom-right groups), `pi_stats`, `context_graph` (bar + stats), `scroll_hint`, plus `feeds`
|
|
98
|
+
(documented below).
|
|
99
|
+
|
|
100
|
+
When the terminal narrows: right segments drop first, then the path shrinks (to ~8 cells),
|
|
101
|
+
then left segments drop end-first — the path is always the last to go.
|
|
102
|
+
|
|
103
|
+
## Feeds
|
|
104
|
+
|
|
105
|
+
`feeds` surfaces values other extensions publish as custom session entries, so the bar can
|
|
106
|
+
carry figures this extension knows nothing about. There is one subscription list, shared by
|
|
107
|
+
the Bottom Right and Bottom Left Feeds toggles — enabling both shows the same values twice.
|
|
108
|
+
Each subscription is four fields, all configurable in the settings TUI:
|
|
109
|
+
|
|
110
|
+
| Field | Meaning | Example |
|
|
111
|
+
| --- | --- | --- |
|
|
112
|
+
| type | the publisher's `customType` | `pi-prompt-cache/savings` |
|
|
113
|
+
| field | property of the entry's `data` to read | `savedUsd` |
|
|
114
|
+
| prefix | literal label drawn before the value | `CS` |
|
|
115
|
+
| format | `currency`, `number`, or `text` | `currency` |
|
|
116
|
+
|
|
117
|
+
In `settings.json` the first column is persisted as `customType`; the settings TUI labels it
|
|
118
|
+
*type*. That example renders `CS$1.44`. The prefix is glued directly to the value, so write
|
|
119
|
+
it as `TOK ` when you want `TOK 42`; leading and trailing spaces survive, while escape
|
|
120
|
+
sequences and control characters are stripped.
|
|
121
|
+
|
|
122
|
+
The list ships seeded with the savings feed published by
|
|
123
|
+
[pi-prompt-cache](https://github.com/underactive/pi-prompt-cache), which is where the
|
|
124
|
+
`currency` rules come from: figures under half a cent are hidden, matching that extension's
|
|
125
|
+
own footer cutoff so the two never disagree. That floor hides the negative values published
|
|
126
|
+
early in a session, before the cache-write premium is amortized by reads. Above the floor,
|
|
127
|
+
`currency` shows two decimals below $100 and whole dollars above.
|
|
128
|
+
|
|
129
|
+
A feed contributes nothing when its publisher is absent, has published nothing this run, or
|
|
130
|
+
reports a value its format suppresses; the segment disappears entirely once every feed is
|
|
131
|
+
quiet, so it costs nothing to leave switched on. Publishers typically reset per process run,
|
|
132
|
+
so entries predating the current session start (`--resume`, `/reload`, `/new`, fork) are
|
|
133
|
+
ignored rather than shown as a stale total. The session is re-read at most once every two
|
|
134
|
+
seconds, because pi copies the whole entry list on each read while the bar re-renders on
|
|
135
|
+
every keystroke.
|
|
136
|
+
|
|
137
|
+
### Publishing a feed
|
|
138
|
+
|
|
139
|
+
Any extension can expose a value to this bar with one call and no dependency on this
|
|
140
|
+
package — the contract is a pi custom session entry, not an API of ours:
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
144
|
+
|
|
145
|
+
export default function (pi: ExtensionAPI) {
|
|
146
|
+
let published = 0;
|
|
147
|
+
pi.on("turn_end", () => {
|
|
148
|
+
const savedUsd = currentSavings();
|
|
149
|
+
// Publish only when the figure actually moves: consumers read the newest
|
|
150
|
+
// entry, never the history.
|
|
151
|
+
if (Math.abs(savedUsd - published) < 0.01) return;
|
|
152
|
+
published = savedUsd;
|
|
153
|
+
pi.appendEntry("my-extension/savings", { savedUsd });
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
A user then subscribes with type `my-extension/savings`, field `savedUsd`, and whatever
|
|
159
|
+
prefix and format they like.
|
|
160
|
+
|
|
161
|
+
**Use `appendEntry`.** It writes a `type: "custom"` entry, which is what the bar looks for.
|
|
162
|
+
It checks the entry kind *before* the `customType`, because custom message entries carry a
|
|
163
|
+
`customType` field too — so a value pushed through `sendMessage` is never read.
|
|
164
|
+
|
|
165
|
+
**Make `data` an object.** The subscription names a field, and the bar reads `data[field]`.
|
|
166
|
+
A bare number or string published as the whole payload cannot be addressed. Several fields
|
|
167
|
+
in one entry are fine; each can be subscribed to separately, and one entry can feed several
|
|
168
|
+
segments.
|
|
169
|
+
|
|
170
|
+
**Match the value to the format.** `currency` and `number` need a finite number and ignore
|
|
171
|
+
anything else, so a pre-formatted `"$1.44"` string only works under `text`. Publish the raw
|
|
172
|
+
number and let the user pick the presentation.
|
|
173
|
+
|
|
174
|
+
**Publish at turn boundaries, not per token.** A high emission rate buys no extra freshness,
|
|
175
|
+
and every append writes to the session file.
|
|
176
|
+
|
|
177
|
+
**Re-publish after a session start if your figure carries over.** As noted above, entries
|
|
178
|
+
written before the consumer's `session_start` are ignored, so after `--resume` the segment
|
|
179
|
+
stays empty until you publish again. Per-run counters get this for free; a lifetime counter
|
|
180
|
+
should emit once on the first turn of each run to become visible again.
|
|
181
|
+
|
|
182
|
+
**Nothing appears in the transcript.** Custom entries are excluded from LLM context, and pi
|
|
183
|
+
renders nothing for a `customType` with no registered entry renderer — a feed costs a line
|
|
184
|
+
in the session file and no screen space.
|
|
185
|
+
|
|
186
|
+
Name the type after your extension (`your-extension/metric`) to avoid collisions, and
|
|
187
|
+
document the field names and units — those are the strings your users type into the
|
|
188
|
+
settings TUI.
|
|
189
|
+
|
|
190
|
+
## Caveats
|
|
191
|
+
|
|
192
|
+
- Other editor-replacing extensions are wrapped rather than raced — their editor renders inside this box — but ones that draw their own bar or chrome will clash visually.
|
|
193
|
+
- The palette is OMP's **dark** theme; light terminal themes will look off.
|
|
194
|
+
|
|
195
|
+
## Development
|
|
196
|
+
|
|
197
|
+
Requires Node >= 22.19 (the test runner uses `--import` with TypeScript resolution).
|
|
198
|
+
|
|
199
|
+
```sh
|
|
200
|
+
ln -s "$(pwd)" ~/.pi/agent/extensions/pi-topping-statusline
|
|
201
|
+
npm install && npm run typecheck && npm test
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
## Attribution
|
|
205
|
+
|
|
206
|
+
The statusline design and most of the rendering code come from
|
|
207
|
+
[oh-my-pi](https://github.com/can1357/oh-my-pi) (MIT, © 2025 Mario Zechner,
|
|
208
|
+
© 2025–2026 Can Bölük), itself a fork of Mario Zechner's pi. This port is MIT as well.
|
package/index.ts
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-topping-statusline — oh-my-pi's statusline as a pi extension.
|
|
3
|
+
*
|
|
4
|
+
* Direct port of oh-my-pi's status-line module (can1357/oh-my-pi,
|
|
5
|
+
* packages/coding-agent/src/modes/components/status-line/, MIT). OMP mounts
|
|
6
|
+
* the bar via its editor's setTopBorderProvider. Upstream pi's editor draws
|
|
7
|
+
* plain ─── rows with no corners or sides, so the wrapper re-renders the inner
|
|
8
|
+
* editor 6 columns narrower and rebuilds a full rounded box around its rows:
|
|
9
|
+
* the bar in ╭─ … ─╮, │ sides on every text row, and a dedicated ╰─ … ─╯
|
|
10
|
+
* bottom border carrying configurable left/right segment groups. Upstream's
|
|
11
|
+
* own bottom border row is dropped.
|
|
12
|
+
*
|
|
13
|
+
* ctx.ui.setEditorComponent is a single last-writer-wins slot that other
|
|
14
|
+
* extensions also claim (e.g. rpiv-pi's lane dock installs its own editor on
|
|
15
|
+
* every session_start, and npm packages load after global extensions). Rather
|
|
16
|
+
* than racing for the slot, this extension wraps whichever factory currently
|
|
17
|
+
* owns it — decorating the produced editor's render() output — and re-wraps
|
|
18
|
+
* when the owner changes (checked on an unref'd 1s timer). The host preserves
|
|
19
|
+
* editor text and handlers across setEditorComponent swaps, so re-installing
|
|
20
|
+
* the wrapper mid-session is lossless.
|
|
21
|
+
*/
|
|
22
|
+
import {
|
|
23
|
+
CustomEditor,
|
|
24
|
+
type ExtensionAPI,
|
|
25
|
+
type ExtensionContext,
|
|
26
|
+
} from "@earendil-works/pi-coding-agent";
|
|
27
|
+
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
28
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
29
|
+
import { SegmentContextBuilder } from "./src/context.js";
|
|
30
|
+
import { buildStatusLine } from "./src/layout.js";
|
|
31
|
+
import {
|
|
32
|
+
perimeterLength,
|
|
33
|
+
perimeterPosition,
|
|
34
|
+
RAINBOW_DEG_PER_FRAME,
|
|
35
|
+
RAINBOW_FRAME_MS,
|
|
36
|
+
RainbowBorder,
|
|
37
|
+
} from "./src/rainbow.js";
|
|
38
|
+
import { registerSettingsCommand } from "./src/settings-menu.js";
|
|
39
|
+
import { createSettingsState } from "./src/settings.js";
|
|
40
|
+
import { theme } from "./src/theme.js";
|
|
41
|
+
import { TokenRateMonitor } from "./src/token-rate.js";
|
|
42
|
+
import type { EffectiveStatusLineSettings, SegmentIncludes } from "./src/types.js";
|
|
43
|
+
|
|
44
|
+
function segmentIncludes(effective: EffectiveStatusLineSettings): SegmentIncludes {
|
|
45
|
+
const allSegments = [
|
|
46
|
+
...effective.leftSegments,
|
|
47
|
+
...effective.rightSegments,
|
|
48
|
+
...effective.bottomLeftSegments,
|
|
49
|
+
...effective.bottomRightSegments,
|
|
50
|
+
];
|
|
51
|
+
return {
|
|
52
|
+
git: allSegments.includes("git"),
|
|
53
|
+
pr: allSegments.includes("pr"),
|
|
54
|
+
piStats: allSegments.includes("pi_stats"),
|
|
55
|
+
tokenRate: allSegments.includes("token_rate"),
|
|
56
|
+
feeds: allSegments.includes("feeds") ? effective.segmentOptions.feeds.map(feed => feed.customType) : [],
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type EditorFactory = NonNullable<ReturnType<ExtensionContext["ui"]["getEditorComponent"]>>;
|
|
61
|
+
|
|
62
|
+
export default function (pi: ExtensionAPI) {
|
|
63
|
+
const state = createSettingsState();
|
|
64
|
+
const builder = new SegmentContextBuilder(pi);
|
|
65
|
+
|
|
66
|
+
let activeTui: TUI | undefined;
|
|
67
|
+
let activeCtx: ExtensionContext | undefined;
|
|
68
|
+
let ourFactory: EditorFactory | undefined;
|
|
69
|
+
let wrappedFactory: EditorFactory | undefined;
|
|
70
|
+
let ensureTimer: ReturnType<typeof setInterval> | undefined;
|
|
71
|
+
let patchedFooter: Component | undefined;
|
|
72
|
+
let footerRenderOriginal: ((width: number) => string[]) | undefined;
|
|
73
|
+
|
|
74
|
+
const requestRender = () => activeTui?.requestRender();
|
|
75
|
+
builder.setRequestRender(requestRender);
|
|
76
|
+
|
|
77
|
+
const rateMonitor = new TokenRateMonitor(requestRender);
|
|
78
|
+
rateMonitor.attach(pi);
|
|
79
|
+
builder.setTokenRateProvider(() => rateMonitor.getDisplay());
|
|
80
|
+
|
|
81
|
+
// Animated rainbow border while the thinking level is max: a timer steps
|
|
82
|
+
// the hue phase and re-renders; renderBoxed paints the box glyphs from it.
|
|
83
|
+
const rainbow = new RainbowBorder();
|
|
84
|
+
let rainbowTimer: ReturnType<typeof setInterval> | undefined;
|
|
85
|
+
const rainbowActive = (): boolean =>
|
|
86
|
+
state.effective.rainbowBorder && activeCtx !== undefined && pi.getThinkingLevel() === "max";
|
|
87
|
+
const syncRainbow = (): void => {
|
|
88
|
+
if (rainbowActive()) {
|
|
89
|
+
if (!rainbowTimer) {
|
|
90
|
+
rainbowTimer = setInterval(() => {
|
|
91
|
+
if (!rainbowActive()) {
|
|
92
|
+
syncRainbow();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
rainbow.step(RAINBOW_DEG_PER_FRAME);
|
|
96
|
+
requestRender();
|
|
97
|
+
}, RAINBOW_FRAME_MS);
|
|
98
|
+
rainbowTimer.unref?.();
|
|
99
|
+
}
|
|
100
|
+
} else if (rainbowTimer) {
|
|
101
|
+
clearInterval(rainbowTimer);
|
|
102
|
+
rainbowTimer = undefined;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
registerSettingsCommand(pi, state, builder, () => {
|
|
107
|
+
syncRainbow();
|
|
108
|
+
requestRender();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const MIN_BOXED_WIDTH = 12;
|
|
112
|
+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
113
|
+
const isBorderRow = (row: string): boolean => {
|
|
114
|
+
const text = row.replace(ANSI_RE, "");
|
|
115
|
+
return /^─+$/.test(text) || /^─+ [↑↓] \d+ more\s*─*$/.test(text);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const renderBoxed = (
|
|
119
|
+
innerRender: (width: number) => string[],
|
|
120
|
+
width: number,
|
|
121
|
+
editor: { borderColor?: (str: string) => string },
|
|
122
|
+
): string[] => {
|
|
123
|
+
const ctx = activeCtx;
|
|
124
|
+
if (!ctx || width < MIN_BOXED_WIDTH) return innerRender(width);
|
|
125
|
+
|
|
126
|
+
// Chrome totals 6 cells on every row, so the bar and the inner text rows
|
|
127
|
+
// share one width budget. Border rows split it 3/3 (╭── … ──╮); text rows
|
|
128
|
+
// split it 2/4 to sit the cursor one column closer to the left edge.
|
|
129
|
+
const innerWidth = width - 6;
|
|
130
|
+
const lines = innerRender(innerWidth);
|
|
131
|
+
// Rows are [top border, ...text rows, bottom border, ...autocomplete rows];
|
|
132
|
+
// autocomplete rows only exist while the dropdown is open, so the bottom
|
|
133
|
+
// border is the last row unless autocompleteState (TS-private on Editor,
|
|
134
|
+
// a plain property at runtime) says otherwise.
|
|
135
|
+
let bottomIdx = lines.length - 1;
|
|
136
|
+
if ((editor as unknown as { autocompleteState?: unknown }).autocompleteState) {
|
|
137
|
+
while (bottomIdx > 0 && !isBorderRow(lines[bottomIdx] ?? "")) bottomIdx--;
|
|
138
|
+
}
|
|
139
|
+
if (lines.length < 3 || bottomIdx < 2) return innerRender(width);
|
|
140
|
+
|
|
141
|
+
const hint = (lines[bottomIdx] ?? "").replace(ANSI_RE, "").match(/[↑↓] \d+ more/)?.[0] ?? "";
|
|
142
|
+
const effective = state.effective;
|
|
143
|
+
const include = segmentIncludes(effective);
|
|
144
|
+
const segCtx = builder.build(innerWidth, effective.segmentOptions, include, hint || undefined);
|
|
145
|
+
// borderColor is assigned by the host after the factory returns — read late.
|
|
146
|
+
const border = editor.borderColor ?? ((s: string) => s);
|
|
147
|
+
const rainbowOn = rainbowActive();
|
|
148
|
+
const box = theme.getBox(effective.borderStyle);
|
|
149
|
+
const perimeter = perimeterLength(width, bottomIdx);
|
|
150
|
+
// Per-glyph paint: rainbow mode colors each cell by its perimeter walk
|
|
151
|
+
// position; otherwise the host border color is applied per glyph, which
|
|
152
|
+
// renders identically to the old per-fragment wraps.
|
|
153
|
+
const paint = (row: number, col: number, ch: string): string =>
|
|
154
|
+
rainbowOn ? rainbow.colorChar(ch, perimeterPosition(row, col, width, bottomIdx), perimeter) : border(ch);
|
|
155
|
+
const horizRun = (row: number, startCol: number, count: number): string => {
|
|
156
|
+
if (!rainbowOn) return border(box.horizontal.repeat(count));
|
|
157
|
+
let out = "";
|
|
158
|
+
for (let k = 0; k < count; k++) out += paint(row, startCol + k, box.horizontal);
|
|
159
|
+
return out;
|
|
160
|
+
};
|
|
161
|
+
const gapColor = (str: string, startCol: number, row: number): string => {
|
|
162
|
+
if (!rainbowOn) return border(str);
|
|
163
|
+
let out = "";
|
|
164
|
+
for (let k = 0; k < str.length; k++) out += paint(row, startCol + k, str[k]!);
|
|
165
|
+
return out;
|
|
166
|
+
};
|
|
167
|
+
const bar = buildStatusLine(
|
|
168
|
+
innerWidth,
|
|
169
|
+
segCtx,
|
|
170
|
+
effective,
|
|
171
|
+
gapColor,
|
|
172
|
+
{
|
|
173
|
+
left: effective.leftSegments,
|
|
174
|
+
right: effective.rightSegments,
|
|
175
|
+
},
|
|
176
|
+
{ col: 3, row: 0 },
|
|
177
|
+
);
|
|
178
|
+
const barWidth = visibleWidth(bar);
|
|
179
|
+
const pad = Math.max(0, innerWidth - barWidth);
|
|
180
|
+
// Leading spacer row: keeps the transcript from sitting flush on the box.
|
|
181
|
+
const out: string[] = [
|
|
182
|
+
"",
|
|
183
|
+
paint(0, 0, box.topLeft) +
|
|
184
|
+
horizRun(0, 1, 2) +
|
|
185
|
+
bar +
|
|
186
|
+
horizRun(0, 3 + barWidth, pad + 2) +
|
|
187
|
+
paint(0, width - 1, box.topRight),
|
|
188
|
+
];
|
|
189
|
+
for (let i = 1; i < bottomIdx; i++) {
|
|
190
|
+
out.push(paint(i, 0, box.vertical) + " " + (lines[i] ?? "") + " " + paint(i, width - 1, box.vertical));
|
|
191
|
+
}
|
|
192
|
+
const bottomBar = buildStatusLine(
|
|
193
|
+
innerWidth,
|
|
194
|
+
segCtx,
|
|
195
|
+
effective,
|
|
196
|
+
gapColor,
|
|
197
|
+
{
|
|
198
|
+
left: effective.bottomLeftSegments,
|
|
199
|
+
right: effective.bottomRightSegments,
|
|
200
|
+
},
|
|
201
|
+
{ col: 3, row: bottomIdx },
|
|
202
|
+
);
|
|
203
|
+
const bottomBarWidth = visibleWidth(bottomBar);
|
|
204
|
+
const bottomPad = Math.max(0, innerWidth - bottomBarWidth);
|
|
205
|
+
out.push(
|
|
206
|
+
paint(bottomIdx, 0, box.bottomLeft) +
|
|
207
|
+
horizRun(bottomIdx, 1, 2) +
|
|
208
|
+
bottomBar +
|
|
209
|
+
horizRun(bottomIdx, 3 + bottomBarWidth, bottomPad + 2) +
|
|
210
|
+
paint(bottomIdx, width - 1, box.bottomRight),
|
|
211
|
+
);
|
|
212
|
+
for (let i = bottomIdx + 1; i < lines.length; i++) {
|
|
213
|
+
out.push(` ${lines[i]}`);
|
|
214
|
+
}
|
|
215
|
+
return out;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const wrapFactory = (inner: EditorFactory | undefined): EditorFactory => {
|
|
219
|
+
return (tui, editorTheme, keybindings) => {
|
|
220
|
+
activeTui = tui;
|
|
221
|
+
const editor = inner
|
|
222
|
+
? inner(tui, editorTheme, keybindings)
|
|
223
|
+
: new CustomEditor(tui, editorTheme, keybindings);
|
|
224
|
+
const innerRender = editor.render.bind(editor);
|
|
225
|
+
editor.render = width => renderBoxed(innerRender, width, editor);
|
|
226
|
+
return editor;
|
|
227
|
+
};
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const ensureInstalled = () => {
|
|
231
|
+
const ctx = activeCtx;
|
|
232
|
+
if (!ctx) return;
|
|
233
|
+
const current = ctx.ui.getEditorComponent();
|
|
234
|
+
if (ourFactory && current === ourFactory) return;
|
|
235
|
+
wrappedFactory = current;
|
|
236
|
+
ourFactory = wrapFactory(current);
|
|
237
|
+
ctx.ui.setEditorComponent(ourFactory);
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
// Redundant with the bar or the graph: the footer's first line (cwd + git
|
|
241
|
+
// branch + session name) entirely, and — within the stats line — the context
|
|
242
|
+
// chunk ("22.8%/1.0M (auto)", which the graph's own label repeats) and the
|
|
243
|
+
// right-aligned model side ("(provider) model • thinking"). The unique stats
|
|
244
|
+
// (↑↓ tokens, cache R/W, hit rate, cost) survive; when nothing unique remains
|
|
245
|
+
// the line collapses entirely.
|
|
246
|
+
const CONTEXT_CHUNK_RE = /(?:\?|\d+(?:\.\d+)?%)\/\S+(?: \(auto\))?/;
|
|
247
|
+
const stripRedundantStats = (statsLine: string | undefined): string | undefined => {
|
|
248
|
+
if (!statsLine) return undefined;
|
|
249
|
+
const plain = statsLine.replace(ANSI_RE, "");
|
|
250
|
+
// The model side is right-aligned with >=2 spaces of padding; stats parts
|
|
251
|
+
// are single-space separated, so the first 2+ space run is the boundary.
|
|
252
|
+
const left = (plain.split(/ {2,}/)[0] ?? "")
|
|
253
|
+
.replace(CONTEXT_CHUNK_RE, "")
|
|
254
|
+
.replace(/\s+/g, " ")
|
|
255
|
+
.trim();
|
|
256
|
+
return left || undefined;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const PI_STATS_CACHE_MS = 250;
|
|
260
|
+
let piStatsCache: { at: number; value: string | undefined } | undefined;
|
|
261
|
+
builder.setPiStatsProvider(width => {
|
|
262
|
+
if (!footerRenderOriginal) return undefined;
|
|
263
|
+
const now = Date.now();
|
|
264
|
+
if (piStatsCache && now - piStatsCache.at < PI_STATS_CACHE_MS) return piStatsCache.value;
|
|
265
|
+
// Render wide so Pi does not truncate the model side before
|
|
266
|
+
// stripRedundantStats finds its separating padding.
|
|
267
|
+
const line = footerRenderOriginal(Math.max(width, 200))[1];
|
|
268
|
+
const value = stripRedundantStats(line);
|
|
269
|
+
piStatsCache = { at: now, value };
|
|
270
|
+
return value;
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// The default footer is the only component with both setAutoCompactEnabled
|
|
274
|
+
// and setSession. Pi >= 0.84 nests it inside the fullscreen dock's footer
|
|
275
|
+
// Container rather than mounting it directly on the TUI, so search the whole
|
|
276
|
+
// subtree instead of just the top-level children.
|
|
277
|
+
const findFooter = (node: Component): Component | undefined => {
|
|
278
|
+
const probe = node as unknown as Record<string, unknown>;
|
|
279
|
+
if (typeof probe.setAutoCompactEnabled === "function" && typeof probe.setSession === "function") return node;
|
|
280
|
+
if (!Array.isArray(probe.children)) return undefined;
|
|
281
|
+
for (const child of probe.children as Component[]) {
|
|
282
|
+
const found = findFooter(child);
|
|
283
|
+
if (found) return found;
|
|
284
|
+
}
|
|
285
|
+
return undefined;
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
// setFooter would replace the whole component (stats + extension-status
|
|
289
|
+
// lines included), so instead patch the live default footer in place and drop
|
|
290
|
+
// its cwd and stats lines, which the box now carries itself.
|
|
291
|
+
const ensureFooterPatched = () => {
|
|
292
|
+
const tui = activeTui;
|
|
293
|
+
if (!tui || patchedFooter) return;
|
|
294
|
+
let footer: Component | undefined;
|
|
295
|
+
for (const child of tui.children) {
|
|
296
|
+
footer = findFooter(child);
|
|
297
|
+
if (footer) break;
|
|
298
|
+
}
|
|
299
|
+
if (!footer) return;
|
|
300
|
+
const orig = footer.render.bind(footer);
|
|
301
|
+
footerRenderOriginal = orig;
|
|
302
|
+
footer.render = width => orig(width).slice(2);
|
|
303
|
+
patchedFooter = footer;
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
pi.on("model_select", () => requestRender());
|
|
307
|
+
pi.on("thinking_level_select", () => {
|
|
308
|
+
syncRainbow();
|
|
309
|
+
requestRender();
|
|
310
|
+
});
|
|
311
|
+
pi.on("session_info_changed", () => requestRender());
|
|
312
|
+
|
|
313
|
+
pi.on("session_start", (_event, ctx) => {
|
|
314
|
+
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
315
|
+
activeCtx = ctx;
|
|
316
|
+
builder.attach(ctx);
|
|
317
|
+
rateMonitor.enable();
|
|
318
|
+
syncRainbow();
|
|
319
|
+
ensureInstalled();
|
|
320
|
+
ensureFooterPatched();
|
|
321
|
+
ensureTimer ??= setInterval(() => {
|
|
322
|
+
ensureInstalled();
|
|
323
|
+
ensureFooterPatched();
|
|
324
|
+
// The host's /settings dialog can change the thinking level without
|
|
325
|
+
// firing thinking_level_select, so reconcile the animator periodically.
|
|
326
|
+
syncRainbow();
|
|
327
|
+
}, 1000);
|
|
328
|
+
ensureTimer.unref?.();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
pi.on("session_shutdown", () => {
|
|
332
|
+
rateMonitor.disable();
|
|
333
|
+
builder.dispose();
|
|
334
|
+
if (rainbowTimer) {
|
|
335
|
+
clearInterval(rainbowTimer);
|
|
336
|
+
rainbowTimer = undefined;
|
|
337
|
+
}
|
|
338
|
+
if (ensureTimer) {
|
|
339
|
+
clearInterval(ensureTimer);
|
|
340
|
+
ensureTimer = undefined;
|
|
341
|
+
}
|
|
342
|
+
if (activeCtx && ourFactory && activeCtx.ui.getEditorComponent() === ourFactory) {
|
|
343
|
+
activeCtx.ui.setEditorComponent(wrappedFactory);
|
|
344
|
+
}
|
|
345
|
+
if (patchedFooter && footerRenderOriginal) {
|
|
346
|
+
patchedFooter.render = footerRenderOriginal;
|
|
347
|
+
}
|
|
348
|
+
patchedFooter = undefined;
|
|
349
|
+
footerRenderOriginal = undefined;
|
|
350
|
+
ourFactory = undefined;
|
|
351
|
+
wrappedFactory = undefined;
|
|
352
|
+
activeTui = undefined;
|
|
353
|
+
activeCtx = undefined;
|
|
354
|
+
});
|
|
355
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@underactive/pi-topping-statusline",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "oh-my-pi's powerline statusline as a pi extension with some customizations",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi-extension"
|
|
8
|
+
],
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"author": "Eric Sison",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/underactive/pi-topping-statusline.git"
|
|
14
|
+
},
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/underactive/pi-topping-statusline/issues"
|
|
17
|
+
},
|
|
18
|
+
"homepage": "https://github.com/underactive/pi-topping-statusline#readme",
|
|
19
|
+
"type": "module",
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=22.19.0"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"src",
|
|
28
|
+
"index.ts",
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"typecheck": "tsc --noEmit",
|
|
34
|
+
"test": "node --import ./src/tests/ts-resolve.ts --test src/tests/*.test.ts"
|
|
35
|
+
},
|
|
36
|
+
"pi": {
|
|
37
|
+
"extensions": [
|
|
38
|
+
"./index.ts"
|
|
39
|
+
],
|
|
40
|
+
"image": "https://raw.githubusercontent.com/underactive/pi-topping-statusline/main/media/poster.png"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@earendil-works/pi-ai": "*",
|
|
44
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
45
|
+
"@earendil-works/pi-tui": "*"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@earendil-works/pi-ai": "0.80.10",
|
|
49
|
+
"@earendil-works/pi-coding-agent": "0.80.10",
|
|
50
|
+
"@earendil-works/pi-tui": "0.80.10",
|
|
51
|
+
"@types/node": "^22.0.0",
|
|
52
|
+
"typescript": "^5.7.0"
|
|
53
|
+
}
|
|
54
|
+
}
|