@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 +9 -0
- package/LICENSE +21 -0
- package/README.md +56 -0
- package/package.json +27 -0
- package/pi-session-hud.ts +437 -0
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
|
+
}
|