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 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
+ [![npm](https://img.shields.io/npm/v/pi-input-bar)](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
+ }