@tmustier/pi-session-hud 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/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - 2026-02-07
4
+
5
+ Initial release.
6
+
7
+ - Persistent session HUD widget placed **below the editor**
8
+ - Shows activity state, session/cwd, git branch + diff stats, context usage, model + thinking level
9
+ - `/hud` command (with `/status` and `/header` aliases)
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thomas Mustier
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,56 @@
1
+ # /hud — Session HUD
2
+
3
+ A Pi extension that adds a persistent heads-up display **below the editor** (above Pi’s built-in footer).
4
+
5
+ Shows (in a compact, color-coded bar):
6
+ - Activity state (idle / running / tool / error / stale)
7
+ - Session name (or cwd) and first user message fallback
8
+ - Git branch + working tree stats
9
+ - Context usage (% + tokens)
10
+ - Current model (+ thinking level)
11
+
12
+ ## Install
13
+
14
+ ### Pi package manager (npm)
15
+
16
+ ```bash
17
+ pi install npm:@tmustier/pi-session-hud
18
+ ```
19
+
20
+ ### Pi package manager (git)
21
+
22
+ ```bash
23
+ pi install git:github.com/tmustier/pi-session-hud
24
+ ```
25
+
26
+ ### Local clone
27
+
28
+ Symlink into Pi’s auto-discovered extensions directory:
29
+
30
+ ```bash
31
+ ln -s ~/pi-session-hud/pi-session-hud.ts ~/.pi/agent/extensions/
32
+ ```
33
+
34
+ Or add to `~/.pi/agent/settings.json`:
35
+
36
+ ```json
37
+ {
38
+ "extensions": ["~/pi-session-hud/pi-session-hud.ts"]
39
+ }
40
+ ```
41
+
42
+ ## Usage
43
+
44
+ In Pi:
45
+
46
+ - Toggle HUD: `/hud`
47
+ - Aliases: `/status`, `/header`
48
+
49
+ ## Notes
50
+
51
+ - Uses `ctx.ui.setWidget(..., { placement: "belowEditor" })` so it’s not the footer and not a header.
52
+ - Git stats are refreshed every ~10s via `git diff --stat HEAD` + `git status --porcelain`.
53
+
54
+ ## Changelog
55
+
56
+ See `CHANGELOG.md`.
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@tmustier/pi-session-hud",
3
+ "version": "0.1.0",
4
+ "description": "Persistent session HUD widget for Pi (below-editor bar with git/context/model/activity).",
5
+ "license": "MIT",
6
+ "author": "Thomas Mustier",
7
+ "keywords": [
8
+ "pi-package"
9
+ ],
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/tmustier/pi-session-hud.git"
13
+ },
14
+ "bugs": "https://github.com/tmustier/pi-session-hud/issues",
15
+ "homepage": "https://github.com/tmustier/pi-session-hud",
16
+ "files": [
17
+ "pi-session-hud.ts",
18
+ "README.md",
19
+ "LICENSE",
20
+ "CHANGELOG.md"
21
+ ],
22
+ "pi": {
23
+ "extensions": [
24
+ "pi-session-hud.ts"
25
+ ]
26
+ }
27
+ }
@@ -0,0 +1,437 @@
1
+ /**
2
+ * Pi Session HUD — Persistent session heads-up display below the editor.
3
+ *
4
+ * Shows:
5
+ * - Activity status with color-coded background (idle/running/tool/error/stale)
6
+ * - Session name or cwd basename
7
+ * - Git branch + working tree stats (+added/-removed)
8
+ * - Context usage bar (% + token count)
9
+ * - Current model
10
+ *
11
+ * Install:
12
+ * - pi install npm:@tmustier/pi-session-hud
13
+ * - or copy/symlink pi-session-hud.ts into ~/.pi/agent/extensions/
14
+ * Toggle: /hud (aliases: /status, /header)
15
+ */
16
+
17
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
18
+ import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
19
+
20
+ // ── Types ────────────────────────────────────────────────────
21
+
22
+ type Status = "idle" | "running" | "tool" | "error" | "stale";
23
+
24
+ const WIDGET_ID = "pi-session-hud";
25
+ const LEGACY_WIDGET_ID = "pi-status-bar";
26
+
27
+ // ── Color Palette (raw ANSI truecolor) ───────────────────────
28
+
29
+ const STATUS_BG: Record<Status, string> = {
30
+ idle: "\x1b[48;2;30;80;50m",
31
+ running: "\x1b[48;2;30;50;90m",
32
+ tool: "\x1b[48;2;90;75;20m",
33
+ error: "\x1b[48;2;100;30;30m",
34
+ stale: "\x1b[48;2;90;60;15m",
35
+ };
36
+
37
+ const STATUS_FG: Record<Status, string> = {
38
+ idle: "\x1b[38;2;100;220;140m",
39
+ running: "\x1b[38;2;100;160;255m",
40
+ tool: "\x1b[38;2;240;200;80m",
41
+ error: "\x1b[38;2;255;100;100m",
42
+ stale: "\x1b[38;2;255;170;60m",
43
+ };
44
+
45
+ const RESET = "\x1b[0m";
46
+
47
+ const BOLD = "\x1b[1m";
48
+ const BOLD_OFF = "\x1b[22m";
49
+
50
+ const ITALIC_ON = "\x1b[3m";
51
+ const ITALIC_OFF = "\x1b[23m";
52
+
53
+ // Reset foreground only (keep background)
54
+ const FG_RESET = "\x1b[39m";
55
+
56
+ const FG_WHITE = "\x1b[38;2;220;220;220m";
57
+ const FG_MUTED = "\x1b[38;2;140;140;140m";
58
+ const FG_DIM = "\x1b[38;2;90;90;90m";
59
+
60
+ const STATUS_ICON: Record<Status, string> = {
61
+ idle: "●",
62
+ running: "◉",
63
+ tool: "⚙",
64
+ error: "✗",
65
+ stale: "⏳",
66
+ };
67
+
68
+ const STATUS_LABEL: Record<Status, string> = {
69
+ idle: "IDLE",
70
+ running: "RUN",
71
+ tool: "TOOL",
72
+ error: "ERR",
73
+ stale: "STALE",
74
+ };
75
+
76
+ // ── Helpers ──────────────────────────────────────────────────
77
+
78
+ function fmtTokens(n: number): string {
79
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
80
+ if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`;
81
+ return `${n}`;
82
+ }
83
+
84
+ function contextBar(percent: number, barWidth: number): string {
85
+ const filled = Math.round((percent / 100) * barWidth);
86
+ const empty = barWidth - filled;
87
+ let barFg: string;
88
+ if (percent < 25) barFg = "\x1b[38;2;100;200;120m"; // green — great
89
+ else if (percent < 40) barFg = "\x1b[38;2;180;210;100m"; // yellow-green — fine
90
+ else if (percent < 60) barFg = "\x1b[38;2;220;180;60m"; // amber — meh
91
+ else barFg = "\x1b[38;2;240;80;80m"; // red — bad
92
+ return `${barFg}${"█".repeat(filled)}${FG_DIM}${"░".repeat(empty)}`;
93
+ }
94
+
95
+ async function getGitStats(pi: ExtensionAPI): Promise<{ added: number; removed: number; dirty: boolean }> {
96
+ try {
97
+ const result = await pi.exec("git", ["diff", "--stat", "HEAD"], { timeout: 2000 });
98
+ if (result.code !== 0) return { added: 0, removed: 0, dirty: false };
99
+ let added = 0, removed = 0;
100
+ for (const line of result.stdout.split("\n")) {
101
+ const m1 = line.match(/(\d+) insertions?\(\+\)/);
102
+ const m2 = line.match(/(\d+) deletions?\(-\)/);
103
+ if (m1) added = parseInt(m1[1]!, 10);
104
+ if (m2) removed = parseInt(m2[1]!, 10);
105
+ }
106
+ const status = await pi.exec("git", ["status", "--porcelain"], { timeout: 2000 });
107
+ const dirty = status.code === 0 && status.stdout.trim().length > 0;
108
+ return { added, removed, dirty };
109
+ } catch {
110
+ return { added: 0, removed: 0, dirty: false };
111
+ }
112
+ }
113
+
114
+ // ── Extension ────────────────────────────────────────────────
115
+
116
+ export default function (pi: ExtensionAPI) {
117
+ let enabled = true;
118
+
119
+ let status: Status = "idle";
120
+ let toolName: string | undefined;
121
+ let toolStartTime: number | undefined;
122
+ let gitBranch: string | null = null;
123
+ let gitAdded = 0;
124
+ let gitRemoved = 0;
125
+ let gitDirty = false;
126
+ let contextPercent = 0;
127
+ let contextTokens = 0;
128
+ let contextWindow = 0;
129
+ let model = "";
130
+
131
+ let currentCtx: ExtensionContext | null = null;
132
+ let gitPollTimer: ReturnType<typeof setInterval> | null = null;
133
+ let staleTimer: ReturnType<typeof setTimeout> | null = null;
134
+ let staleInterval: ReturnType<typeof setInterval> | null = null;
135
+ let widgetTui: any = null;
136
+ let widgetTheme: any = null;
137
+ let firstUserText: string | null = null;
138
+
139
+ // ── Render ───────────────────────────────────────────────
140
+
141
+ function renderBar(width: number): string[] {
142
+ const bg = STATUS_BG[status];
143
+ const sFg = STATUS_FG[status];
144
+ const icon = STATUS_ICON[status];
145
+ let label = STATUS_LABEL[status];
146
+
147
+ if (status === "tool" && toolName) {
148
+ label = toolName;
149
+ }
150
+ if (status === "stale" && toolName) {
151
+ const elapsed = toolStartTime ? Math.floor((Date.now() - toolStartTime) / 1000) : 0;
152
+ label = `${toolName} ${elapsed}s`;
153
+ }
154
+
155
+ const sep = `${FG_DIM}│${FG_RESET}`;
156
+
157
+ const padBgLine = (prefixed: string): string => {
158
+ // truncateToWidth() injects a hard reset (\x1b[0m) before its ellipsis.
159
+ // That would clear our background. So ellipsis re-applies bg.
160
+ const ellipsis = `${bg}…${FG_RESET}`;
161
+ return truncateToWidth(prefixed, width, ellipsis, true) + RESET;
162
+ };
163
+
164
+ // ── Main line: directory (branch): name/message ──
165
+ const home = process.env.HOME || "";
166
+ const cwd = process.cwd();
167
+ let displayDir: string;
168
+ if (home && cwd.startsWith(home)) {
169
+ const rel = cwd.slice(home.length);
170
+ displayDir = rel ? `~${rel}` : "~";
171
+ } else {
172
+ displayDir = cwd;
173
+ }
174
+
175
+ const sessionName = pi.getSessionName();
176
+ const hasName = Boolean(sessionName && sessionName.trim());
177
+ const mainTextRaw = hasName
178
+ ? sessionName!.trim()
179
+ : (firstUserText && firstUserText.trim())
180
+ ? firstUserText.trim()
181
+ : "";
182
+
183
+ const mainTextStyled = hasName && widgetTheme
184
+ ? widgetTheme.fg("warning", mainTextRaw)
185
+ : widgetTheme
186
+ ? widgetTheme.italic(widgetTheme.fg("dim", mainTextRaw))
187
+ : `${FG_DIM}${ITALIC_ON}${mainTextRaw}${ITALIC_OFF}${FG_RESET}`;
188
+
189
+ const branchStyled = gitBranch
190
+ ? ` ${FG_MUTED}(${FG_WHITE}${gitBranch}${FG_MUTED})${FG_RESET}`
191
+ : "";
192
+
193
+ const mainInner =
194
+ ` ${FG_WHITE}${displayDir}${FG_RESET}` +
195
+ branchStyled +
196
+ `${FG_DIM}: ${FG_RESET}` +
197
+ `${mainTextStyled}${FG_RESET} `;
198
+
199
+ const lineMain = padBgLine(`${bg}${mainInner}`);
200
+
201
+ // ── Context line: context % ... │ model + thinking ──
202
+ const ctxParts: string[] = [];
203
+ const bar = contextBar(contextPercent, 6);
204
+ const pct = `${Math.round(contextPercent)}%`;
205
+ const tok = `${fmtTokens(contextTokens)}/${fmtTokens(contextWindow)}`;
206
+ ctxParts.push(` ${bar}${FG_RESET} ${FG_WHITE}${pct}${FG_RESET} ${FG_MUTED}${tok}${FG_RESET} `);
207
+ if (model) {
208
+ const thinking = pi.getThinkingLevel();
209
+ const thinkingStr = thinking !== "off" ? ` ${FG_MUTED}• ${thinking}${FG_RESET}` : "";
210
+ ctxParts.push(` ${FG_MUTED}${model}${FG_RESET}${thinkingStr} `);
211
+ }
212
+ const lineContext = padBgLine(`${bg}${ctxParts.join(sep)}`);
213
+
214
+ // ── Status line: STATUS (+/-) ──
215
+ const STATUS_LABEL_WIDTH = 10;
216
+ const labelTrunc = truncateToWidth(label, STATUS_LABEL_WIDTH, "…");
217
+ const labelPad = labelTrunc + " ".repeat(Math.max(0, STATUS_LABEL_WIDTH - visibleWidth(labelTrunc)));
218
+ let statusInner = ` ${sFg}${BOLD}${icon} ${labelPad}${BOLD_OFF}${FG_RESET}`;
219
+
220
+ // Git diff stats next to status
221
+ const diffParts: string[] = [];
222
+ if (gitAdded > 0) diffParts.push(`\x1b[38;2;100;200;120m+${gitAdded}${FG_RESET}`);
223
+ if (gitRemoved > 0) diffParts.push(`\x1b[38;2;240;100;100m-${gitRemoved}${FG_RESET}`);
224
+ if (gitDirty && gitAdded === 0 && gitRemoved === 0) diffParts.push(`${FG_MUTED}~${FG_RESET}`);
225
+ if (diffParts.length) {
226
+ statusInner += ` ${diffParts.join(" ")}`;
227
+ }
228
+ statusInner += " ";
229
+ const lineStatus = padBgLine(`${bg}${statusInner}`);
230
+
231
+ // Padding lines: just spaces, exactly width chars
232
+ const emptyLine = truncateToWidth(`${bg}${" ".repeat(width)}${RESET}`, width);
233
+
234
+ return [emptyLine, lineMain, lineContext, lineStatus, emptyLine, ""];
235
+ }
236
+
237
+ // ── Data refresh ─────────────────────────────────────────
238
+
239
+ async function refreshGit() {
240
+ const stats = await getGitStats(pi);
241
+ gitAdded = stats.added;
242
+ gitRemoved = stats.removed;
243
+ gitDirty = stats.dirty;
244
+ widgetTui?.requestRender();
245
+ }
246
+
247
+ function refreshContext() {
248
+ if (!currentCtx) return;
249
+ const usage = currentCtx.getContextUsage();
250
+ if (usage) {
251
+ contextPercent = usage.percent;
252
+ contextTokens = usage.tokens;
253
+ contextWindow = usage.contextWindow;
254
+ }
255
+ model = currentCtx.model?.id ?? "";
256
+ }
257
+
258
+ function clearStaleTimer() {
259
+ if (staleTimer) { clearTimeout(staleTimer); staleTimer = null; }
260
+ if (staleInterval) { clearInterval(staleInterval); staleInterval = null; }
261
+ }
262
+
263
+ function startStaleTimer() {
264
+ clearStaleTimer();
265
+ staleTimer = setTimeout(() => {
266
+ if (status === "tool") {
267
+ status = "stale";
268
+ widgetTui?.requestRender();
269
+ staleInterval = setInterval(() => {
270
+ if (status !== "stale") { clearInterval(staleInterval!); staleInterval = null; return; }
271
+ widgetTui?.requestRender();
272
+ }, 1000);
273
+ }
274
+ }, 30_000);
275
+ }
276
+
277
+ // ── Install widget ───────────────────────────────────────
278
+
279
+ function extractFirstUserText(ctx: ExtensionContext): string | null {
280
+ try {
281
+ const entries: any[] = ctx.sessionManager.getEntries?.() ?? [];
282
+ for (const e of entries) {
283
+ if (e?.type !== "message") continue;
284
+ const m = e.message;
285
+ if (!m || m.role !== "user") continue;
286
+ const parts: any[] = m.content ?? [];
287
+ let text = "";
288
+ for (const p of parts) {
289
+ if (p?.type === "text" && typeof p.text === "string") text += p.text;
290
+ }
291
+ text = text.replace(/[\r\n\t]/g, " ").replace(/\s+/g, " ").trim();
292
+ if (text) return text;
293
+ }
294
+ return null;
295
+ } catch {
296
+ return null;
297
+ }
298
+ }
299
+
300
+ function install(ctx: ExtensionContext) {
301
+ if (!ctx.hasUI || !enabled) return;
302
+ currentCtx = ctx;
303
+ model = ctx.model?.id ?? "";
304
+ firstUserText = extractFirstUserText(ctx);
305
+
306
+ // Remove legacy widget from older versions
307
+ if (LEGACY_WIDGET_ID !== WIDGET_ID) ctx.ui.setWidget(LEGACY_WIDGET_ID, undefined);
308
+
309
+ ctx.ui.setWidget(WIDGET_ID, (tui, theme) => {
310
+ widgetTui = tui;
311
+ widgetTheme = theme;
312
+ return {
313
+ render: (width: number) => renderBar(width),
314
+ invalidate() {},
315
+ dispose() {
316
+ if (gitPollTimer) { clearInterval(gitPollTimer); gitPollTimer = null; }
317
+ clearStaleTimer();
318
+ widgetTui = null;
319
+ widgetTheme = null;
320
+ },
321
+ };
322
+ }, { placement: "belowEditor" });
323
+
324
+ refreshContext();
325
+ refreshGit();
326
+
327
+ // Poll git stats every 10s (branch comes from footerData reactively via built-in footer)
328
+ if (gitPollTimer) clearInterval(gitPollTimer);
329
+ gitPollTimer = setInterval(() => refreshGit(), 10_000);
330
+
331
+ // Get initial git branch
332
+ pi.exec("git", ["branch", "--show-current"], { timeout: 2000 }).then((r) => {
333
+ if (r.code === 0) {
334
+ gitBranch = r.stdout.trim() || "detached";
335
+ widgetTui?.requestRender();
336
+ }
337
+ }).catch(() => {});
338
+ }
339
+
340
+ // ── Events ───────────────────────────────────────────────
341
+
342
+ pi.on("session_start", async (_event, ctx) => { install(ctx); });
343
+ pi.on("session_switch", async (_event, ctx) => { status = "idle"; install(ctx); });
344
+
345
+ pi.on("agent_start", async (_event, ctx) => {
346
+ currentCtx = ctx;
347
+ status = "running";
348
+ toolName = undefined;
349
+ clearStaleTimer();
350
+ widgetTui?.requestRender();
351
+ });
352
+
353
+ pi.on("agent_end", async (_event, ctx) => {
354
+ currentCtx = ctx;
355
+ status = "idle";
356
+ toolName = undefined;
357
+ clearStaleTimer();
358
+ refreshContext();
359
+ refreshGit();
360
+ // Re-poll branch in case it changed
361
+ pi.exec("git", ["branch", "--show-current"], { timeout: 2000 }).then((r) => {
362
+ if (r.code === 0) {
363
+ gitBranch = r.stdout.trim() || "detached";
364
+ widgetTui?.requestRender();
365
+ }
366
+ }).catch(() => {});
367
+ widgetTui?.requestRender();
368
+ });
369
+
370
+ pi.on("tool_call", async (event, ctx) => {
371
+ currentCtx = ctx;
372
+ status = "tool";
373
+ toolName = event.toolName;
374
+ toolStartTime = Date.now();
375
+ startStaleTimer();
376
+ widgetTui?.requestRender();
377
+ });
378
+
379
+ pi.on("tool_result", async (event, ctx) => {
380
+ currentCtx = ctx;
381
+ clearStaleTimer();
382
+ status = event.isError ? "error" : "running";
383
+ toolName = undefined;
384
+ widgetTui?.requestRender();
385
+ });
386
+
387
+ pi.on("turn_end", async (_event, ctx) => {
388
+ currentCtx = ctx;
389
+ refreshContext();
390
+ // Populate first user text lazily if session was empty at install time
391
+ if (!firstUserText) firstUserText = extractFirstUserText(ctx);
392
+ widgetTui?.requestRender();
393
+ });
394
+
395
+ pi.on("model_select", async (event, ctx) => {
396
+ currentCtx = ctx;
397
+ model = event.model.id;
398
+ widgetTui?.requestRender();
399
+ });
400
+
401
+ // ── Toggle command ───────────────────────────────────────
402
+
403
+ async function toggleSessionHud(ctx: ExtensionContext) {
404
+ enabled = !enabled;
405
+ if (enabled) {
406
+ install(ctx);
407
+ ctx.ui.notify("Session HUD enabled", "info");
408
+ } else {
409
+ ctx.ui.setWidget(WIDGET_ID, undefined);
410
+ if (LEGACY_WIDGET_ID !== WIDGET_ID) ctx.ui.setWidget(LEGACY_WIDGET_ID, undefined);
411
+ if (gitPollTimer) { clearInterval(gitPollTimer); gitPollTimer = null; }
412
+ clearStaleTimer();
413
+ ctx.ui.notify("Session HUD disabled", "info");
414
+ }
415
+ }
416
+
417
+ pi.registerCommand("hud", {
418
+ description: "Toggle the session HUD",
419
+ handler: async (_args, ctx) => {
420
+ await toggleSessionHud(ctx);
421
+ },
422
+ });
423
+
424
+ pi.registerCommand("status", {
425
+ description: "Toggle the session HUD (alias for /hud)",
426
+ handler: async (_args, ctx) => {
427
+ await toggleSessionHud(ctx);
428
+ },
429
+ });
430
+
431
+ pi.registerCommand("header", {
432
+ description: "Toggle the session HUD (alias for /hud)",
433
+ handler: async (_args, ctx) => {
434
+ await toggleSessionHud(ctx);
435
+ },
436
+ });
437
+ }