pi-hud 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +5 -5
- package/assets/pi-hud-logo-only.jpg +0 -0
- package/extensions/config/hud-settings.ts +15 -0
- package/extensions/git/git.ts +45 -0
- package/extensions/hud.ts +437 -0
- package/extensions/mcp/mcp-adapter.ts +41 -0
- package/extensions/parsers/subagents.ts +77 -0
- package/extensions/settings/hud-settings.ts +219 -0
- package/extensions/types/hud.ts +51 -0
- package/extensions/utils/formatters.ts +23 -0
- package/extensions/utils/records.ts +3 -0
- package/package.json +12 -7
- package/scripts/verify-package-files.mjs +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ludevdot
|
|
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
CHANGED
|
@@ -57,9 +57,9 @@ Run `/hud` again, or press `f2`, to hide or show it. Press `ctrl+h` to minimize
|
|
|
57
57
|
|
|
58
58
|
## Commands
|
|
59
59
|
|
|
60
|
-
| Command
|
|
61
|
-
|
|
|
62
|
-
| `/hud`
|
|
60
|
+
| Command | Description |
|
|
61
|
+
| --------------- | -------------------------------------------------------- |
|
|
62
|
+
| `/hud` | Toggle the hud. |
|
|
63
63
|
| `/hud-settings` | Configure position, shortcuts, auto-compact, and sizing. |
|
|
64
64
|
|
|
65
65
|
## Settings
|
|
@@ -98,7 +98,7 @@ Shortcut changes require `/reload` because shortcuts are registered when the ext
|
|
|
98
98
|
|
|
99
99
|
## Notes
|
|
100
100
|
|
|
101
|
-
- MCP servers are shown only when Pi has `pi-mcp-adapter` installed; config files alone do not enable the section.
|
|
101
|
+
- MCP servers are shown only when Pi has [`pi-mcp-adapter`](https://pi.dev/packages/pi-mcp-adapter?name=pi-mcp-adap) installed; config files alone do not enable the section.
|
|
102
102
|
- Subagent status is based on Pi extension events and `pi-subagents` tool/result shapes when available.
|
|
103
103
|
- The HUD auto-compacts for the full assistant turn and expands when the turn ends, instead of changing state on each reasoning update.
|
|
104
104
|
- The overlay is hidden on narrow terminals under the configured `minTerminalWidth`.
|
|
@@ -111,4 +111,4 @@ Shortcut changes require `/reload` because shortcuts are registered when the ext
|
|
|
111
111
|
|
|
112
112
|
## License
|
|
113
113
|
|
|
114
|
-
MIT
|
|
114
|
+
MIT
|
|
Binary file
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { OverlayAnchor } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { HudSettings } from "../types/hud.js";
|
|
3
|
+
|
|
4
|
+
export const VALID_POSITIONS: OverlayAnchor[] = ["center", "top-left", "top-right", "bottom-left", "bottom-right", "top-center", "bottom-center", "left-center", "right-center"];
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_HUD_SETTINGS: HudSettings = {
|
|
7
|
+
position: "top-right",
|
|
8
|
+
shortcut: "f2",
|
|
9
|
+
minimizeShortcut: "ctrl+h",
|
|
10
|
+
autoCompactWhileStreaming: true,
|
|
11
|
+
expandedWidth: 42,
|
|
12
|
+
compactWidth: 26,
|
|
13
|
+
minTerminalWidth: 90,
|
|
14
|
+
margin: { top: 1, right: 1, bottom: 1 },
|
|
15
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
export function getGitBranch(cwd: string): string | null {
|
|
6
|
+
const gitPaths = findGitPaths(cwd);
|
|
7
|
+
if (!gitPaths) return null;
|
|
8
|
+
const result = spawnSync("git", ["--no-optional-locks", "symbolic-ref", "--quiet", "--short", "HEAD"], {
|
|
9
|
+
cwd: gitPaths.repoDir,
|
|
10
|
+
encoding: "utf8",
|
|
11
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
12
|
+
});
|
|
13
|
+
const branch = result.status === 0 ? result.stdout.trim() : "";
|
|
14
|
+
return branch || null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function findGitPaths(cwd: string): { repoDir: string; headPath: string } | null {
|
|
18
|
+
let dir = cwd;
|
|
19
|
+
while (true) {
|
|
20
|
+
const gitPath = join(dir, ".git");
|
|
21
|
+
if (existsSync(gitPath)) {
|
|
22
|
+
try {
|
|
23
|
+
const stat = statSync(gitPath);
|
|
24
|
+
if (stat.isFile()) {
|
|
25
|
+
const content = readFileSync(gitPath, "utf8").trim();
|
|
26
|
+
if (content.startsWith("gitdir: ")) {
|
|
27
|
+
const gitDir = resolve(dir, content.slice(8).trim());
|
|
28
|
+
const headPath = join(gitDir, "HEAD");
|
|
29
|
+
if (!existsSync(headPath)) return null;
|
|
30
|
+
return { repoDir: dir, headPath };
|
|
31
|
+
}
|
|
32
|
+
} else if (stat.isDirectory()) {
|
|
33
|
+
const headPath = join(gitPath, "HEAD");
|
|
34
|
+
if (!existsSync(headPath)) return null;
|
|
35
|
+
return { repoDir: dir, headPath };
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const parent = dirname(dir);
|
|
42
|
+
if (parent === dir) return null;
|
|
43
|
+
dir = parent;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { Component, OverlayHandle, OverlayOptions, TUI } from "@earendil-works/pi-tui";
|
|
4
|
+
import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
5
|
+
import { getGitBranch } from "./git/git.js";
|
|
6
|
+
import { getMcpAdapterInfo } from "./mcp/mcp-adapter.js";
|
|
7
|
+
import { getSubagentToolLabel, parseSubagentMessage, parseSubagentResultCounts } from "./parsers/subagents.js";
|
|
8
|
+
import { getProjectPath, handleHudSettingsCommand, readHudSettings, toShortcutKey } from "./settings/hud-settings.js";
|
|
9
|
+
import type { ActiveSubagentToolRun, AgentStatus, HudSettings, SessionStats, SubagentRunCounts, SubagentStatus } from "./types/hud.js";
|
|
10
|
+
import { formatElapsed, formatNumber, formatShortcut } from "./utils/formatters.js";
|
|
11
|
+
|
|
12
|
+
export default function (pi: ExtensionAPI) {
|
|
13
|
+
let hudHandle: OverlayHandle | null = null;
|
|
14
|
+
let refreshTimer: ReturnType<typeof setInterval> | null = null;
|
|
15
|
+
let hudTui: TUI | null = null;
|
|
16
|
+
let generation = 0;
|
|
17
|
+
let opening = false;
|
|
18
|
+
let assistantTurnActive = false;
|
|
19
|
+
let manualCompactOverride: boolean | undefined;
|
|
20
|
+
let currentHudSettings: HudSettings | undefined;
|
|
21
|
+
const agentStatus: AgentStatus = { running: 0, completed: 0 };
|
|
22
|
+
const subagentStatus: SubagentStatus = { running: 0, completed: 0, failed: 0, seen: false, tokens: 0 };
|
|
23
|
+
const subagentRuns = new Map<string, SubagentRunCounts>();
|
|
24
|
+
const activeSubagentTools = new Map<string, ActiveSubagentToolRun>();
|
|
25
|
+
let completedSubagentToolRuns = 0;
|
|
26
|
+
let failedSubagentToolRuns = 0;
|
|
27
|
+
|
|
28
|
+
const clearRefreshTimer = () => {
|
|
29
|
+
if (refreshTimer === null) return;
|
|
30
|
+
clearInterval(refreshTimer);
|
|
31
|
+
refreshTimer = null;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const resetHudState = () => {
|
|
35
|
+
clearRefreshTimer();
|
|
36
|
+
hudHandle = null;
|
|
37
|
+
hudTui = null;
|
|
38
|
+
opening = false;
|
|
39
|
+
currentHudSettings = undefined;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const hideHud = () => {
|
|
43
|
+
generation++;
|
|
44
|
+
const handle = hudHandle;
|
|
45
|
+
resetHudState();
|
|
46
|
+
handle?.hide();
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const requestHudRender = () => {
|
|
50
|
+
hudTui?.requestRender();
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const isCompact = (settings: HudSettings) => manualCompactOverride ?? (settings.autoCompactWhileStreaming && assistantTurnActive);
|
|
54
|
+
|
|
55
|
+
const recalculateSubagentStatus = () => {
|
|
56
|
+
subagentStatus.running = activeSubagentTools.size;
|
|
57
|
+
subagentStatus.completed = completedSubagentToolRuns;
|
|
58
|
+
subagentStatus.failed = failedSubagentToolRuns;
|
|
59
|
+
subagentStatus.seen = subagentRuns.size > 0 || activeSubagentTools.size > 0 || completedSubagentToolRuns > 0 || failedSubagentToolRuns > 0;
|
|
60
|
+
subagentStatus.activeLabel = undefined;
|
|
61
|
+
subagentStatus.activeStartedAt = undefined;
|
|
62
|
+
subagentStatus.tokens = 0;
|
|
63
|
+
|
|
64
|
+
for (const counts of subagentRuns.values()) {
|
|
65
|
+
subagentStatus.running += counts.running;
|
|
66
|
+
subagentStatus.completed += counts.completed;
|
|
67
|
+
subagentStatus.failed += counts.failed;
|
|
68
|
+
subagentStatus.tokens += counts.tokens;
|
|
69
|
+
if (!subagentStatus.activeLabel && counts.activeLabel) {
|
|
70
|
+
subagentStatus.activeLabel = counts.activeLabel;
|
|
71
|
+
subagentStatus.activeStartedAt = counts.activeStartedAt;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
for (const activeRun of activeSubagentTools.values()) {
|
|
75
|
+
if (!subagentStatus.activeLabel) {
|
|
76
|
+
subagentStatus.activeLabel = activeRun.label;
|
|
77
|
+
subagentStatus.activeStartedAt = activeRun.startedAt;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const updateSubagentStatusFromMessage = (message: unknown): boolean => {
|
|
83
|
+
const parsed = parseSubagentMessage(message);
|
|
84
|
+
if (!parsed) return false;
|
|
85
|
+
subagentRuns.set(parsed.requestId, parsed.counts);
|
|
86
|
+
recalculateSubagentStatus();
|
|
87
|
+
return true;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const startRefreshTimer = (currentGeneration: number, tui: TUI) => {
|
|
91
|
+
clearRefreshTimer();
|
|
92
|
+
refreshTimer = setInterval(() => {
|
|
93
|
+
if (currentGeneration !== generation || hudHandle === null) {
|
|
94
|
+
clearRefreshTimer();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
tui.requestRender();
|
|
98
|
+
}, 1000);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const showHud = (ctx: ExtensionContext) => {
|
|
102
|
+
if (!ctx.hasUI || hudHandle !== null || opening) return;
|
|
103
|
+
|
|
104
|
+
const settings = readHudSettings(getProjectPath(ctx));
|
|
105
|
+
currentHudSettings = settings;
|
|
106
|
+
const currentGeneration = ++generation;
|
|
107
|
+
opening = true;
|
|
108
|
+
try {
|
|
109
|
+
ctx.ui
|
|
110
|
+
.custom<void>((tui, theme, _keybindings, done) => {
|
|
111
|
+
hudTui = tui;
|
|
112
|
+
return new HudComponent(pi, ctx, tui, theme, done, subagentStatus, settings, () => isCompact(settings));
|
|
113
|
+
}, {
|
|
114
|
+
overlay: true,
|
|
115
|
+
overlayOptions: () => createHudOverlayOptions(settings, isCompact(settings)),
|
|
116
|
+
onHandle: (handle) => {
|
|
117
|
+
if (currentGeneration !== generation) {
|
|
118
|
+
handle.hide();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
hudHandle = handle;
|
|
122
|
+
opening = false;
|
|
123
|
+
if (hudTui !== null) {
|
|
124
|
+
startRefreshTimer(currentGeneration, hudTui);
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
})
|
|
128
|
+
.catch(() => {
|
|
129
|
+
if (currentGeneration !== generation) return;
|
|
130
|
+
resetHudState();
|
|
131
|
+
});
|
|
132
|
+
} catch {
|
|
133
|
+
if (currentGeneration === generation) resetHudState();
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const toggleHud = (ctx: ExtensionContext) => {
|
|
138
|
+
if (!ctx.hasUI) return;
|
|
139
|
+
if (hudHandle !== null || opening) {
|
|
140
|
+
hideHud();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
showHud(ctx);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const toggleCompact = () => {
|
|
147
|
+
const settings = currentHudSettings ?? readHudSettings(process.cwd());
|
|
148
|
+
manualCompactOverride = !isCompact(settings);
|
|
149
|
+
requestHudRender();
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
pi.on("agent_start", () => {
|
|
153
|
+
agentStatus.running++;
|
|
154
|
+
requestHudRender();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
pi.on("agent_end", () => {
|
|
158
|
+
agentStatus.running = Math.max(0, agentStatus.running - 1);
|
|
159
|
+
agentStatus.completed++;
|
|
160
|
+
requestHudRender();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
pi.on("turn_start", () => {
|
|
164
|
+
if (assistantTurnActive) return;
|
|
165
|
+
assistantTurnActive = true;
|
|
166
|
+
requestHudRender();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
pi.on("turn_end", () => {
|
|
170
|
+
if (!assistantTurnActive) return;
|
|
171
|
+
assistantTurnActive = false;
|
|
172
|
+
requestHudRender();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
pi.on("message_start", (event) => {
|
|
176
|
+
if (updateSubagentStatusFromMessage(event.message)) requestHudRender();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
pi.on("message_update", (event) => {
|
|
180
|
+
if (updateSubagentStatusFromMessage(event.message)) requestHudRender();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
pi.on("message_end", (event) => {
|
|
184
|
+
if (updateSubagentStatusFromMessage(event.message)) requestHudRender();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
pi.on("tool_execution_start", (event) => {
|
|
188
|
+
if (event.toolName !== "subagent") return;
|
|
189
|
+
activeSubagentTools.set(event.toolCallId, {
|
|
190
|
+
label: getSubagentToolLabel(event.args),
|
|
191
|
+
startedAt: Date.now(),
|
|
192
|
+
});
|
|
193
|
+
recalculateSubagentStatus();
|
|
194
|
+
requestHudRender();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
pi.on("tool_execution_end", (event) => {
|
|
198
|
+
if (event.toolName !== "subagent") return;
|
|
199
|
+
if (activeSubagentTools.delete(event.toolCallId)) {
|
|
200
|
+
const resultCounts = parseSubagentResultCounts(event.result);
|
|
201
|
+
if (resultCounts) {
|
|
202
|
+
completedSubagentToolRuns += resultCounts.completed;
|
|
203
|
+
failedSubagentToolRuns += resultCounts.failed;
|
|
204
|
+
} else if (event.isError) failedSubagentToolRuns++;
|
|
205
|
+
else completedSubagentToolRuns++;
|
|
206
|
+
}
|
|
207
|
+
recalculateSubagentStatus();
|
|
208
|
+
requestHudRender();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
pi.on("session_start", (_event, ctx) => {
|
|
212
|
+
showHud(ctx);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
pi.on("session_shutdown", () => {
|
|
216
|
+
hideHud();
|
|
217
|
+
agentStatus.running = 0;
|
|
218
|
+
agentStatus.completed = 0;
|
|
219
|
+
assistantTurnActive = false;
|
|
220
|
+
manualCompactOverride = undefined;
|
|
221
|
+
subagentRuns.clear();
|
|
222
|
+
activeSubagentTools.clear();
|
|
223
|
+
completedSubagentToolRuns = 0;
|
|
224
|
+
failedSubagentToolRuns = 0;
|
|
225
|
+
recalculateSubagentStatus();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
pi.registerCommand("hud", {
|
|
229
|
+
description: "Toggle the session HUD overlay",
|
|
230
|
+
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
231
|
+
toggleHud(ctx);
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
pi.registerCommand("hud-settings", {
|
|
236
|
+
description: "Configure the session HUD",
|
|
237
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
238
|
+
await handleHudSettingsCommand(args, ctx);
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const startupSettings = readHudSettings(process.cwd());
|
|
243
|
+
const startupShortcut = toShortcutKey(startupSettings.shortcut);
|
|
244
|
+
if (startupShortcut) {
|
|
245
|
+
pi.registerShortcut(startupShortcut, {
|
|
246
|
+
description: "Toggle the session HUD",
|
|
247
|
+
handler: (ctx: ExtensionContext) => {
|
|
248
|
+
toggleHud(ctx);
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
const startupMinimizeShortcut = toShortcutKey(startupSettings.minimizeShortcut);
|
|
253
|
+
if (startupMinimizeShortcut) {
|
|
254
|
+
pi.registerShortcut(startupMinimizeShortcut, {
|
|
255
|
+
description: "Minimize or expand the session HUD",
|
|
256
|
+
handler: () => {
|
|
257
|
+
toggleCompact();
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
class HudComponent implements Component {
|
|
265
|
+
constructor(
|
|
266
|
+
private pi: ExtensionAPI,
|
|
267
|
+
private ctx: ExtensionContext,
|
|
268
|
+
private tui: TUI,
|
|
269
|
+
private theme: Theme,
|
|
270
|
+
private done: () => void,
|
|
271
|
+
private subagentStatus: SubagentStatus,
|
|
272
|
+
private settings: HudSettings,
|
|
273
|
+
private isCompact: () => boolean,
|
|
274
|
+
) {}
|
|
275
|
+
|
|
276
|
+
handleInput(data: string): void {
|
|
277
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
|
278
|
+
this.done();
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (matchesKey(data, "r")) {
|
|
283
|
+
this.tui.requestRender();
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
render(width: number): string[] {
|
|
288
|
+
const stats = this.computeStats();
|
|
289
|
+
const model = this.ctx.model;
|
|
290
|
+
const sessionName = this.pi.getSessionName() ?? this.ctx.sessionManager.getSessionName() ?? "New session";
|
|
291
|
+
const sessionId = this.ctx.sessionManager.getSessionId();
|
|
292
|
+
const projectPath = this.ctx.sessionManager.getCwd() || this.ctx.cwd;
|
|
293
|
+
const gitBranch = getGitBranch(projectPath);
|
|
294
|
+
const mcpAdapter = getMcpAdapterInfo(this.pi, projectPath);
|
|
295
|
+
const contextUsage = this.ctx.getContextUsage?.();
|
|
296
|
+
const contextWindow = contextUsage?.contextWindow ?? model?.contextWindow ?? 0;
|
|
297
|
+
const contextTokens = contextUsage?.tokens ?? stats.totalTokens;
|
|
298
|
+
const contextPercent = contextUsage?.percent ?? (contextWindow > 0 ? (contextTokens / contextWindow) * 100 : null);
|
|
299
|
+
const innerWidth = Math.max(1, width - 2);
|
|
300
|
+
const lines: string[] = [];
|
|
301
|
+
|
|
302
|
+
if (this.isCompact()) {
|
|
303
|
+
this.pushTopBorder(lines, innerWidth, "HUD");
|
|
304
|
+
this.pushLine(lines, innerWidth, this.theme.fg("accent", model?.name ?? model?.id ?? "No model"));
|
|
305
|
+
this.pushLine(lines, innerWidth, contextPercent === null ? "ctx unknown" : `${contextPercent.toFixed(1)}% ctx`);
|
|
306
|
+
this.pushLine(lines, innerWidth, `${this.theme.fg("warning", `${this.subagentStatus.running} run`)} · ${this.theme.fg("error", `${this.subagentStatus.failed} err`)}`);
|
|
307
|
+
if (this.subagentStatus.activeLabel) {
|
|
308
|
+
this.pushLine(lines, innerWidth, this.theme.fg("accent", `[·] ${this.subagentStatus.activeLabel}`));
|
|
309
|
+
}
|
|
310
|
+
this.pushBottomBorder(lines, innerWidth);
|
|
311
|
+
return lines;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
this.pushTopBorder(lines, innerWidth, "Session");
|
|
315
|
+
this.pushLine(lines, innerWidth, this.theme.fg("accent", sessionName));
|
|
316
|
+
this.pushLine(lines, innerWidth, this.theme.fg("dim", sessionId));
|
|
317
|
+
this.pushBlank(lines, innerWidth);
|
|
318
|
+
this.pushLine(lines, innerWidth, `Model ${model?.name ?? model?.id ?? "No model"}`);
|
|
319
|
+
this.pushLine(lines, innerWidth, this.theme.fg("dim", `${formatNumber(contextWindow)} ctx`));
|
|
320
|
+
|
|
321
|
+
this.pushSection(lines, innerWidth, "Subagents");
|
|
322
|
+
this.pushLine(
|
|
323
|
+
lines,
|
|
324
|
+
innerWidth,
|
|
325
|
+
`${this.theme.fg("warning", `${this.subagentStatus.running} run`)} · ${this.theme.fg("success", `${this.subagentStatus.completed} done`)} · ${this.theme.fg("error", `${this.subagentStatus.failed} err`)}`,
|
|
326
|
+
);
|
|
327
|
+
if (this.subagentStatus.activeLabel) {
|
|
328
|
+
this.pushLine(lines, innerWidth, this.theme.fg("accent", `[·] ${this.subagentStatus.activeLabel}`));
|
|
329
|
+
this.pushLine(lines, innerWidth, this.theme.fg("dim", ` ↳ ◷ ${formatElapsed(this.subagentStatus.activeStartedAt)} ${formatNumber(this.subagentStatus.tokens)} ctx`));
|
|
330
|
+
} else if (this.subagentStatus.seen) {
|
|
331
|
+
this.pushLine(lines, innerWidth, this.theme.fg("dim", `subagents ${this.subagentStatus.running} run · ${this.subagentStatus.completed} done`));
|
|
332
|
+
} else {
|
|
333
|
+
this.pushLine(lines, innerWidth, this.theme.fg("dim", "subagents idle"));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
this.pushSection(lines, innerWidth, "Context");
|
|
337
|
+
this.pushLine(lines, innerWidth, contextTokens === null ? "tokens unknown" : `${formatNumber(contextTokens)} tokens`);
|
|
338
|
+
this.pushLine(lines, innerWidth, contextPercent === null ? "usage unknown" : `${contextPercent.toFixed(1)}% used`);
|
|
339
|
+
this.pushLine(lines, innerWidth, `$${stats.cost.toFixed(4)} spent`);
|
|
340
|
+
this.pushLine(lines, innerWidth, this.theme.fg("dim", `in ${formatNumber(stats.inputTokens)} out ${formatNumber(stats.outputTokens)}`));
|
|
341
|
+
this.pushLine(lines, innerWidth, this.theme.fg("dim", `cache ${formatNumber(stats.cacheReadTokens)}/${formatNumber(stats.cacheWriteTokens)}`));
|
|
342
|
+
|
|
343
|
+
this.pushSection(lines, innerWidth, "Project");
|
|
344
|
+
this.pushLine(lines, innerWidth, projectPath);
|
|
345
|
+
if (gitBranch) {
|
|
346
|
+
this.pushLine(lines, innerWidth, this.theme.fg("dim", `branch ${gitBranch}`));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (mcpAdapter.available) {
|
|
350
|
+
this.pushSection(lines, innerWidth, "MCP");
|
|
351
|
+
if (mcpAdapter.servers.length === 0) {
|
|
352
|
+
this.pushLine(lines, innerWidth, this.theme.fg("dim", "adapter installed"));
|
|
353
|
+
} else {
|
|
354
|
+
for (const server of mcpAdapter.servers) {
|
|
355
|
+
this.pushLine(lines, innerWidth, server);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
this.pushSection(lines, innerWidth, "Help");
|
|
361
|
+
this.pushLine(lines, innerWidth, this.theme.fg("dim", `/hud or ${formatShortcut(this.settings.shortcut)} hide/show`));
|
|
362
|
+
this.pushLine(lines, innerWidth, this.theme.fg("dim", `${formatShortcut(this.settings.minimizeShortcut)} minimize/expand`));
|
|
363
|
+
this.pushBottomBorder(lines, innerWidth);
|
|
364
|
+
|
|
365
|
+
return lines;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
invalidate(): void {}
|
|
369
|
+
|
|
370
|
+
private computeStats(): SessionStats {
|
|
371
|
+
const stats: SessionStats = {
|
|
372
|
+
inputTokens: 0,
|
|
373
|
+
outputTokens: 0,
|
|
374
|
+
cacheReadTokens: 0,
|
|
375
|
+
cacheWriteTokens: 0,
|
|
376
|
+
totalTokens: 0,
|
|
377
|
+
cost: 0,
|
|
378
|
+
assistantMessages: 0,
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
for (const entry of this.ctx.sessionManager.getBranch()) {
|
|
382
|
+
if (entry.type !== "message" || entry.message.role !== "assistant") continue;
|
|
383
|
+
|
|
384
|
+
const message = entry.message as AssistantMessage;
|
|
385
|
+
stats.inputTokens += message.usage.input || 0;
|
|
386
|
+
stats.outputTokens += message.usage.output || 0;
|
|
387
|
+
stats.cacheReadTokens += message.usage.cacheRead || 0;
|
|
388
|
+
stats.cacheWriteTokens += message.usage.cacheWrite || 0;
|
|
389
|
+
stats.totalTokens += message.usage.totalTokens || 0;
|
|
390
|
+
stats.cost += message.usage.cost.total || 0;
|
|
391
|
+
stats.assistantMessages++;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return stats;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
private pushTopBorder(lines: string[], innerWidth: number, title: string): void {
|
|
398
|
+
const border = this.theme.fg("border", `╭${"─".repeat(innerWidth)}╮`);
|
|
399
|
+
lines.push(border);
|
|
400
|
+
this.pushLine(lines, innerWidth, this.theme.fg("accent", this.theme.bold(` ${title}`)));
|
|
401
|
+
this.pushSeparator(lines, innerWidth);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private pushBottomBorder(lines: string[], innerWidth: number): void {
|
|
405
|
+
lines.push(this.theme.fg("border", `╰${"─".repeat(innerWidth)}╯`));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
private pushSeparator(lines: string[], innerWidth: number): void {
|
|
409
|
+
lines.push(this.theme.fg("border", `├${"─".repeat(innerWidth)}┤`));
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private pushSection(lines: string[], innerWidth: number, title: string): void {
|
|
413
|
+
this.pushBlank(lines, innerWidth);
|
|
414
|
+
this.pushLine(lines, innerWidth, this.theme.fg("accent", title));
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private pushBlank(lines: string[], innerWidth: number): void {
|
|
418
|
+
this.pushLine(lines, innerWidth, "");
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
private pushLine(lines: string[], innerWidth: number, text: string): void {
|
|
422
|
+
const content = truncateToWidth(` ${text}`, innerWidth, "…", true);
|
|
423
|
+
lines.push(this.theme.fg("border", "│") + content + this.theme.fg("border", "│"));
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function createHudOverlayOptions(settings: HudSettings, compact: boolean): OverlayOptions {
|
|
428
|
+
return {
|
|
429
|
+
anchor: settings.position,
|
|
430
|
+
width: compact ? settings.compactWidth : settings.expandedWidth,
|
|
431
|
+
maxHeight: "100%",
|
|
432
|
+
margin: settings.margin,
|
|
433
|
+
visible: (termWidth) => termWidth >= settings.minTerminalWidth,
|
|
434
|
+
nonCapturing: true,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { isRecord } from "../utils/records.js";
|
|
6
|
+
|
|
7
|
+
export function getMcpAdapterInfo(pi: ExtensionAPI, cwd: string): { available: boolean; servers: string[] } {
|
|
8
|
+
const hasAdapter = pi.getAllTools().some((tool) => isMcpAdapterSource(tool.sourceInfo.source)) || pi.getCommands().some((command) => isMcpAdapterSource(command.sourceInfo.source));
|
|
9
|
+
if (!hasAdapter) return { available: false, servers: [] };
|
|
10
|
+
return { available: true, servers: getConfiguredMcpServers(cwd) };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isMcpAdapterSource(source: string): boolean {
|
|
14
|
+
return source.includes("pi-mcp-adapter") || source.includes("nicobailon/pi-mcp-adapter");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getConfiguredMcpServers(cwd: string): string[] {
|
|
18
|
+
const names = new Set<string>();
|
|
19
|
+
for (const path of getMcpConfigPaths(cwd)) {
|
|
20
|
+
for (const name of readMcpServerNames(path)) {
|
|
21
|
+
names.add(name);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return [...names].sort((a, b) => a.localeCompare(b));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getMcpConfigPaths(cwd: string): string[] {
|
|
28
|
+
const agentDir = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
|
|
29
|
+
return [join(homedir(), ".config", "mcp", "mcp.json"), join(agentDir, "mcp.json"), join(cwd, ".mcp.json"), join(cwd, ".pi", "mcp.json")];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readMcpServerNames(path: string): string[] {
|
|
33
|
+
if (!existsSync(path)) return [];
|
|
34
|
+
try {
|
|
35
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
36
|
+
if (!isRecord(parsed) || !isRecord(parsed.mcpServers)) return [];
|
|
37
|
+
return Object.keys(parsed.mcpServers);
|
|
38
|
+
} catch {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { SubagentRunCounts } from "../types/hud.js";
|
|
2
|
+
import { isRecord } from "../utils/records.js";
|
|
3
|
+
|
|
4
|
+
export function parseSubagentMessage(message: unknown): { requestId: string; counts: SubagentRunCounts } | undefined {
|
|
5
|
+
if (!isRecord(message)) return undefined;
|
|
6
|
+
if (message.role !== "custom" || message.customType !== "subagent-slash-result") return undefined;
|
|
7
|
+
const details = message.details;
|
|
8
|
+
if (!isRecord(details) || typeof details.requestId !== "string") return undefined;
|
|
9
|
+
const result = details.result;
|
|
10
|
+
if (!isRecord(result)) return undefined;
|
|
11
|
+
const resultDetails = result.details;
|
|
12
|
+
if (!isRecord(resultDetails)) return undefined;
|
|
13
|
+
|
|
14
|
+
const statusSources = collectSubagentStatusSources(resultDetails);
|
|
15
|
+
if (statusSources.length === 0) return undefined;
|
|
16
|
+
|
|
17
|
+
const counts: SubagentRunCounts = { running: 0, completed: 0, failed: 0, tokens: 0 };
|
|
18
|
+
for (const source of statusSources) {
|
|
19
|
+
if (typeof source.tokens === "number") counts.tokens += source.tokens;
|
|
20
|
+
if (source.status === "running") {
|
|
21
|
+
counts.running++;
|
|
22
|
+
if (!counts.activeLabel) counts.activeLabel = getSubagentProgressLabel(source);
|
|
23
|
+
if (!counts.activeStartedAt) counts.activeStartedAt = Date.now() - getDurationMs(source);
|
|
24
|
+
} else if (source.status === "completed" || source.status === "complete") counts.completed++;
|
|
25
|
+
else if (source.status === "failed") counts.failed++;
|
|
26
|
+
}
|
|
27
|
+
return { requestId: details.requestId, counts };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function parseSubagentResultCounts(result: unknown): Pick<SubagentRunCounts, "completed" | "failed"> | undefined {
|
|
31
|
+
if (!isRecord(result)) return undefined;
|
|
32
|
+
const details = result.details;
|
|
33
|
+
if (!isRecord(details)) return undefined;
|
|
34
|
+
const statusSources = collectSubagentStatusSources(details);
|
|
35
|
+
if (statusSources.length === 0) return undefined;
|
|
36
|
+
const counts = { completed: 0, failed: 0 };
|
|
37
|
+
for (const source of statusSources) {
|
|
38
|
+
if (source.status === "failed") counts.failed++;
|
|
39
|
+
else if (source.status === "completed" || source.status === "complete") counts.completed++;
|
|
40
|
+
}
|
|
41
|
+
return counts.completed > 0 || counts.failed > 0 ? counts : undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function getSubagentToolLabel(args: unknown): string {
|
|
45
|
+
if (!isRecord(args)) return "subagent";
|
|
46
|
+
const agent = typeof args.agent === "string" ? args.agent : undefined;
|
|
47
|
+
const task = typeof args.task === "string" ? args.task : undefined;
|
|
48
|
+
if (task) return task;
|
|
49
|
+
if (agent) return agent;
|
|
50
|
+
if (Array.isArray(args.tasks) && args.tasks.length > 0) return `${args.tasks.length} subagents`;
|
|
51
|
+
if (Array.isArray(args.chain) && args.chain.length > 0) return `${args.chain.length} step chain`;
|
|
52
|
+
return "subagent";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function collectSubagentStatusSources(resultDetails: Record<string, unknown>): Array<Record<string, unknown>> {
|
|
56
|
+
if (Array.isArray(resultDetails.progress)) {
|
|
57
|
+
return resultDetails.progress.filter(isRecord);
|
|
58
|
+
}
|
|
59
|
+
if (!Array.isArray(resultDetails.results)) return [];
|
|
60
|
+
return resultDetails.results.filter(isRecord).flatMap((result) => {
|
|
61
|
+
if (isRecord(result.progress)) return [result.progress];
|
|
62
|
+
if (typeof result.exitCode === "number") {
|
|
63
|
+
return [{ status: result.exitCode === 0 ? "completed" : "failed" }];
|
|
64
|
+
}
|
|
65
|
+
return [];
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getSubagentProgressLabel(progress: Record<string, unknown>): string {
|
|
70
|
+
const task = typeof progress.task === "string" ? progress.task : undefined;
|
|
71
|
+
const agent = typeof progress.agent === "string" ? progress.agent : undefined;
|
|
72
|
+
return task || agent || "subagent";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function getDurationMs(progress: Record<string, unknown>): number {
|
|
76
|
+
return typeof progress.durationMs === "number" ? Math.max(0, progress.durationMs) : 0;
|
|
77
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { KeyId, OverlayAnchor, OverlayMargin } from "@earendil-works/pi-tui";
|
|
6
|
+
import { DEFAULT_HUD_SETTINGS, VALID_POSITIONS } from "../config/hud-settings.js";
|
|
7
|
+
import type { HudSettings } from "../types/hud.js";
|
|
8
|
+
import { formatHudSettings } from "../utils/formatters.js";
|
|
9
|
+
import { isRecord } from "../utils/records.js";
|
|
10
|
+
|
|
11
|
+
export function getProjectPath(ctx: ExtensionContext): string {
|
|
12
|
+
return ctx.sessionManager.getCwd() || ctx.cwd;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function readHudSettings(cwd: string): HudSettings {
|
|
16
|
+
let settings = { ...DEFAULT_HUD_SETTINGS, margin: { ...DEFAULT_HUD_SETTINGS.margin } };
|
|
17
|
+
for (const path of getSettingsPaths(cwd)) {
|
|
18
|
+
const hud = readHudSettingsObject(path);
|
|
19
|
+
if (hud) settings = normalizeHudSettings(hud, settings);
|
|
20
|
+
}
|
|
21
|
+
return settings;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function handleHudSettingsCommand(args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
25
|
+
const projectPath = getProjectPath(ctx);
|
|
26
|
+
const settings = readHudSettings(projectPath);
|
|
27
|
+
const trimmed = args.trim();
|
|
28
|
+
if (trimmed.length > 0) {
|
|
29
|
+
const updated = updateHudSettingFromArgs(settings, trimmed);
|
|
30
|
+
if (!updated) {
|
|
31
|
+
ctx.ui.notify("Usage: /hud-settings position|shortcut|minimizeShortcut|autoCompactWhileStreaming|expandedWidth|compactWidth|minTerminalWidth <value>", "warning");
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
writeProjectHudSettings(projectPath, updated.settings);
|
|
35
|
+
ctx.ui.notify(updated.message, "info");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const choice = await ctx.ui.select("HUD settings", ["position", "shortcut", "minimizeShortcut", "autoCompactWhileStreaming", "expandedWidth", "compactWidth", "minTerminalWidth", "show current"]);
|
|
40
|
+
if (!choice) return;
|
|
41
|
+
if (choice === "show current") {
|
|
42
|
+
ctx.ui.notify(formatHudSettings(settings), "info");
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (choice === "position") {
|
|
47
|
+
const position = await ctx.ui.select("HUD position", VALID_POSITIONS);
|
|
48
|
+
if (!position) return;
|
|
49
|
+
const updated = { ...settings, position: position as OverlayAnchor };
|
|
50
|
+
writeProjectHudSettings(projectPath, updated);
|
|
51
|
+
ctx.ui.notify(`HUD position set to ${position}. Reopen /hud if it is currently visible.`, "info");
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (choice === "shortcut" || choice === "minimizeShortcut") {
|
|
56
|
+
const shortcut = await ctx.ui.input(`HUD ${choice}`, settings[choice]);
|
|
57
|
+
if (shortcut === undefined) return;
|
|
58
|
+
const normalizedShortcut = normalizeShortcut(shortcut, "");
|
|
59
|
+
if (normalizedShortcut.length === 0) {
|
|
60
|
+
ctx.ui.notify("Invalid HUD shortcut. Do not use enter, return, alt+m, ctrl+m, ctrl+shift+m, ctrl+j, or ctrl+shift+j because they conflict with Pi or terminal input keys.", "warning");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const updated = { ...settings, [choice]: normalizedShortcut };
|
|
64
|
+
writeProjectHudSettings(projectPath, updated);
|
|
65
|
+
ctx.ui.notify(`HUD ${choice} saved. Run /reload for the shortcut registration to change.`, "info");
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (choice === "autoCompactWhileStreaming") {
|
|
70
|
+
const value = await ctx.ui.select("Auto-compact while streaming", ["enabled", "disabled"]);
|
|
71
|
+
if (!value) return;
|
|
72
|
+
const updated = { ...settings, autoCompactWhileStreaming: value === "enabled" };
|
|
73
|
+
writeProjectHudSettings(projectPath, updated);
|
|
74
|
+
ctx.ui.notify(`HUD auto-compact ${value}.`, "info");
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const numericChoice = choice as "expandedWidth" | "compactWidth" | "minTerminalWidth";
|
|
79
|
+
const value = await ctx.ui.input(`HUD ${numericChoice}`, String(settings[numericChoice]));
|
|
80
|
+
if (value === undefined) return;
|
|
81
|
+
const updated = updateHudSettingFromArgs(settings, `${numericChoice} ${value}`);
|
|
82
|
+
if (!updated) {
|
|
83
|
+
ctx.ui.notify(`Invalid value for ${numericChoice}.`, "warning");
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
writeProjectHudSettings(projectPath, updated.settings);
|
|
87
|
+
ctx.ui.notify(updated.message, "info");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function toShortcutKey(shortcut: string): KeyId | undefined {
|
|
91
|
+
const normalized = normalizeShortcut(shortcut, "");
|
|
92
|
+
return normalized.length > 0 ? (normalized as KeyId) : undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function getSettingsPaths(cwd: string): string[] {
|
|
96
|
+
const agentDir = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
|
|
97
|
+
return [join(agentDir, "settings.json"), join(cwd, ".pi", "settings.json")];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function readHudSettingsObject(path: string): Record<string, unknown> | undefined {
|
|
101
|
+
if (!existsSync(path)) return undefined;
|
|
102
|
+
try {
|
|
103
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
104
|
+
if (!isRecord(parsed) || !isRecord(parsed.hud)) return undefined;
|
|
105
|
+
return parsed.hud;
|
|
106
|
+
} catch {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function normalizeHudSettings(input: Record<string, unknown>, base: HudSettings): HudSettings {
|
|
112
|
+
return {
|
|
113
|
+
position: normalizePosition(input.position, base.position),
|
|
114
|
+
shortcut: typeof input.shortcut === "string" ? normalizeShortcut(input.shortcut, base.shortcut) : base.shortcut,
|
|
115
|
+
minimizeShortcut: typeof input.minimizeShortcut === "string" ? normalizeShortcut(input.minimizeShortcut, base.minimizeShortcut) : base.minimizeShortcut,
|
|
116
|
+
autoCompactWhileStreaming: typeof input.autoCompactWhileStreaming === "boolean" ? input.autoCompactWhileStreaming : base.autoCompactWhileStreaming,
|
|
117
|
+
expandedWidth: normalizePositiveInteger(input.expandedWidth, base.expandedWidth, 20, 80),
|
|
118
|
+
compactWidth: normalizePositiveInteger(input.compactWidth, base.compactWidth, 16, 60),
|
|
119
|
+
minTerminalWidth: normalizePositiveInteger(input.minTerminalWidth, base.minTerminalWidth, 40, 300),
|
|
120
|
+
margin: normalizeMargin(input.margin, base.margin),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function normalizePosition(value: unknown, fallback: OverlayAnchor): OverlayAnchor {
|
|
125
|
+
return typeof value === "string" && VALID_POSITIONS.includes(value as OverlayAnchor) ? (value as OverlayAnchor) : fallback;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function normalizeShortcut(value: string, fallback: string): string {
|
|
129
|
+
const shortcut = value.trim();
|
|
130
|
+
const parts = shortcut.toLowerCase().split("+");
|
|
131
|
+
const key = parts.at(-1);
|
|
132
|
+
if (key === "enter" || key === "return" || shortcut.toLowerCase() === "alt+m") return fallback;
|
|
133
|
+
if (parts.includes("ctrl") && (key === "m" || key === "j")) return fallback;
|
|
134
|
+
return shortcut;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function normalizePositiveInteger(value: unknown, fallback: number, min: number, max: number): number {
|
|
138
|
+
if (typeof value !== "number" || !Number.isInteger(value)) return fallback;
|
|
139
|
+
return Math.min(max, Math.max(min, value));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function normalizeMargin(value: unknown, fallback: OverlayMargin): OverlayMargin {
|
|
143
|
+
if (!isRecord(value)) return { ...fallback };
|
|
144
|
+
return {
|
|
145
|
+
top: normalizeMarginValue(value.top, fallback.top),
|
|
146
|
+
right: normalizeMarginValue(value.right, fallback.right),
|
|
147
|
+
bottom: normalizeMarginValue(value.bottom, fallback.bottom),
|
|
148
|
+
left: normalizeMarginValue(value.left, fallback.left),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function normalizeMarginValue(value: unknown, fallback: number | undefined): number | undefined {
|
|
153
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0) return fallback;
|
|
154
|
+
return Math.min(20, value);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function updateHudSettingFromArgs(settings: HudSettings, args: string): { settings: HudSettings; message: string } | undefined {
|
|
158
|
+
const [key, ...valueParts] = args.split(/\s+/);
|
|
159
|
+
const value = valueParts.join(" ").trim();
|
|
160
|
+
if (!key || value.length === 0) return undefined;
|
|
161
|
+
if (key === "position") {
|
|
162
|
+
const position = normalizePosition(value, settings.position);
|
|
163
|
+
if (position !== value) return undefined;
|
|
164
|
+
return { settings: { ...settings, position }, message: `HUD position set to ${position}. Reopen /hud if it is currently visible.` };
|
|
165
|
+
}
|
|
166
|
+
if (key === "shortcut" || key === "minimizeShortcut") {
|
|
167
|
+
const shortcut = normalizeShortcut(value, "");
|
|
168
|
+
if (shortcut.length === 0) return undefined;
|
|
169
|
+
return { settings: { ...settings, [key]: shortcut }, message: `HUD ${key} saved. Run /reload for the shortcut registration to change.` };
|
|
170
|
+
}
|
|
171
|
+
if (key === "autoCompactWhileStreaming") {
|
|
172
|
+
const enabled = parseBoolean(value);
|
|
173
|
+
if (enabled === undefined) return undefined;
|
|
174
|
+
return { settings: { ...settings, autoCompactWhileStreaming: enabled }, message: `HUD auto-compact ${enabled ? "enabled" : "disabled"}.` };
|
|
175
|
+
}
|
|
176
|
+
if (key === "expandedWidth" || key === "compactWidth" || key === "minTerminalWidth") {
|
|
177
|
+
const parsed = Number(value);
|
|
178
|
+
if (!Number.isInteger(parsed)) return undefined;
|
|
179
|
+
const updated = { ...settings, [key]: parsed };
|
|
180
|
+
return { settings: normalizeHudSettings(updated, settings), message: `HUD ${key} set to ${parsed}.` };
|
|
181
|
+
}
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function parseBoolean(value: string): boolean | undefined {
|
|
186
|
+
const normalized = value.toLowerCase();
|
|
187
|
+
if (["true", "on", "yes", "1", "enabled"].includes(normalized)) return true;
|
|
188
|
+
if (["false", "off", "no", "0", "disabled"].includes(normalized)) return false;
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function writeProjectHudSettings(cwd: string, hud: HudSettings): void {
|
|
193
|
+
const path = join(cwd, ".pi", "settings.json");
|
|
194
|
+
let root: Record<string, unknown> = {};
|
|
195
|
+
if (existsSync(path)) {
|
|
196
|
+
try {
|
|
197
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
198
|
+
if (isRecord(parsed)) root = { ...parsed };
|
|
199
|
+
} catch {
|
|
200
|
+
root = {};
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
root.hud = serializeHudSettings(hud);
|
|
204
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
205
|
+
writeFileSync(path, `${JSON.stringify(root, null, "\t")}\n`, "utf8");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function serializeHudSettings(settings: HudSettings): Record<string, unknown> {
|
|
209
|
+
return {
|
|
210
|
+
position: settings.position,
|
|
211
|
+
shortcut: settings.shortcut,
|
|
212
|
+
minimizeShortcut: settings.minimizeShortcut,
|
|
213
|
+
autoCompactWhileStreaming: settings.autoCompactWhileStreaming,
|
|
214
|
+
expandedWidth: settings.expandedWidth,
|
|
215
|
+
compactWidth: settings.compactWidth,
|
|
216
|
+
minTerminalWidth: settings.minTerminalWidth,
|
|
217
|
+
margin: settings.margin,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { OverlayAnchor, OverlayMargin } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
export interface SessionStats {
|
|
4
|
+
inputTokens: number;
|
|
5
|
+
outputTokens: number;
|
|
6
|
+
cacheReadTokens: number;
|
|
7
|
+
cacheWriteTokens: number;
|
|
8
|
+
totalTokens: number;
|
|
9
|
+
cost: number;
|
|
10
|
+
assistantMessages: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface AgentStatus {
|
|
14
|
+
running: number;
|
|
15
|
+
completed: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SubagentStatus {
|
|
19
|
+
running: number;
|
|
20
|
+
completed: number;
|
|
21
|
+
failed: number;
|
|
22
|
+
seen: boolean;
|
|
23
|
+
activeLabel?: string;
|
|
24
|
+
activeStartedAt?: number;
|
|
25
|
+
tokens: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SubagentRunCounts {
|
|
29
|
+
running: number;
|
|
30
|
+
completed: number;
|
|
31
|
+
failed: number;
|
|
32
|
+
tokens: number;
|
|
33
|
+
activeLabel?: string;
|
|
34
|
+
activeStartedAt?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ActiveSubagentToolRun {
|
|
38
|
+
label: string;
|
|
39
|
+
startedAt: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface HudSettings {
|
|
43
|
+
position: OverlayAnchor;
|
|
44
|
+
shortcut: string;
|
|
45
|
+
minimizeShortcut: string;
|
|
46
|
+
autoCompactWhileStreaming: boolean;
|
|
47
|
+
expandedWidth: number;
|
|
48
|
+
compactWidth: number;
|
|
49
|
+
minTerminalWidth: number;
|
|
50
|
+
margin: OverlayMargin;
|
|
51
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { HudSettings } from "../types/hud.js";
|
|
2
|
+
|
|
3
|
+
export function formatHudSettings(settings: HudSettings): string {
|
|
4
|
+
return `HUD position=${settings.position}, shortcut=${settings.shortcut || "disabled"}, minimizeShortcut=${settings.minimizeShortcut || "disabled"}, autoCompactWhileStreaming=${settings.autoCompactWhileStreaming}, expandedWidth=${settings.expandedWidth}, compactWidth=${settings.compactWidth}, minTerminalWidth=${settings.minTerminalWidth}`;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function formatShortcut(shortcut: string): string {
|
|
8
|
+
const trimmed = shortcut.trim();
|
|
9
|
+
return trimmed.length > 0 ? trimmed : "disabled";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function formatElapsed(startedAt: number | undefined): string {
|
|
13
|
+
const elapsedSeconds = startedAt ? Math.max(0, Math.floor((Date.now() - startedAt) / 1000)) : 0;
|
|
14
|
+
const minutes = Math.floor(elapsedSeconds / 60);
|
|
15
|
+
const seconds = elapsedSeconds % 60;
|
|
16
|
+
return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function formatNumber(value: number): string {
|
|
20
|
+
if (value < 1000) return value.toLocaleString();
|
|
21
|
+
if (value < 1_000_000) return `${(value / 1000).toFixed(1)}k`;
|
|
22
|
+
return `${(value / 1_000_000).toFixed(1)}m`;
|
|
23
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hud",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "A persistent hud for Pi with context, project, and subagent status.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"type": "git",
|
|
9
9
|
"url": "git+https://github.com/ludevdot/pi-hud.git"
|
|
10
10
|
},
|
|
11
|
+
"packageManager": "pnpm@10.30.2",
|
|
11
12
|
"keywords": [
|
|
12
13
|
"pi-package",
|
|
13
14
|
"pi",
|
|
@@ -19,8 +20,16 @@
|
|
|
19
20
|
"extensions",
|
|
20
21
|
"assets",
|
|
21
22
|
"README.md",
|
|
22
|
-
"LICENSE"
|
|
23
|
+
"LICENSE",
|
|
24
|
+
"scripts"
|
|
23
25
|
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "vitest --run",
|
|
28
|
+
"pack:dry": "pnpm pack --dry-run",
|
|
29
|
+
"verify:package": "node scripts/verify-package-files.mjs",
|
|
30
|
+
"prepack": "pnpm test && pnpm run verify:package",
|
|
31
|
+
"prepublishOnly": "pnpm test && pnpm run verify:package"
|
|
32
|
+
},
|
|
24
33
|
"pi": {
|
|
25
34
|
"extensions": [
|
|
26
35
|
"./extensions"
|
|
@@ -42,9 +51,5 @@
|
|
|
42
51
|
},
|
|
43
52
|
"engines": {
|
|
44
53
|
"node": ">=20.0.0"
|
|
45
|
-
},
|
|
46
|
-
"scripts": {
|
|
47
|
-
"test": "vitest --run",
|
|
48
|
-
"pack:dry": "pnpm pack --dry-run"
|
|
49
54
|
}
|
|
50
|
-
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, statSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
const root = join(fileURLToPath(new URL("..", import.meta.url)));
|
|
7
|
+
|
|
8
|
+
const requiredPaths = [
|
|
9
|
+
"assets/pi-hud.jpeg",
|
|
10
|
+
"assets/pi-hud-logo-only.jpg",
|
|
11
|
+
"extensions/config/hud-settings.ts",
|
|
12
|
+
"extensions/git/git.ts",
|
|
13
|
+
"extensions/hud.ts",
|
|
14
|
+
"extensions/mcp/mcp-adapter.ts",
|
|
15
|
+
"extensions/parsers/subagents.ts",
|
|
16
|
+
"extensions/settings/hud-settings.ts",
|
|
17
|
+
"extensions/types/hud.ts",
|
|
18
|
+
"extensions/utils/formatters.ts",
|
|
19
|
+
"extensions/utils/records.ts",
|
|
20
|
+
"LICENSE",
|
|
21
|
+
"README.md",
|
|
22
|
+
"package.json",
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const missing = requiredPaths.filter((relativePath) => {
|
|
26
|
+
const absolutePath = join(root, relativePath);
|
|
27
|
+
return !existsSync(absolutePath) || !statSync(absolutePath).isFile();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
if (missing.length > 0) {
|
|
31
|
+
console.error("pi-hud package is missing required Pi resources:");
|
|
32
|
+
for (const relativePath of missing) {
|
|
33
|
+
console.error(`- ${relativePath}`);
|
|
34
|
+
}
|
|
35
|
+
console.error("\nRefusing to pack/publish an incomplete npm package.");
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
console.log(
|
|
40
|
+
`pi-hud package resource check passed (${requiredPaths.length} files).`,
|
|
41
|
+
);
|