pi-input-bar 1.0.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/.gitattributes +16 -0
- package/.github/workflows/publish.yml +16 -0
- package/README.md +71 -0
- package/extensions/index.ts +1 -0
- package/extensions/input-bar.ts +407 -0
- package/extensions/render.ts +264 -0
- package/package.json +19 -0
package/.gitattributes
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Force LF line endings for all text files
|
|
2
|
+
* text=auto
|
|
3
|
+
|
|
4
|
+
# Force LF for source files
|
|
5
|
+
*.ts text eol=lf
|
|
6
|
+
*.js text eol=lf
|
|
7
|
+
*.json text eol=lf
|
|
8
|
+
*.md text eol=lf
|
|
9
|
+
|
|
10
|
+
# Binary files
|
|
11
|
+
*.png binary
|
|
12
|
+
*.jpg binary
|
|
13
|
+
*.gif binary
|
|
14
|
+
*.ico binary
|
|
15
|
+
*.ttf binary
|
|
16
|
+
*.woff2 binary
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
name: Publish to npm
|
|
2
|
+
on:
|
|
3
|
+
push:
|
|
4
|
+
tags: ['v*']
|
|
5
|
+
jobs:
|
|
6
|
+
publish:
|
|
7
|
+
runs-on: ubuntu-latest
|
|
8
|
+
steps:
|
|
9
|
+
- uses: actions/checkout@v4
|
|
10
|
+
- uses: actions/setup-node@v4
|
|
11
|
+
with:
|
|
12
|
+
node-version: 24
|
|
13
|
+
registry-url: 'https://registry.npmjs.org'
|
|
14
|
+
- run: npm publish --access public
|
|
15
|
+
env:
|
|
16
|
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
package/README.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# pi-input-bar
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/pi-input-bar)
|
|
4
|
+
|
|
5
|
+
A [pi](https://pi.dev) extension that embeds live status in your editor's border — no extra rows, no clutter. Everything fits inside the input box: folder, branch, model, speed, context, cost.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
╭─ ⌂ agent :: ⎇ main ● ────────────────── openrouter/tencent/hy3:free :: ✦ high ─╮
|
|
9
|
+
│ _ │
|
|
10
|
+
╰─────────────────────── ⛁ 10%/262k :: ↑29k ↓1.6k R24k :: ⚡pp 512 · tg 34.2 t/s ─╯
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
From npm:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pi install npm:pi-input-bar
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Or from git:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pi install git:github.com/sebaxzero/pi-input-bar.git
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Add `-l` to either form to install project-locally (adds to `.pi/settings.json` only).
|
|
28
|
+
|
|
29
|
+
No dependencies, no build step, nothing to configure — the bar appears as soon as it loads.
|
|
30
|
+
|
|
31
|
+
## What you see
|
|
32
|
+
|
|
33
|
+
**Top border** — where you are and what's thinking:
|
|
34
|
+
- Current folder and git branch
|
|
35
|
+
- A dot that pulses while the agent is responding (`●`)
|
|
36
|
+
- `provider/model` and thinking effort level (color-coded)
|
|
37
|
+
|
|
38
|
+
**Bottom border** — what's happening under the hood:
|
|
39
|
+
- **Context usage** — fill level, colored green → yellow → red as it fills
|
|
40
|
+
- **Token stats** — input, output, cache reads/writes
|
|
41
|
+
- **Session cost** — running total
|
|
42
|
+
- **Speed** — prefill (`pp`) and generation (`tg`) tokens/sec, llama.cpp style
|
|
43
|
+
|
|
44
|
+
**While streaming** — a random working word replaces the default label:
|
|
45
|
+
_Pondering…, Percolating…, Marinating…_ — re-rolled on every agent run.
|
|
46
|
+
|
|
47
|
+
## Commands
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
/input-bar — show current config
|
|
51
|
+
/input-bar set KEY=VAL — override config for the current session only
|
|
52
|
+
/input-bar save — write the current config to input-bar.json
|
|
53
|
+
/input-bar reset — restore defaults
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Configuration
|
|
57
|
+
|
|
58
|
+
Persistent configuration lives in `extensions/input-bar.json` next to the installed extension (auto-created on first load with defaults). You can ask the agent to edit it, or tune values live with `/input-bar set`.
|
|
59
|
+
|
|
60
|
+
| Key | Default | Description |
|
|
61
|
+
|-----|---------|-------------|
|
|
62
|
+
| `TOP` | `true` | Show folder/branch/model/effort in the top border |
|
|
63
|
+
| `BOTTOM` | `true` | Show stats in the bottom border (and slim down the footer) |
|
|
64
|
+
| `TPS` | `true` | Show tokens/sec |
|
|
65
|
+
| `ANIM` | `true` | Random working word while streaming |
|
|
66
|
+
| `ICONS` | `true` | Glyphs (⌂ ⎇ ⛁ ⚡) in the bars |
|
|
67
|
+
| `WORDS` | `""` | Comma-separated custom working-word pool |
|
|
68
|
+
|
|
69
|
+
## License
|
|
70
|
+
|
|
71
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./input-bar.ts";
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-input-bar
|
|
3
|
+
*
|
|
4
|
+
* Embeds live status into the input editor's own border lines (no extra rows):
|
|
5
|
+
*
|
|
6
|
+
* ─ ⌂ folder :: ⎇ branch ● ─────── provider/model :: ✦ effort ─ ← top border
|
|
7
|
+
* > (editor content)
|
|
8
|
+
* ────────────────── ⛁ 10%/262k :: ↑29k ↓1.6k R24k :: ⚡34 t/s ─ ← bottom border
|
|
9
|
+
*
|
|
10
|
+
* Implementation: a CustomEditor subclass whose render() takes the default
|
|
11
|
+
* editor output and rewrites the two full-rule border lines. The built-in
|
|
12
|
+
* footer is replaced by a minimal one that only shows extension statuses, so
|
|
13
|
+
* no duplicate stats lines appear below the editor.
|
|
14
|
+
*
|
|
15
|
+
* While streaming, a random working word (Claude Code style) replaces pi's
|
|
16
|
+
* default label, re-rolled on every agent run. Tokens/sec: live ~4 chars/token
|
|
17
|
+
* estimate (marked "~") while streaming, exact usage.output / elapsed after.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type {
|
|
21
|
+
ExtensionAPI,
|
|
22
|
+
ExtensionContext,
|
|
23
|
+
} from "@earendil-works/pi-coding-agent";
|
|
24
|
+
import { CustomEditor } from "@earendil-works/pi-coding-agent";
|
|
25
|
+
import { truncateToWidth, visibleWidth as tuiVisibleWidth } from "@earendil-works/pi-tui";
|
|
26
|
+
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
27
|
+
import { basename, dirname, join } from "node:path";
|
|
28
|
+
import { fileURLToPath } from "node:url";
|
|
29
|
+
import {
|
|
30
|
+
bottomBarSegments,
|
|
31
|
+
composeBottomLine,
|
|
32
|
+
composeLine,
|
|
33
|
+
DEFAULT_WORDS,
|
|
34
|
+
pickWord,
|
|
35
|
+
replaceEdgeChars,
|
|
36
|
+
stripAnsi,
|
|
37
|
+
tokensPerSecond,
|
|
38
|
+
topBarSegments,
|
|
39
|
+
type ColorName,
|
|
40
|
+
type Colorize,
|
|
41
|
+
} from "./render.ts";
|
|
42
|
+
|
|
43
|
+
// Config lives next to the extension file: ./extensions/input-bar.json
|
|
44
|
+
const EXT_DIR = dirname(fileURLToPath(import.meta.url));
|
|
45
|
+
const CONFIG_PATH = join(EXT_DIR, "input-bar.json");
|
|
46
|
+
|
|
47
|
+
const DEFAULTS = {
|
|
48
|
+
TOP: true, // embed folder/branch/model/effort into the editor's top border
|
|
49
|
+
BOTTOM: true, // embed context/tokens/tps into the editor's bottom border
|
|
50
|
+
TPS: true, // show tokens/sec
|
|
51
|
+
ANIM: true, // random working word while streaming
|
|
52
|
+
ICONS: true, // glyphs (⌂ ⎇ ⛁ ⚡) in the bars
|
|
53
|
+
WORDS: "", // comma-separated override for the working-word pool
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const cfg: typeof DEFAULTS = (() => {
|
|
57
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
58
|
+
try {
|
|
59
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(DEFAULTS, null, 2) + "\n", "utf-8");
|
|
60
|
+
} catch {
|
|
61
|
+
// Read-only install location — keep defaults in memory
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
return { ...DEFAULTS, ...JSON.parse(readFileSync(CONFIG_PATH, "utf-8")) };
|
|
66
|
+
} catch {
|
|
67
|
+
return { ...DEFAULTS };
|
|
68
|
+
}
|
|
69
|
+
})();
|
|
70
|
+
|
|
71
|
+
function wordPool(): string[] {
|
|
72
|
+
const custom = cfg.WORDS.split(",").map((w) => w.trim()).filter(Boolean);
|
|
73
|
+
return custom.length ? custom : DEFAULT_WORDS;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Fallback branch resolution for when the FooterDataProvider isn't captured yet
|
|
77
|
+
// (it only reaches us through the setFooter factory). Cached with a short TTL.
|
|
78
|
+
let branchCache: { value: string | null; at: number } = { value: null, at: 0 };
|
|
79
|
+
|
|
80
|
+
function gitBranchFallback(cwd: string): string | null {
|
|
81
|
+
if (Date.now() - branchCache.at < 5000) return branchCache.value;
|
|
82
|
+
let value: string | null = null;
|
|
83
|
+
try {
|
|
84
|
+
let dir = cwd;
|
|
85
|
+
for (;;) {
|
|
86
|
+
const gitPath = join(dir, ".git");
|
|
87
|
+
if (existsSync(gitPath)) {
|
|
88
|
+
let headPath = join(gitPath, "HEAD");
|
|
89
|
+
if (statSync(gitPath).isFile()) {
|
|
90
|
+
// Worktree/submodule: .git is a file pointing at the real git dir
|
|
91
|
+
const m = readFileSync(gitPath, "utf-8").match(/^gitdir:\s*(.+)$/m);
|
|
92
|
+
if (m) headPath = join(m[1].trim(), "HEAD");
|
|
93
|
+
}
|
|
94
|
+
const head = readFileSync(headPath, "utf-8").trim();
|
|
95
|
+
const ref = head.match(/^ref:\s*refs\/heads\/(.+)$/);
|
|
96
|
+
value = ref ? ref[1] : "detached";
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
const parent = dirname(dir);
|
|
100
|
+
if (parent === dir) break;
|
|
101
|
+
dir = parent;
|
|
102
|
+
}
|
|
103
|
+
} catch {
|
|
104
|
+
value = null;
|
|
105
|
+
}
|
|
106
|
+
branchCache = { value, at: Date.now() };
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Total characters of text + thinking in an assistant message. */
|
|
111
|
+
function messageChars(message: any): number {
|
|
112
|
+
let chars = 0;
|
|
113
|
+
for (const block of message?.content ?? []) {
|
|
114
|
+
if (typeof block?.text === "string") chars += block.text.length;
|
|
115
|
+
else if (typeof block?.thinking === "string") chars += block.thinking.length;
|
|
116
|
+
}
|
|
117
|
+
return chars;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export default function (pi: ExtensionAPI) {
|
|
121
|
+
// Latest context seen by any handler — its methods are stable runtime bindings.
|
|
122
|
+
let ctxRef: ExtensionContext | undefined;
|
|
123
|
+
// FooterDataProvider captured from the setFooter factory; the editor reads it too.
|
|
124
|
+
let footerData: { getGitBranch(): string | null } | undefined;
|
|
125
|
+
|
|
126
|
+
// t/s tracking. The clock starts at the FIRST streamed token, not at
|
|
127
|
+
// message_start — otherwise prompt-processing time (long on local servers)
|
|
128
|
+
// dilutes the average and the value creeps up without ever reaching the
|
|
129
|
+
// real generation speed.
|
|
130
|
+
let streaming = false;
|
|
131
|
+
let messageStartMs = 0;
|
|
132
|
+
let firstTokenMs = 0;
|
|
133
|
+
let liveChars = 0;
|
|
134
|
+
let lastTps: number | undefined;
|
|
135
|
+
// Prefill (prompt processing) speed: tokens processed / time until first token
|
|
136
|
+
let lastPps: number | undefined;
|
|
137
|
+
// Live-estimate calibration: learned chars/token ratio from finished messages
|
|
138
|
+
let charsPerToken = 4;
|
|
139
|
+
|
|
140
|
+
function snapshotTps(): { tps: number | undefined; live: boolean } {
|
|
141
|
+
if (streaming && firstTokenMs) {
|
|
142
|
+
const est = tokensPerSecond(liveChars / charsPerToken, Date.now() - firstTokenMs);
|
|
143
|
+
if (est !== undefined) return { tps: est, live: true };
|
|
144
|
+
}
|
|
145
|
+
return { tps: lastTps, live: false };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function makeColorize(theme: { fg(color: any, text: string): string }, effortLevel: string): Colorize {
|
|
149
|
+
const thinkingKey = "thinking" + effortLevel.charAt(0).toUpperCase() + effortLevel.slice(1);
|
|
150
|
+
return (color: ColorName, text: string) =>
|
|
151
|
+
theme.fg(color === "thinking" ? thinkingKey : color, text);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function currentEffort(): string {
|
|
155
|
+
try {
|
|
156
|
+
return pi.getThinkingLevel();
|
|
157
|
+
} catch {
|
|
158
|
+
return "off";
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Top border line with folder/branch/model/effort embedded. */
|
|
163
|
+
function topLine(width: number, theme: any): string {
|
|
164
|
+
const ctx = ctxRef;
|
|
165
|
+
const effort = currentEffort();
|
|
166
|
+
const { left, right } = topBarSegments({
|
|
167
|
+
folder: basename(ctx?.cwd || process.cwd()) || "/",
|
|
168
|
+
branch: footerData?.getGitBranch() ?? gitBranchFallback(ctx?.cwd || process.cwd()),
|
|
169
|
+
streaming,
|
|
170
|
+
provider: ctx?.model?.provider,
|
|
171
|
+
modelId: ctx?.model?.id,
|
|
172
|
+
effort: ctx?.model?.reasoning ? effort : undefined,
|
|
173
|
+
icons: cfg.ICONS,
|
|
174
|
+
});
|
|
175
|
+
const line = composeLine(left, right, width, makeColorize(theme, effort), tuiVisibleWidth);
|
|
176
|
+
return truncateToWidth(line, width);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Bottom border line with context/token/tps stats embedded, right-aligned. */
|
|
180
|
+
function bottomLine(width: number, theme: any): string {
|
|
181
|
+
const ctx = ctxRef;
|
|
182
|
+
let input = 0, output = 0, cacheRead = 0, cacheWrite = 0, cost = 0;
|
|
183
|
+
try {
|
|
184
|
+
for (const entry of ctx?.sessionManager.getEntries() ?? []) {
|
|
185
|
+
if (entry.type === "message" && (entry as any).message.role === "assistant") {
|
|
186
|
+
const u = (entry as any).message.usage;
|
|
187
|
+
input += u.input;
|
|
188
|
+
output += u.output;
|
|
189
|
+
cacheRead += u.cacheRead;
|
|
190
|
+
cacheWrite += u.cacheWrite;
|
|
191
|
+
cost += u.cost.total;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
} catch {
|
|
195
|
+
// Session shape changed — leave totals at 0
|
|
196
|
+
}
|
|
197
|
+
const usage = ctx?.getContextUsage();
|
|
198
|
+
const { tps, live } = snapshotTps();
|
|
199
|
+
const segs = bottomBarSegments({
|
|
200
|
+
percent: usage?.percent ?? null,
|
|
201
|
+
contextWindow: usage?.contextWindow ?? ctx?.model?.contextWindow ?? 0,
|
|
202
|
+
input, output, cacheRead, cacheWrite, cost,
|
|
203
|
+
tps: cfg.TPS ? tps : undefined,
|
|
204
|
+
tpsLive: live,
|
|
205
|
+
pps: cfg.TPS ? lastPps : undefined,
|
|
206
|
+
icons: cfg.ICONS,
|
|
207
|
+
});
|
|
208
|
+
const line = composeBottomLine(segs, width, makeColorize(theme, currentEffort()), tuiVisibleWidth);
|
|
209
|
+
return truncateToWidth(line, width);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Editor that rewrites its full-rule border lines with the info bars.
|
|
214
|
+
* With both bars active the box is closed: rounded corners on the border
|
|
215
|
+
* lines and │ sides painted over the paddingX columns of content lines.
|
|
216
|
+
*/
|
|
217
|
+
class BarEditor extends CustomEditor {
|
|
218
|
+
/**
|
|
219
|
+
* pi copies the default editor's paddingX onto custom editors right after
|
|
220
|
+
* the factory runs (setCustomEditorComponent), which would collapse our
|
|
221
|
+
* side columns to the user's setting (often 0). Enforce a minimum of 2 so
|
|
222
|
+
* the │ sides always have a spare space column next to the content/cursor.
|
|
223
|
+
*/
|
|
224
|
+
setPaddingX(padding: number): void {
|
|
225
|
+
super.setPaddingX(Math.max(2, Number.isFinite(padding) ? padding : 2));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
render(width: number): string[] {
|
|
229
|
+
const lines = super.render(width);
|
|
230
|
+
const theme = ctxRef?.ui?.theme;
|
|
231
|
+
if (!theme || width < 10) return lines;
|
|
232
|
+
const rule = "─".repeat(width);
|
|
233
|
+
const isRule = (l: string) => stripAnsi(l) === rule;
|
|
234
|
+
// Top border is the first full-rule line (absent while scrolled — the
|
|
235
|
+
// "↑ N more" indicator takes its place and is left untouched).
|
|
236
|
+
const topIdx = cfg.TOP ? lines.findIndex(isRule) : -1;
|
|
237
|
+
// Bottom border is the last full-rule line (autocomplete rows follow it).
|
|
238
|
+
let bottomIdx = -1;
|
|
239
|
+
if (cfg.BOTTOM) {
|
|
240
|
+
for (let i = lines.length - 1; i > topIdx; i--) {
|
|
241
|
+
if (isRule(lines[i])) {
|
|
242
|
+
bottomIdx = i;
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const closed = topIdx !== -1 && bottomIdx !== -1;
|
|
248
|
+
if (topIdx !== -1) {
|
|
249
|
+
const line = topLine(width, theme);
|
|
250
|
+
lines[topIdx] = closed ? replaceEdgeChars(line, "╭", "╮") : line;
|
|
251
|
+
}
|
|
252
|
+
if (bottomIdx !== -1) {
|
|
253
|
+
const line = bottomLine(width, theme);
|
|
254
|
+
lines[bottomIdx] = closed ? replaceEdgeChars(line, "╰", "╯") : line;
|
|
255
|
+
}
|
|
256
|
+
if (closed) {
|
|
257
|
+
// Paint │ over the outermost padding column of the rows in between.
|
|
258
|
+
const side = theme.fg("dim", "│");
|
|
259
|
+
for (let i = topIdx + 1; i < bottomIdx; i++) {
|
|
260
|
+
const l = lines[i];
|
|
261
|
+
if (l.startsWith(" ") && l.endsWith(" ")) {
|
|
262
|
+
lines[i] = side + l.slice(1, -1) + side;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return lines;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function installBars(ctx: ExtensionContext) {
|
|
271
|
+
if (cfg.TOP || cfg.BOTTOM) {
|
|
272
|
+
// paddingX: 2 keeps a spare space column on each side of the content so
|
|
273
|
+
// the │ sides can be painted without touching text or the cursor cell.
|
|
274
|
+
ctx.ui.setEditorComponent(
|
|
275
|
+
(tui, theme, keybindings) => new BarEditor(tui, theme, keybindings, { paddingX: 2 }),
|
|
276
|
+
);
|
|
277
|
+
} else {
|
|
278
|
+
ctx.ui.setEditorComponent(undefined);
|
|
279
|
+
}
|
|
280
|
+
if (cfg.BOTTOM) {
|
|
281
|
+
// Minimal footer: kills the built-in stats lines (now embedded in the
|
|
282
|
+
// editor border) but keeps extension statuses and the branch watcher.
|
|
283
|
+
ctx.ui.setFooter((tui, theme, fd) => {
|
|
284
|
+
footerData = fd;
|
|
285
|
+
const unsub = fd.onBranchChange(() => tui.requestRender());
|
|
286
|
+
return {
|
|
287
|
+
render(width: number): string[] {
|
|
288
|
+
const statuses: ReadonlyMap<string, string> = fd.getExtensionStatuses();
|
|
289
|
+
if (statuses.size === 0) return [];
|
|
290
|
+
const line = Array.from(statuses.entries())
|
|
291
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
292
|
+
.map(([, text]) => text.replace(/[\r\n\t]/g, " ").trim())
|
|
293
|
+
.join(" ");
|
|
294
|
+
return [truncateToWidth(theme.fg("dim", line), width)];
|
|
295
|
+
},
|
|
296
|
+
invalidate() {},
|
|
297
|
+
dispose() { unsub(); },
|
|
298
|
+
};
|
|
299
|
+
});
|
|
300
|
+
} else {
|
|
301
|
+
ctx.ui.setFooter(undefined);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
pi.on("session_start", (_event, ctx) => {
|
|
306
|
+
ctxRef = ctx;
|
|
307
|
+
if (ctx.mode !== "tui") return;
|
|
308
|
+
installBars(ctx);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
pi.on("agent_start", (_event, ctx) => {
|
|
312
|
+
ctxRef = ctx;
|
|
313
|
+
if (ctx.hasUI && cfg.ANIM) {
|
|
314
|
+
ctx.ui.setWorkingMessage(pickWord(wordPool()));
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
pi.on("message_start", (event, ctx) => {
|
|
319
|
+
ctxRef = ctx;
|
|
320
|
+
if (event.message.role !== "assistant") return;
|
|
321
|
+
streaming = true;
|
|
322
|
+
messageStartMs = Date.now();
|
|
323
|
+
firstTokenMs = 0;
|
|
324
|
+
liveChars = 0;
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
pi.on("message_update", (event) => {
|
|
328
|
+
if (event.message.role !== "assistant") return;
|
|
329
|
+
const chars = messageChars(event.message);
|
|
330
|
+
if (!firstTokenMs && chars > 0) firstTokenMs = Date.now();
|
|
331
|
+
liveChars = chars;
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
pi.on("message_end", (event, ctx) => {
|
|
335
|
+
ctxRef = ctx;
|
|
336
|
+
if (event.message.role !== "assistant") return;
|
|
337
|
+
streaming = false;
|
|
338
|
+
if (!firstTokenMs) return;
|
|
339
|
+
const usage = (event.message as any).usage;
|
|
340
|
+
const outTokens = usage?.output ?? 0;
|
|
341
|
+
if (outTokens > 0 && liveChars > 0) {
|
|
342
|
+
charsPerToken = Math.min(8, Math.max(1, liveChars / outTokens));
|
|
343
|
+
}
|
|
344
|
+
const tps = tokensPerSecond(outTokens, Date.now() - firstTokenMs);
|
|
345
|
+
if (tps !== undefined) lastTps = tps;
|
|
346
|
+
// Prefill: prompt tokens actually processed (cached reads cost ~nothing)
|
|
347
|
+
// over the message_start → first-token window. Skip tiny windows where
|
|
348
|
+
// network/server overhead dominates the measurement.
|
|
349
|
+
const prefillTokens = (usage?.input ?? 0) + (usage?.cacheWrite ?? 0);
|
|
350
|
+
const prefillMs = firstTokenMs - messageStartMs;
|
|
351
|
+
if (prefillTokens > 0 && prefillMs > 150) {
|
|
352
|
+
lastPps = prefillTokens / (prefillMs / 1000);
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
pi.on("agent_end", (_event, ctx) => {
|
|
357
|
+
ctxRef = ctx;
|
|
358
|
+
streaming = false;
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
pi.registerCommand("input-bar", {
|
|
362
|
+
description: "Configure the input bar (show | set KEY=VAL | save | reset)",
|
|
363
|
+
handler: async (args, ctx) => {
|
|
364
|
+
ctxRef = ctx;
|
|
365
|
+
const trimmed = args.trim();
|
|
366
|
+
const notify = (msg: string) => ctx.ui.notify(msg, "info");
|
|
367
|
+
|
|
368
|
+
if (!trimmed || trimmed === "show") {
|
|
369
|
+
notify(
|
|
370
|
+
Object.entries(cfg)
|
|
371
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
372
|
+
.join(" "),
|
|
373
|
+
);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (trimmed === "save") {
|
|
377
|
+
try {
|
|
378
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
|
|
379
|
+
notify(`saved ${CONFIG_PATH}`);
|
|
380
|
+
} catch (e) {
|
|
381
|
+
ctx.ui.notify(`could not save: ${e}`, "error");
|
|
382
|
+
}
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
if (trimmed === "reset") {
|
|
386
|
+
Object.assign(cfg, DEFAULTS);
|
|
387
|
+
installBars(ctx);
|
|
388
|
+
notify("input-bar config reset (session only; use save to persist)");
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
const match = trimmed.match(/^set\s+([A-Z]+)\s*=\s*(.*)$/);
|
|
392
|
+
if (!match) {
|
|
393
|
+
ctx.ui.notify("usage: /input-bar [show | set KEY=VAL | save | reset]", "warning");
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
const [, key, raw] = match;
|
|
397
|
+
if (!(key in cfg)) {
|
|
398
|
+
ctx.ui.notify(`unknown key ${key} (${Object.keys(cfg).join(", ")})`, "error");
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
(cfg as any)[key] = typeof (cfg as any)[key] === "boolean" ? raw === "true" || raw === "1" : raw;
|
|
402
|
+
installBars(ctx);
|
|
403
|
+
if (key === "ANIM" && !cfg.ANIM) ctx.ui.setWorkingMessage();
|
|
404
|
+
notify(`${key}=${(cfg as any)[key]} (session only; use /input-bar save to persist)`);
|
|
405
|
+
},
|
|
406
|
+
});
|
|
407
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure rendering logic for pi-input-bar. No pi imports so test.mjs can import
|
|
3
|
+
* this file directly (Node ≥ 22.18 type stripping).
|
|
4
|
+
*
|
|
5
|
+
* Lines are built from plain-text segments with a color name attached; the
|
|
6
|
+
* extension maps color names to theme.fg() calls. Width math always runs on
|
|
7
|
+
* the plain text, so ANSI codes never break the layout.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type ColorName =
|
|
11
|
+
| "dim"
|
|
12
|
+
| "muted"
|
|
13
|
+
| "text"
|
|
14
|
+
| "accent"
|
|
15
|
+
| "success"
|
|
16
|
+
| "warning"
|
|
17
|
+
| "error"
|
|
18
|
+
| "thinking";
|
|
19
|
+
|
|
20
|
+
export type Colorize = (color: ColorName, text: string) => string;
|
|
21
|
+
|
|
22
|
+
export interface Segment {
|
|
23
|
+
text: string;
|
|
24
|
+
color: ColorName;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Format token counts for compact display (same semantics as pi's footer). */
|
|
28
|
+
export function formatTokens(count: number): string {
|
|
29
|
+
if (count < 1000) return count.toString();
|
|
30
|
+
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
31
|
+
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
|
32
|
+
if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
|
|
33
|
+
return `${Math.round(count / 1000000)}M`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
37
|
+
|
|
38
|
+
export function stripAnsi(s: string): string {
|
|
39
|
+
return s.replace(ANSI_RE, "");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Approximate terminal cell width of a single code point. */
|
|
43
|
+
function charWidth(cp: number): number {
|
|
44
|
+
// Zero-width: combining marks, ZWJ/ZWNJ, variation selectors
|
|
45
|
+
if (
|
|
46
|
+
(cp >= 0x0300 && cp <= 0x036f) ||
|
|
47
|
+
cp === 0x200b ||
|
|
48
|
+
cp === 0x200c ||
|
|
49
|
+
cp === 0x200d ||
|
|
50
|
+
(cp >= 0xfe00 && cp <= 0xfe0f)
|
|
51
|
+
) {
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
// Wide: CJK, Hangul, fullwidth forms, emoji blocks
|
|
55
|
+
if (
|
|
56
|
+
(cp >= 0x1100 && cp <= 0x115f) ||
|
|
57
|
+
(cp >= 0x2e80 && cp <= 0xa4cf) ||
|
|
58
|
+
(cp >= 0xac00 && cp <= 0xd7a3) ||
|
|
59
|
+
(cp >= 0xf900 && cp <= 0xfaff) ||
|
|
60
|
+
(cp >= 0xff00 && cp <= 0xff60) ||
|
|
61
|
+
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
|
62
|
+
(cp >= 0x1f300 && cp <= 0x1faff) ||
|
|
63
|
+
(cp >= 0x20000 && cp <= 0x3fffd)
|
|
64
|
+
) {
|
|
65
|
+
return 2;
|
|
66
|
+
}
|
|
67
|
+
return 1;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Visible terminal width of a string (ANSI stripped, wide chars counted as 2). */
|
|
71
|
+
export function visibleWidth(s: string): number {
|
|
72
|
+
let w = 0;
|
|
73
|
+
for (const ch of stripAnsi(s)) w += charWidth(ch.codePointAt(0)!);
|
|
74
|
+
return w;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Width-measuring function; pi-tui's visibleWidth is injected at runtime. */
|
|
78
|
+
export type WidthFn = (s: string) => number;
|
|
79
|
+
|
|
80
|
+
function segsWidth(segs: Segment[], widthFn: WidthFn): number {
|
|
81
|
+
let w = 0;
|
|
82
|
+
for (const s of segs) w += widthFn(s.text);
|
|
83
|
+
return w;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function paint(segs: Segment[], c: Colorize): string {
|
|
87
|
+
return segs.map((s) => c(s.color, s.text)).join("");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Compose one line: left segments, a dim "─" filler, right segments.
|
|
92
|
+
* If both sides don't fit, the right side is dropped; if the left alone
|
|
93
|
+
* doesn't fit, its last segments are dropped until it does.
|
|
94
|
+
*/
|
|
95
|
+
export function composeLine(
|
|
96
|
+
left: Segment[],
|
|
97
|
+
right: Segment[],
|
|
98
|
+
width: number,
|
|
99
|
+
c: Colorize,
|
|
100
|
+
widthFn: WidthFn = visibleWidth,
|
|
101
|
+
): string {
|
|
102
|
+
const l = [...left];
|
|
103
|
+
let r = [...right];
|
|
104
|
+
if (segsWidth(l, widthFn) + segsWidth(r, widthFn) + 2 > width) r = [];
|
|
105
|
+
while (l.length > 1 && segsWidth(l, widthFn) + segsWidth(r, widthFn) + 2 > width) l.pop();
|
|
106
|
+
const gap = Math.max(1, width - segsWidth(l, widthFn) - segsWidth(r, widthFn));
|
|
107
|
+
return paint(l, c) + c("dim", "─".repeat(gap)) + paint(r, c);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface TopBarData {
|
|
111
|
+
folder: string;
|
|
112
|
+
branch: string | null;
|
|
113
|
+
streaming: boolean;
|
|
114
|
+
provider: string | undefined;
|
|
115
|
+
modelId: string | undefined;
|
|
116
|
+
/** Thinking level, or undefined when the model has no reasoning. */
|
|
117
|
+
effort: string | undefined;
|
|
118
|
+
icons: boolean;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function topBarSegments(d: TopBarData): { left: Segment[]; right: Segment[] } {
|
|
122
|
+
const sep: Segment = { text: " :: ", color: "dim" };
|
|
123
|
+
const left: Segment[] = [
|
|
124
|
+
{ text: "──", color: "dim" },
|
|
125
|
+
{ text: d.icons ? " ⌂ " : " ", color: "accent" },
|
|
126
|
+
{ text: d.folder, color: "accent" },
|
|
127
|
+
];
|
|
128
|
+
if (d.branch) {
|
|
129
|
+
left.push(sep, { text: d.icons ? "⎇ " : "", color: "success" }, { text: d.branch, color: "success" });
|
|
130
|
+
}
|
|
131
|
+
left.push({ text: d.streaming ? " ● " : " ○ ", color: d.streaming ? "warning" : "dim" });
|
|
132
|
+
|
|
133
|
+
const right: Segment[] = [];
|
|
134
|
+
if (d.modelId) {
|
|
135
|
+
const model = d.provider ? `${d.provider}/${d.modelId}` : d.modelId;
|
|
136
|
+
right.push({ text: ` ${model}`, color: "success" });
|
|
137
|
+
if (d.effort) {
|
|
138
|
+
right.push(sep, { text: (d.icons ? "✦ " : "") + d.effort, color: "thinking" });
|
|
139
|
+
}
|
|
140
|
+
right.push({ text: " ──", color: "dim" });
|
|
141
|
+
}
|
|
142
|
+
return { left, right };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface BottomBarData {
|
|
146
|
+
/** Context usage percent, or null when unknown (right after compaction). */
|
|
147
|
+
percent: number | null;
|
|
148
|
+
contextWindow: number;
|
|
149
|
+
input: number;
|
|
150
|
+
output: number;
|
|
151
|
+
cacheRead: number;
|
|
152
|
+
cacheWrite: number;
|
|
153
|
+
cost: number;
|
|
154
|
+
/** Generation tokens per second, or undefined when not yet measured. */
|
|
155
|
+
tps: number | undefined;
|
|
156
|
+
/** True while the tps value is a live in-stream estimate. */
|
|
157
|
+
tpsLive: boolean;
|
|
158
|
+
/** Prefill (prompt processing) tokens per second, or undefined when unknown. */
|
|
159
|
+
pps: number | undefined;
|
|
160
|
+
icons: boolean;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function formatRate(rate: number): string {
|
|
164
|
+
return rate >= 100 ? rate.toFixed(0) : rate.toFixed(1);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function bottomBarSegments(d: BottomBarData): Segment[] {
|
|
168
|
+
const sep: Segment = { text: " :: ", color: "dim" };
|
|
169
|
+
const pctColor: ColorName = d.percent === null ? "muted" : d.percent > 90 ? "error" : d.percent > 70 ? "warning" : "success";
|
|
170
|
+
const pct = d.percent === null ? "?" : `${d.percent.toFixed(0)}%`;
|
|
171
|
+
const segs: Segment[] = [
|
|
172
|
+
{ text: d.icons ? " ⛁ " : " ", color: pctColor },
|
|
173
|
+
{ text: `${pct}/${formatTokens(d.contextWindow)}`, color: pctColor },
|
|
174
|
+
];
|
|
175
|
+
|
|
176
|
+
const stats: string[] = [];
|
|
177
|
+
if (d.input) stats.push(`↑${formatTokens(d.input)}`);
|
|
178
|
+
if (d.output) stats.push(`↓${formatTokens(d.output)}`);
|
|
179
|
+
if (d.cacheRead) stats.push(`R${formatTokens(d.cacheRead)}`);
|
|
180
|
+
if (d.cacheWrite) stats.push(`W${formatTokens(d.cacheWrite)}`);
|
|
181
|
+
if (stats.length) segs.push(sep, { text: stats.join(" "), color: "muted" });
|
|
182
|
+
|
|
183
|
+
if (d.cost) segs.push(sep, { text: `$${d.cost.toFixed(3)}`, color: "muted" });
|
|
184
|
+
|
|
185
|
+
if (d.tps !== undefined || d.pps !== undefined) {
|
|
186
|
+
// With both rates, label them llama.cpp-style (pp = prefill, tg = generation)
|
|
187
|
+
const parts: string[] = [];
|
|
188
|
+
if (d.pps !== undefined) parts.push(`pp ${formatRate(d.pps)}`);
|
|
189
|
+
if (d.tps !== undefined) {
|
|
190
|
+
const tg = `${formatRate(d.tps)}${d.tpsLive ? "~" : ""}`;
|
|
191
|
+
parts.push(d.pps !== undefined ? `tg ${tg}` : tg);
|
|
192
|
+
}
|
|
193
|
+
segs.push(sep, {
|
|
194
|
+
text: `${d.icons ? "⚡" : ""}${parts.join(" · ")} t/s`,
|
|
195
|
+
color: d.tpsLive ? "warning" : "muted",
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
segs.push({ text: " ──", color: "dim" });
|
|
199
|
+
return segs;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Right-aligned bottom line: dim filler, then the stats block. */
|
|
203
|
+
export function composeBottomLine(
|
|
204
|
+
segs: Segment[],
|
|
205
|
+
width: number,
|
|
206
|
+
c: Colorize,
|
|
207
|
+
widthFn: WidthFn = visibleWidth,
|
|
208
|
+
): string {
|
|
209
|
+
const s = [...segs];
|
|
210
|
+
// Drop middle segments (keep context% and trailing space) until it fits.
|
|
211
|
+
while (s.length > 2 && segsWidth(s, widthFn) + 1 > width) s.splice(2, 1);
|
|
212
|
+
const gap = Math.max(1, width - segsWidth(s, widthFn));
|
|
213
|
+
return c("dim", "─".repeat(gap)) + paint(s, c);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Replace the first and last visible characters of a line (skipping ANSI
|
|
218
|
+
* codes) — used to turn flat border lines into box corners (╭─…─╮ / ╰─…─╯).
|
|
219
|
+
* Assumes the replacements have the same cell width as the replaced chars.
|
|
220
|
+
*/
|
|
221
|
+
export function replaceEdgeChars(line: string, open: string, close: string): string {
|
|
222
|
+
const tokens = line.split(/(\x1b\[[0-9;]*m)/);
|
|
223
|
+
let first = -1;
|
|
224
|
+
let last = -1;
|
|
225
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
226
|
+
if (tokens[i].length === 0 || tokens[i].startsWith("\x1b")) continue;
|
|
227
|
+
if (first === -1) first = i;
|
|
228
|
+
last = i;
|
|
229
|
+
}
|
|
230
|
+
if (first === -1) return line;
|
|
231
|
+
const firstChars = [...tokens[first]];
|
|
232
|
+
firstChars[0] = open;
|
|
233
|
+
tokens[first] = firstChars.join("");
|
|
234
|
+
// When first === last this reads the already-updated token, which is intended
|
|
235
|
+
const lastChars = [...tokens[last]];
|
|
236
|
+
lastChars[lastChars.length - 1] = close;
|
|
237
|
+
tokens[last] = lastChars.join("");
|
|
238
|
+
return tokens.join("");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Working-word pool, Claude Code style. */
|
|
242
|
+
export const DEFAULT_WORDS = [
|
|
243
|
+
"Thinking", "Pondering", "Percolating", "Brewing", "Conjuring", "Scheming",
|
|
244
|
+
"Marinating", "Ruminating", "Noodling", "Cogitating", "Simmering", "Musing",
|
|
245
|
+
"Tinkering", "Weaving", "Distilling", "Incubating", "Hatching", "Whirring",
|
|
246
|
+
"Crunching", "Divining", "Untangling", "Spelunking", "Wrangling", "Vibing",
|
|
247
|
+
];
|
|
248
|
+
|
|
249
|
+
/** Pick a random word; rnd injectable for tests. */
|
|
250
|
+
export function pickWord(words: readonly string[], rnd: () => number = Math.random): string {
|
|
251
|
+
if (words.length === 0) return "Thinking";
|
|
252
|
+
return words[Math.min(words.length - 1, Math.floor(rnd() * words.length))];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Estimate tokens from a character count (~4 chars/token). */
|
|
256
|
+
export function estimateTokens(chars: number): number {
|
|
257
|
+
return chars / 4;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Compute tokens/sec; undefined when the sample is too small to be meaningful. */
|
|
261
|
+
export function tokensPerSecond(tokens: number, elapsedMs: number): number | undefined {
|
|
262
|
+
if (tokens <= 0 || elapsedMs < 500) return undefined;
|
|
263
|
+
return tokens / (elapsedMs / 1000);
|
|
264
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-input-bar",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Pi extension: decorated input bar — folder/branch/model/effort above the editor, context/token usage and tokens-per-second below, plus random working words while the agent streams.",
|
|
5
|
+
"keywords": ["pi-package", "pi", "pi-coding-agent", "extension", "tui", "statusbar", "footer", "input-bar"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"homepage": "https://github.com/sebaxzero/pi-input-bar",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/sebaxzero/pi-input-bar.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/sebaxzero/pi-input-bar/issues"
|
|
15
|
+
},
|
|
16
|
+
"pi": {
|
|
17
|
+
"extensions": ["./extensions"]
|
|
18
|
+
}
|
|
19
|
+
}
|