pi-shannon-statusline 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/README.md +78 -0
- package/package.json +19 -0
- package/shannon-statusline.png +0 -0
- package/src/__tests__/hud.test.ts +242 -0
- package/src/index.ts +505 -0
package/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
<img src="shannon-statusline.png" alt="pi-shannon-statusline preview" width="100%" />
|
|
4
|
+
|
|
5
|
+
# pi-shannon-statusline
|
|
6
|
+
|
|
7
|
+
**Cyberpunk terminal HUD for [Pi](https://github.com/earendil-works/pi-coding-agent)**
|
|
8
|
+
|
|
9
|
+
Ported from [shannon-statusline](https://github.com/RealAlexandreAI/shannon-statusline) (Claude Code).
|
|
10
|
+
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## What you get
|
|
16
|
+
|
|
17
|
+
A live cyberpunk HUD rendered below every Pi response:
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
⌘ ~/D/project │ ⎇ main* ↑2 !3 +1 │ ↺ loop ×12 │ ✦ 12m
|
|
21
|
+
↑ deepseek / deepseek-v4-pro │ ⊡ ████████░░░░ 65% (200k) │ ↑ 36k
|
|
22
|
+
※ ×3 AGENTS.md │ ⊕ ×4 MCPs │ ×5 skills
|
|
23
|
+
─────────────────────────────────────────────────────────────
|
|
24
|
+
✔ read ×12 │ ✔ edit ×7 │ ✔ bash ×4
|
|
25
|
+
↻ bash: src/index.ts (3s)
|
|
26
|
+
─────────────────────────────────────────────────────────────
|
|
27
|
+
↻ agent (3s) │ ✔ agent ×2
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Matrix katakana rain on the left. Monokai Pro palette. No config, no commands — plug and play.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pi install npm:pi-shannon-statusline
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Or from source:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
git clone https://github.com/RealAlexandreAI/pi-shannon-statusline.git
|
|
44
|
+
cd pi-shannon-statusline && pi install .
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Features
|
|
50
|
+
|
|
51
|
+
| Section | Content |
|
|
52
|
+
|---|---|
|
|
53
|
+
| **Project + Git** | CWD (fish-style abbreviation), branch, dirty, ahead/behind, file changes |
|
|
54
|
+
| **Turn count** | `↺ loop ×N` between git and duration |
|
|
55
|
+
| **Model + Context** | Provider / model name, context bar with percentage, token count |
|
|
56
|
+
| **Config counts** | AGENTS.md ×N, rules ×N, MCPs ×N, skills ×N |
|
|
57
|
+
| **Tool activity** | Completed tool counts, running tools with elapsed time |
|
|
58
|
+
| **Agent activity** | Running agent timer, completed agent count |
|
|
59
|
+
| **Matrix rain** | 6-column animated katakana rain, always on |
|
|
60
|
+
|
|
61
|
+
## Pi-native advantages
|
|
62
|
+
|
|
63
|
+
- **Live context API** — reads `getContextUsage()` directly, no stdin parsing
|
|
64
|
+
- **Widget rendering** — uses `ctx.ui.setWidget()` below the editor
|
|
65
|
+
- **Session-aware** — resets on `/new`, `/resume`, `/fork`
|
|
66
|
+
- **Model auto-detection** — updates on `model_select` event
|
|
67
|
+
- **Tool/agent tracking** — hooks `tool_call`, `tool_result`, `agent_start`, `agent_end`
|
|
68
|
+
- **Zero config** — install and go
|
|
69
|
+
|
|
70
|
+
## Testing
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
node --test src/__tests__/hud.test.ts
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
MIT — based on [shannon-statusline](https://github.com/RealAlexandreAI/shannon-statusline) (MIT)
|
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-shannon-statusline",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Cyberpunk terminal HUD for Pi \u2014 ported from shannon-statusline for Claude Code",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"pi": {
|
|
8
|
+
"extensions": [
|
|
9
|
+
"./src/index.ts"
|
|
10
|
+
]
|
|
11
|
+
},
|
|
12
|
+
"repository": "github:RealAlexandreAI/pi-shannon-statusline",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"pi-package",
|
|
15
|
+
"hud",
|
|
16
|
+
"statusline",
|
|
17
|
+
"cyberpunk"
|
|
18
|
+
]
|
|
19
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for pi-shannon-statusline HUD renderer.
|
|
3
|
+
* Run: node --test src/__tests__/hud.test.ts
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it } from "node:test";
|
|
7
|
+
import { strict as assert } from "node:assert";
|
|
8
|
+
|
|
9
|
+
// ═══════════════════════════════════════════════════════════════
|
|
10
|
+
// Copy of production render logic (pure, no fs/child_process deps)
|
|
11
|
+
// ═══════════════════════════════════════════════════════════════
|
|
12
|
+
|
|
13
|
+
const R = "\x1b[0m";
|
|
14
|
+
const D = "\x1b[2m";
|
|
15
|
+
const COMMENT = "\x1b[38;5;243m";
|
|
16
|
+
const GREEN = "\x1b[38;5;154m";
|
|
17
|
+
const CYAN = "\x1b[38;5;123m";
|
|
18
|
+
const PURPLE = "\x1b[38;5;141m";
|
|
19
|
+
const YELLOW = "\x1b[38;5;221m";
|
|
20
|
+
const I_RUN = "↻";
|
|
21
|
+
|
|
22
|
+
function c(text: string, color: string) { return `${color}${text}${R}`; }
|
|
23
|
+
|
|
24
|
+
function fmtDuration(ms: number): string {
|
|
25
|
+
if (ms < 1000) return `${ms}ms`;
|
|
26
|
+
const s = ms / 1000;
|
|
27
|
+
if (s < 60) return `${s.toFixed(0)}s`;
|
|
28
|
+
const m = Math.floor(s / 60);
|
|
29
|
+
if (m < 60) return `${m}m ${Math.round(s % 60)}s`;
|
|
30
|
+
const h = Math.floor(m / 60);
|
|
31
|
+
return `${h}h ${m % 60}m`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function fmtTokens(n: number): string {
|
|
35
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
36
|
+
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
|
|
37
|
+
return `${n}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface ToolRecord {
|
|
41
|
+
name: string; status: "running" | "completed" | "error";
|
|
42
|
+
startTime: number; endTime?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface AgentRecord {
|
|
46
|
+
status: "running" | "completed";
|
|
47
|
+
startTime: number; endTime?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ═══════════════════════════════════════════════════════════════
|
|
51
|
+
// Tool count rendering (extracted from buildHud)
|
|
52
|
+
// ═══════════════════════════════════════════════════════════════
|
|
53
|
+
|
|
54
|
+
function renderToolCounts(tools: ToolRecord[]): string[] {
|
|
55
|
+
const sep = `${COMMENT}│${R}`;
|
|
56
|
+
const completed = tools.filter(t => t.status === "completed");
|
|
57
|
+
const toolCounts = new Map<string, number>();
|
|
58
|
+
for (const t of completed) toolCounts.set(t.name, (toolCounts.get(t.name) ?? 0) + 1);
|
|
59
|
+
|
|
60
|
+
const parts: string[] = [];
|
|
61
|
+
for (const name of ["read", "edit", "write", "bash", "grep", "find", "ls"]) {
|
|
62
|
+
const count = toolCounts.get(name) ?? 0;
|
|
63
|
+
if (count > 0) parts.push(`${GREEN} ${c(name, R.replace("\x1b[0m", ""))}${count > 1 ? ` ${c(`×${count}`, COMMENT)}` : ""}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (parts.length === 0) return [];
|
|
67
|
+
|
|
68
|
+
// stand-in for FG color
|
|
69
|
+
const FG = "\x1b[38;5;252m";
|
|
70
|
+
// rebuild with proper color
|
|
71
|
+
const rebuilt: string[] = [];
|
|
72
|
+
for (const name of ["read", "edit", "write", "bash", "grep", "find", "ls"]) {
|
|
73
|
+
const count = toolCounts.get(name) ?? 0;
|
|
74
|
+
if (count > 0) rebuilt.push(`${GREEN} ${c(name, FG)}${count > 1 ? ` ${c(`×${count}`, COMMENT)}` : ""}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return [`${COMMENT}${"─".repeat(67)}${R}`, rebuilt.join(` ${sep} `)];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function renderAgentActivity(agents: AgentRecord[]): string[] {
|
|
81
|
+
const sep = `${COMMENT}│${R}`;
|
|
82
|
+
const agentRunning = agents.filter(a => a.status === "running");
|
|
83
|
+
const agentCompleted = agents.filter(a => a.status === "completed");
|
|
84
|
+
|
|
85
|
+
if (agentRunning.length === 0 && agentCompleted.length === 0) return [];
|
|
86
|
+
|
|
87
|
+
const lines: string[] = [];
|
|
88
|
+
lines.push(`${COMMENT}${"─".repeat(67)}${R}`);
|
|
89
|
+
|
|
90
|
+
const parts: string[] = [];
|
|
91
|
+
for (const a of agentRunning) {
|
|
92
|
+
parts.push(`${c(I_RUN, YELLOW)} ${c("agent", PURPLE)} ${c(`(${fmtDuration(Date.now() - a.startTime)})`, COMMENT)}`);
|
|
93
|
+
}
|
|
94
|
+
if (agentCompleted.length > 0) {
|
|
95
|
+
parts.push(`${c("agent", PURPLE)} ${GREEN} ${c(`×${agentCompleted.length}`, COMMENT)}`);
|
|
96
|
+
}
|
|
97
|
+
lines.push(parts.join(` ${sep} `));
|
|
98
|
+
return lines;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Strip ANSI for assertion readability
|
|
102
|
+
function stripANSI(s: string): string {
|
|
103
|
+
return s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ═══════════════════════════════════════════════════════════════
|
|
107
|
+
// Tests
|
|
108
|
+
// ═══════════════════════════════════════════════════════════════
|
|
109
|
+
|
|
110
|
+
describe("fmtTokens", () => {
|
|
111
|
+
it("formats < 1000 as plain number", () => { assert.equal(fmtTokens(0), "0"); assert.equal(fmtTokens(999), "999"); });
|
|
112
|
+
it("formats k", () => { assert.equal(fmtTokens(1500), "1.5k"); assert.equal(fmtTokens(9999), "10.0k"); });
|
|
113
|
+
it("formats M", () => { assert.equal(fmtTokens(1_500_000), "1.5M"); });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("fmtDuration", () => {
|
|
117
|
+
it("formats ms", () => { assert.equal(fmtDuration(500), "500ms"); });
|
|
118
|
+
it("formats seconds", () => { assert.equal(fmtDuration(3500), "4s"); });
|
|
119
|
+
it("formats minutes", () => { assert.equal(fmtDuration(125000), "2m 5s"); });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("renderToolCounts", () => {
|
|
123
|
+
it("returns empty for no completed tools", () => {
|
|
124
|
+
assert.deepStrictEqual(renderToolCounts([]), []);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("shows single tool without ×1", () => {
|
|
128
|
+
const lines = renderToolCounts([
|
|
129
|
+
{ name: "read", status: "completed", startTime: 0 },
|
|
130
|
+
], "cyberpunk");
|
|
131
|
+
const text = lines.map(stripANSI).join("\n");
|
|
132
|
+
assert.ok(text.includes("read"), `should include read: ${text}`);
|
|
133
|
+
assert.ok(!text.includes("×1"), `should not include ×1: ${text}`);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("shows ×N for multiple uses", () => {
|
|
137
|
+
const lines = renderToolCounts([
|
|
138
|
+
{ name: "read", status: "completed", startTime: 0 },
|
|
139
|
+
{ name: "read", status: "completed", startTime: 1 },
|
|
140
|
+
{ name: "bash", status: "completed", startTime: 2 },
|
|
141
|
+
{ name: "bash", status: "completed", startTime: 3 },
|
|
142
|
+
{ name: "bash", status: "completed", startTime: 4 },
|
|
143
|
+
], "cyberpunk");
|
|
144
|
+
const text = lines.map(stripANSI).join("\n");
|
|
145
|
+
assert.ok(text.includes("×2"), `should have ×2: ${text}`);
|
|
146
|
+
assert.ok(text.includes("×3"), `should have ×3: ${text}`);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("shows only completed, not running/error", () => {
|
|
150
|
+
const lines = renderToolCounts([
|
|
151
|
+
{ name: "read", status: "completed", startTime: 0 },
|
|
152
|
+
{ name: "bash", status: "running", startTime: 1 },
|
|
153
|
+
{ name: "grep", status: "error", startTime: 2 },
|
|
154
|
+
], "cyberpunk");
|
|
155
|
+
const text = lines.map(stripANSI).join("\n");
|
|
156
|
+
assert.ok(text.includes("read"), `should include completed read: ${text}`);
|
|
157
|
+
assert.ok(!text.includes("bash"), `should not include running bash: ${text}`);
|
|
158
|
+
assert.ok(!text.includes("grep"), `should not include error grep: ${text}`);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("shows all tracked tool names in order", () => {
|
|
162
|
+
const lines = renderToolCounts([
|
|
163
|
+
{ name: "read", status: "completed", startTime: 0 },
|
|
164
|
+
{ name: "edit", status: "completed", startTime: 1 },
|
|
165
|
+
{ name: "write", status: "completed", startTime: 2 },
|
|
166
|
+
{ name: "bash", status: "completed", startTime: 3 },
|
|
167
|
+
{ name: "grep", status: "completed", startTime: 4 },
|
|
168
|
+
{ name: "find", status: "completed", startTime: 5 },
|
|
169
|
+
{ name: "ls", status: "completed", startTime: 6 },
|
|
170
|
+
], "cyberpunk");
|
|
171
|
+
const text = lines.map(stripANSI).join("\n");
|
|
172
|
+
const names = ["read", "edit", "write", "bash", "grep", "find", "ls"];
|
|
173
|
+
let lastIdx = -1;
|
|
174
|
+
for (const name of names) {
|
|
175
|
+
const idx = text.indexOf(name);
|
|
176
|
+
assert.ok(idx > lastIdx, `${name} should appear after previous (${lastIdx}), got ${idx}\n${text}`);
|
|
177
|
+
lastIdx = idx;
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("separator is 67 chars", () => {
|
|
182
|
+
const lines = renderToolCounts([{ name: "read", status: "completed", startTime: 0 }]);
|
|
183
|
+
const sep = stripANSI(lines[0]!);
|
|
184
|
+
assert.equal(sep.length, 67, `separator should be 67 chars, got ${sep.length}: "${sep}"`);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("renderAgentActivity", () => {
|
|
189
|
+
it("returns empty for no agents", () => {
|
|
190
|
+
assert.deepStrictEqual(renderAgentActivity([]), []);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("shows running agent with timer", () => {
|
|
194
|
+
const now = Date.now();
|
|
195
|
+
const lines = renderAgentActivity([
|
|
196
|
+
{ status: "running", startTime: now - 3000 },
|
|
197
|
+
], "cyberpunk");
|
|
198
|
+
const text = lines.map(stripANSI).join("\n");
|
|
199
|
+
assert.ok(text.includes("agent"), `should include agent: ${text}`);
|
|
200
|
+
assert.ok(text.includes("↻"), `should include spinner: ${text}`);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("shows completed count with agent label", () => {
|
|
204
|
+
const lines = renderAgentActivity([
|
|
205
|
+
{ status: "completed", startTime: 0, endTime: 1000 },
|
|
206
|
+
{ status: "completed", startTime: 0, endTime: 2000 },
|
|
207
|
+
], "cyberpunk");
|
|
208
|
+
const text = lines.map(stripANSI).join("\n");
|
|
209
|
+
assert.ok(text.includes("agent"), `should include agent label: ${text}`);
|
|
210
|
+
assert.ok(text.includes("×2"), `should include ×2: ${text}`);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("shows agent label even when only completed (no running)", () => {
|
|
214
|
+
const lines = renderAgentActivity([
|
|
215
|
+
{ status: "completed", startTime: 0 },
|
|
216
|
+
], "cyberpunk");
|
|
217
|
+
const text = lines.map(stripANSI).join("\n");
|
|
218
|
+
assert.ok(text.includes("agent"), `should include agent even with only completed: ${text}`);
|
|
219
|
+
assert.ok(text.includes("×1"), `should include ×1: ${text}`);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("merges running + completed into one line", () => {
|
|
223
|
+
const now = Date.now();
|
|
224
|
+
const lines = renderAgentActivity([
|
|
225
|
+
{ status: "running", startTime: now - 5000 },
|
|
226
|
+
{ status: "completed", startTime: 0, endTime: 1000 },
|
|
227
|
+
], "cyberpunk");
|
|
228
|
+
const text = lines.map(stripANSI).join("\n");
|
|
229
|
+
// All agent content should be in one line (after separator)
|
|
230
|
+
const dataLine = lines[1] ? stripANSI(lines[1]) : "";
|
|
231
|
+
assert.ok(dataLine.includes("agent"), "data line should include agent");
|
|
232
|
+
assert.ok(dataLine.includes("×1"), "data line should include ×1");
|
|
233
|
+
// Running agent text should be on same line
|
|
234
|
+
assert.ok(dataLine.includes("↻"), "data line should include running indicator");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("separator is 67 chars for agent section", () => {
|
|
238
|
+
const lines = renderAgentActivity([{ status: "completed", startTime: 0 }]);
|
|
239
|
+
const sep = stripANSI(lines[0]!);
|
|
240
|
+
assert.equal(sep.length, 67, `separator should be 67 chars, got ${sep.length}`);
|
|
241
|
+
});
|
|
242
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-shannon-statusline — Cyberpunk HUD for Pi
|
|
3
|
+
* Ported from shannon-statusline (Claude Code).
|
|
4
|
+
*/
|
|
5
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { readdirSync, existsSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
13
|
+
|
|
14
|
+
// ═══════════════════════════════════════════════════════════════
|
|
15
|
+
// Types
|
|
16
|
+
// ═══════════════════════════════════════════════════════════════
|
|
17
|
+
|
|
18
|
+
interface GitStatus {
|
|
19
|
+
branch: string;
|
|
20
|
+
isDirty: boolean;
|
|
21
|
+
ahead: number;
|
|
22
|
+
behind: number;
|
|
23
|
+
modified: number;
|
|
24
|
+
added: number;
|
|
25
|
+
deleted: number;
|
|
26
|
+
untracked: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface AgentRecord {
|
|
30
|
+
status: "running" | "completed";
|
|
31
|
+
startTime: number;
|
|
32
|
+
endTime?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface ToolRecord {
|
|
36
|
+
name: string;
|
|
37
|
+
target: string | null;
|
|
38
|
+
status: "running" | "completed" | "error";
|
|
39
|
+
startTime: number;
|
|
40
|
+
endTime?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ═══════════════════════════════════════════════════════════════
|
|
44
|
+
// State
|
|
45
|
+
// ═══════════════════════════════════════════════════════════════
|
|
46
|
+
|
|
47
|
+
let sessionStartTime = 0;
|
|
48
|
+
let turnIndex = 0;
|
|
49
|
+
let tools: ToolRecord[] = [];
|
|
50
|
+
let agents: AgentRecord[] = [];
|
|
51
|
+
let modelProvider = "";
|
|
52
|
+
let modelId = "";
|
|
53
|
+
let cwd = "";
|
|
54
|
+
|
|
55
|
+
// ═══════════════════════════════════════════════════════════════
|
|
56
|
+
// ANSI palette
|
|
57
|
+
// ═══════════════════════════════════════════════════════════════
|
|
58
|
+
|
|
59
|
+
const R = "\x1b[0m";
|
|
60
|
+
const D = "\x1b[2m";
|
|
61
|
+
|
|
62
|
+
function rgb(r: number, g: number, b: number) { return `\x1b[38;2;${r};${g};${b}m`; }
|
|
63
|
+
|
|
64
|
+
// Monokai Pro
|
|
65
|
+
const FG = "\x1b[38;5;252m";
|
|
66
|
+
const COMMENT = "\x1b[38;5;243m";
|
|
67
|
+
const PINK = "\x1b[38;5;198m";
|
|
68
|
+
const GREEN = "\x1b[38;5;154m";
|
|
69
|
+
const ORANGE = "\x1b[38;5;208m";
|
|
70
|
+
const CYAN = "\x1b[38;5;123m";
|
|
71
|
+
const PURPLE = "\x1b[38;5;141m";
|
|
72
|
+
const YELLOW = "\x1b[38;5;221m";
|
|
73
|
+
|
|
74
|
+
function c(text: string, color: string) { return `${color}${text}${R}`; }
|
|
75
|
+
function dim(text: string) { return `${D}${text}${R}`; }
|
|
76
|
+
|
|
77
|
+
// Icons
|
|
78
|
+
const I_PATH = "⌘";
|
|
79
|
+
const I_BRANCH = "⎇";
|
|
80
|
+
const I_CLOCK = "✦";
|
|
81
|
+
const I_CTX = "⊡";
|
|
82
|
+
const I_IN = "↑";
|
|
83
|
+
const I_DONE = "✔";
|
|
84
|
+
const I_RUN = "↻";
|
|
85
|
+
const I_CLAUDE = "※";
|
|
86
|
+
const I_MCP = "⊕";
|
|
87
|
+
|
|
88
|
+
// ═══════════════════════════════════════════════════════════════
|
|
89
|
+
// Fish-style path shortening (from original shannon-statusline)
|
|
90
|
+
// ═══════════════════════════════════════════════════════════════
|
|
91
|
+
|
|
92
|
+
function abbreviateSegment(segment: string): string {
|
|
93
|
+
if (segment.length <= 1) return segment;
|
|
94
|
+
const extra = segment.match(/[-.](.)/);
|
|
95
|
+
return extra ? `${segment[0]}${extra[0]}` : segment[0];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function truncateTailSegment(segment: string, maxLen: number): string {
|
|
99
|
+
if (segment.length <= maxLen) return segment;
|
|
100
|
+
if (maxLen <= 1) return "…";
|
|
101
|
+
const extStart = segment.lastIndexOf(".");
|
|
102
|
+
const hasExt = extStart > 0 && extStart < segment.length - 1;
|
|
103
|
+
if (!hasExt) return `…${segment.slice(-(maxLen - 1))}`;
|
|
104
|
+
const ext = segment.slice(extStart);
|
|
105
|
+
const base = segment.slice(0, extStart);
|
|
106
|
+
const budget = maxLen - ext.length - 1;
|
|
107
|
+
if (budget <= 0) return `…${ext.slice(-(maxLen - 1))}`;
|
|
108
|
+
return `…${base.slice(-budget)}${ext}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function shortenDisplayPath(fullPath: string, home: string, maxLen: number): string {
|
|
112
|
+
if (!fullPath) return "";
|
|
113
|
+
let display = fullPath;
|
|
114
|
+
if (home && fullPath === home) return "~";
|
|
115
|
+
if (home && fullPath.startsWith(home + "/")) {
|
|
116
|
+
display = "~" + fullPath.slice(home.length);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const prefix = display.startsWith("~") ? "~" : display.startsWith("/") ? "/" : "";
|
|
120
|
+
const rawParts = display.split("/").filter(Boolean);
|
|
121
|
+
const parts = prefix === "~" ? rawParts.slice(1) : rawParts;
|
|
122
|
+
if (parts.length <= 1) return display;
|
|
123
|
+
|
|
124
|
+
const tail = parts.slice(-1);
|
|
125
|
+
const head = parts.slice(0, -1).map(abbreviateSegment);
|
|
126
|
+
let shortened = [...head, ...tail].join("/");
|
|
127
|
+
if (prefix) shortened = prefix + "/" + shortened;
|
|
128
|
+
|
|
129
|
+
if (shortened.length <= maxLen) return shortened;
|
|
130
|
+
|
|
131
|
+
const ellipsis = prefix + "/…/" + tail.join("/");
|
|
132
|
+
if (ellipsis.length <= maxLen) return ellipsis;
|
|
133
|
+
|
|
134
|
+
const budget = Math.max(1, maxLen - (prefix ? prefix.length + 4 : 3));
|
|
135
|
+
return `${prefix ? prefix + "/" : ""}…/${truncateTailSegment(tail[0]!, budget)}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ═══════════════════════════════════════════════════════════════
|
|
139
|
+
// Context bar
|
|
140
|
+
// ═══════════════════════════════════════════════════════════════
|
|
141
|
+
|
|
142
|
+
function ctxBar(percent: number, width: number): string {
|
|
143
|
+
const safeP = Math.min(100, Math.max(0, percent));
|
|
144
|
+
const filled = Math.round((safeP / 100) * width);
|
|
145
|
+
const empty = width - filled;
|
|
146
|
+
|
|
147
|
+
let r0: number, g0: number, b0: number;
|
|
148
|
+
let r1: number, g1: number, b1: number;
|
|
149
|
+
if (safeP >= 85) { [r0, g0, b0] = [90, 0, 48]; [r1, g1, b1] = [255, 0, 144]; }
|
|
150
|
+
else if (safeP >= 70) { [r0, g0, b0] = [122, 21, 0]; [r1, g1, b1] = [255, 107, 0]; }
|
|
151
|
+
else { [r0, g0, b0] = [0, 51, 0]; [r1, g1, b1] = [57, 255, 20]; }
|
|
152
|
+
|
|
153
|
+
const cells: string[] = [];
|
|
154
|
+
for (let i = 0; i < filled; i++) {
|
|
155
|
+
const t = filled > 1 ? i / (filled - 1) : 1;
|
|
156
|
+
cells.push(`${rgb(Math.round(r0 + (r1 - r0) * t), Math.round(g0 + (g1 - g0) * t), Math.round(b0 + (b1 - b0) * t))}█`);
|
|
157
|
+
}
|
|
158
|
+
return `${cells.join("")}${D}${"░".repeat(empty)}${R}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function ctxPctColor(percent: number): string {
|
|
162
|
+
if (percent >= 85) return rgb(255, 0, 144);
|
|
163
|
+
if (percent >= 70) return rgb(255, 107, 0);
|
|
164
|
+
return rgb(57, 255, 20);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ═══════════════════════════════════════════════════════════════
|
|
168
|
+
// Formatters
|
|
169
|
+
// ═══════════════════════════════════════════════════════════════
|
|
170
|
+
|
|
171
|
+
function fmtTokens(n: number): string {
|
|
172
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
173
|
+
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
|
|
174
|
+
return `${n}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function fmtDuration(ms: number): string {
|
|
178
|
+
if (ms < 1000) return `${ms}ms`;
|
|
179
|
+
const s = ms / 1000;
|
|
180
|
+
if (s < 60) return `${s.toFixed(0)}s`;
|
|
181
|
+
const m = Math.floor(s / 60);
|
|
182
|
+
if (m < 60) return `${m}m ${Math.round(s % 60)}s`;
|
|
183
|
+
const h = Math.floor(m / 60);
|
|
184
|
+
return `${h}h ${m % 60}m`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ═══════════════════════════════════════════════════════════════
|
|
188
|
+
// Matrix rain (6 columns like original)
|
|
189
|
+
// ═══════════════════════════════════════════════════════════════
|
|
190
|
+
|
|
191
|
+
const RAIN_CHARS = "ヲァィゥェォャュョッーアイウエオカキクケコサシスセソ0123456789λΨΩΔΦ";
|
|
192
|
+
const RAIN_COLS = 6;
|
|
193
|
+
const RAIN_SPEED_MS = 900;
|
|
194
|
+
const RAIN_COL_OFFSET_MS = 280;
|
|
195
|
+
|
|
196
|
+
function rainCell(row: number, col: number, now: number, total: number): string {
|
|
197
|
+
const colPhase = ((now + col * RAIN_COL_OFFSET_MS) / RAIN_SPEED_MS) % total;
|
|
198
|
+
const headRow = Math.floor(colPhase);
|
|
199
|
+
const dist = (row - headRow + total) % total;
|
|
200
|
+
const ch = RAIN_CHARS[Math.floor(now / 350 + row * 7 + col * 13) % RAIN_CHARS.length] ?? " ";
|
|
201
|
+
|
|
202
|
+
if (dist === 0) return `${rgb(200, 255, 200)}${ch}${R}`;
|
|
203
|
+
if (dist === 1) return `${rgb(57, 255, 20)}${ch}${R}`;
|
|
204
|
+
if (dist === 2) return `${rgb(0, 200, 0)}${ch}${R}`;
|
|
205
|
+
if (dist === 3) return `${rgb(0, 160, 0)}${ch}${R}`;
|
|
206
|
+
if (dist === 4) return `${rgb(0, 100, 0)}${ch}${R}`;
|
|
207
|
+
if (dist === total - 1) return `${rgb(20, 20, 20)}${ch}${R}`;
|
|
208
|
+
if (dist === total - 2) return `${rgb(0, 0, 0)}${ch}${R}`;
|
|
209
|
+
return `${rgb(8, 8, 8)}${ch}${R}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function makeRain(row: number, total: number): string {
|
|
213
|
+
// rain always on, no early return
|
|
214
|
+
const now = Date.now();
|
|
215
|
+
const cells: string[] = [];
|
|
216
|
+
for (let c = 0; c < RAIN_COLS; c++) cells.push(rainCell(row, c, now, total));
|
|
217
|
+
return `${cells.join(" ")} `;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ═══════════════════════════════════════════════════════════════
|
|
221
|
+
// Git
|
|
222
|
+
// ═══════════════════════════════════════════════════════════════
|
|
223
|
+
|
|
224
|
+
async function getGit(dir: string): Promise<GitStatus | null> {
|
|
225
|
+
if (!dir) return null;
|
|
226
|
+
try {
|
|
227
|
+
const { stdout: branchOut } = await execFileAsync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
228
|
+
cwd: dir, timeout: 1500, encoding: "utf8",
|
|
229
|
+
});
|
|
230
|
+
const branch = branchOut.trim();
|
|
231
|
+
if (!branch) return null;
|
|
232
|
+
|
|
233
|
+
let isDirty = false, modified = 0, added = 0, deleted = 0, untracked = 0;
|
|
234
|
+
try {
|
|
235
|
+
const { stdout: statusOut } = await execFileAsync("git", ["--no-optional-locks", "status", "--porcelain"], {
|
|
236
|
+
cwd: dir, timeout: 1500, encoding: "utf8",
|
|
237
|
+
});
|
|
238
|
+
const lines = statusOut.trim().split("\n").filter(Boolean);
|
|
239
|
+
isDirty = lines.length > 0;
|
|
240
|
+
for (const line of lines) {
|
|
241
|
+
if (line.startsWith("??")) untracked++;
|
|
242
|
+
else if (line[0] === "A") added++;
|
|
243
|
+
else if (line[0] === "D" || line[1] === "D") deleted++;
|
|
244
|
+
else if (line[0] === "M" || line[1] === "M" || line[0] === "R" || line[0] === "C") modified++;
|
|
245
|
+
}
|
|
246
|
+
} catch { /* ignore */ }
|
|
247
|
+
|
|
248
|
+
let ahead = 0, behind = 0;
|
|
249
|
+
try {
|
|
250
|
+
const { stdout: revOut } = await execFileAsync("git", ["rev-list", "--left-right", "--count", "@{upstream}...HEAD"], {
|
|
251
|
+
cwd: dir, timeout: 1500, encoding: "utf8",
|
|
252
|
+
});
|
|
253
|
+
const parts = revOut.trim().split(/\s+/);
|
|
254
|
+
if (parts.length === 2) { behind = parseInt(parts[0]!, 10) || 0; ahead = parseInt(parts[1]!, 10) || 0; }
|
|
255
|
+
} catch { /* no upstream */ }
|
|
256
|
+
|
|
257
|
+
return { branch, isDirty, ahead, behind, modified, added, deleted, untracked };
|
|
258
|
+
} catch { return null; }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ═══════════════════════════════════════════════════════════════
|
|
262
|
+
// Config counter
|
|
263
|
+
// ═══════════════════════════════════════════════════════════════
|
|
264
|
+
|
|
265
|
+
function countConfigs(dir: string) {
|
|
266
|
+
let claudeMd = 0, rules = 0, mcps = 0, skills = 0;
|
|
267
|
+
try {
|
|
268
|
+
if (existsSync(join(dir, "AGENTS.md"))) claudeMd++;
|
|
269
|
+
if (existsSync(join(dir, "CLAUDE.md"))) claudeMd++;
|
|
270
|
+
if (existsSync(join(dir, ".claude", "CLAUDE.md"))) claudeMd++;
|
|
271
|
+
if (existsSync(join(dir, ".pi", "agent", "AGENTS.md"))) claudeMd++;
|
|
272
|
+
|
|
273
|
+
const rulesDir = join(dir, ".claude", "rules");
|
|
274
|
+
if (existsSync(rulesDir)) rules = readdirSync(rulesDir).filter(f => f.endsWith(".md")).length;
|
|
275
|
+
|
|
276
|
+
if (existsSync(join(dir, ".mcp.json"))) mcps++;
|
|
277
|
+
if (existsSync(join(dir, ".config", "mcp", "mcp.json"))) mcps++;
|
|
278
|
+
|
|
279
|
+
const skillsDir = join(dir, ".claude", "skills");
|
|
280
|
+
if (existsSync(skillsDir)) skills = readdirSync(skillsDir, { recursive: true }).filter(f => f.endsWith("SKILL.md")).length;
|
|
281
|
+
} catch { /* ignore */ }
|
|
282
|
+
return { claudeMd, rules, mcps, skills };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ═══════════════════════════════════════════════════════════════
|
|
286
|
+
// HUD Renderer
|
|
287
|
+
// ═══════════════════════════════════════════════════════════════
|
|
288
|
+
|
|
289
|
+
async function buildHud(ctx: any): Promise<string[]> {
|
|
290
|
+
const lines: string[] = [];
|
|
291
|
+
const dir = cwd;
|
|
292
|
+
const sep = `${COMMENT}│${R}`;
|
|
293
|
+
|
|
294
|
+
// ── Line 1: Project + Git + Duration ──
|
|
295
|
+
const parts1: string[] = [];
|
|
296
|
+
if (dir) {
|
|
297
|
+
const home = homedir();
|
|
298
|
+
parts1.push(`${c(I_PATH, ORANGE)} ${c(shortenDisplayPath(dir, home, 30), ORANGE)}`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const git = await getGit(dir);
|
|
302
|
+
if (git) {
|
|
303
|
+
const dirty = git.isDirty ? "*" : "";
|
|
304
|
+
const branchColor = CYAN;
|
|
305
|
+
let gitStr = `${c(I_BRANCH, branchColor)} ${c(`${git.branch}${dirty}`, branchColor)}`;
|
|
306
|
+
const details: string[] = [];
|
|
307
|
+
if (git.ahead > 0) details.push(c(`↑${git.ahead}`, GREEN));
|
|
308
|
+
if (git.behind > 0) details.push(c(`↓${git.behind}`, PINK));
|
|
309
|
+
if (git.modified > 0) details.push(c(`!${git.modified}`, PINK));
|
|
310
|
+
if (git.added > 0) details.push(c(`+${git.added}`, GREEN));
|
|
311
|
+
if (git.deleted > 0) details.push(c(`✘${git.deleted}`, PINK));
|
|
312
|
+
if (git.untracked > 0) details.push(c(`?${git.untracked}`, COMMENT));
|
|
313
|
+
if (details.length > 0) gitStr += ` ${details.join(" ")}`;
|
|
314
|
+
parts1.push(gitStr);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (sessionStartTime > 0) {
|
|
318
|
+
// Turn count before duration
|
|
319
|
+
if (turnIndex > 0) parts1.push(`${c(`↺ loop`, PURPLE)} ${c(`×${turnIndex}`, FG)}`);
|
|
320
|
+
parts1.push(`${c(I_CLOCK, COMMENT)} ${c(fmtDuration(Date.now() - sessionStartTime), COMMENT)}`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
lines.push(parts1.join(` ${sep} `));
|
|
324
|
+
|
|
325
|
+
// ── Line 2: Model (provider/id) + Thinking level + Context + Tokens ──
|
|
326
|
+
const modelColor = CYAN;
|
|
327
|
+
const providerColor = COMMENT;
|
|
328
|
+
let modelStr: string;
|
|
329
|
+
if (modelProvider && modelId) {
|
|
330
|
+
modelStr = `${c(I_IN, modelColor)} ${c(modelProvider, providerColor)}${dim("/")}${c(modelId, modelColor)}`;
|
|
331
|
+
} else if (modelId) {
|
|
332
|
+
modelStr = `${c(I_IN, modelColor)} ${c(modelId, modelColor)}`;
|
|
333
|
+
} else if (modelProvider) {
|
|
334
|
+
modelStr = `${c(I_IN, modelColor)} ${c(modelProvider, modelColor)}`;
|
|
335
|
+
} else {
|
|
336
|
+
modelStr = `${c(I_IN, modelColor)} ${c("pi", modelColor)}`;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Thinking level removed — Pi doesn't expose real-time value in event context
|
|
340
|
+
// (getThinkingLevel() only on command ctx, ctx.model has no current level)
|
|
341
|
+
|
|
342
|
+
let ctxStr = "";
|
|
343
|
+
try {
|
|
344
|
+
const usage = ctx.getContextUsage?.();
|
|
345
|
+
if (usage) {
|
|
346
|
+
const pct = usage.percent ?? 0;
|
|
347
|
+
const bar = ctxBar(pct, 10);
|
|
348
|
+
const win = usage.contextWindow ?? 0;
|
|
349
|
+
const winLabel = win >= 1_000_000 ? `${(win / 1_000_000).toFixed(1)}M` : win >= 1000 ? `${Math.round(win / 1000)}k` : "";
|
|
350
|
+
ctxStr = `${c(I_CTX, CYAN)} ${bar} ${c(`${pct.toFixed(1)}%`, ctxPctColor(pct))}`;
|
|
351
|
+
if (winLabel) ctxStr += ` ${dim(`(${winLabel})`)}`;
|
|
352
|
+
|
|
353
|
+
const totalTokens = usage.tokens ?? 0;
|
|
354
|
+
let tokStr = `${c(I_IN, CYAN)} ${c(fmtTokens(totalTokens), FG)}`;
|
|
355
|
+
|
|
356
|
+
const line2 = `${modelStr} ${sep} ${ctxStr} ${sep} ${tokStr}`;
|
|
357
|
+
lines.push(line2);
|
|
358
|
+
} else {
|
|
359
|
+
lines.push(modelStr);
|
|
360
|
+
}
|
|
361
|
+
} catch { lines.push(modelStr); }
|
|
362
|
+
|
|
363
|
+
// ── Line 3: Config counts ──
|
|
364
|
+
const configs = countConfigs(dir);
|
|
365
|
+
const cfgParts: string[] = [];
|
|
366
|
+
if (configs.claudeMd > 0) cfgParts.push(`${c(I_CLAUDE, ORANGE)} ${c(`×${configs.claudeMd}`, ORANGE)} ${dim("AGENTS.md")}`);
|
|
367
|
+
if (configs.rules > 0) cfgParts.push(`${c(`×${configs.rules}`, COMMENT)} ${dim("rules")}`);
|
|
368
|
+
if (configs.mcps > 0) cfgParts.push(`${c(I_MCP, CYAN)} ${c(`×${configs.mcps}`, CYAN)} ${dim("MCPs")}`);
|
|
369
|
+
if (configs.skills > 0) cfgParts.push(`${c(`×${configs.skills}`, PURPLE)} ${dim("skills")}`);
|
|
370
|
+
if (cfgParts.length > 0) lines.push(cfgParts.join(` ${sep} `));
|
|
371
|
+
|
|
372
|
+
// ── Separator + Tool counts ──
|
|
373
|
+
const completed = tools.filter(t => t.status === "completed");
|
|
374
|
+
const toolCounts = new Map<string, number>();
|
|
375
|
+
for (const t of completed) toolCounts.set(t.name, (toolCounts.get(t.name) ?? 0) + 1);
|
|
376
|
+
|
|
377
|
+
const toolLineParts: string[] = [];
|
|
378
|
+
for (const name of ["read", "edit", "write", "bash", "grep", "find", "ls"]) {
|
|
379
|
+
const count = toolCounts.get(name) ?? 0;
|
|
380
|
+
if (count > 0) toolLineParts.push(`${GREEN} ${c(name, FG)}${count > 1 ? ` ${c(`×${count}`, COMMENT)}` : ""}`);
|
|
381
|
+
}
|
|
382
|
+
if (toolLineParts.length > 0) {
|
|
383
|
+
lines.push(`${COMMENT}${"─".repeat(67)}${R}`);
|
|
384
|
+
lines.push(toolLineParts.join(` ${sep} `));
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ── Running tools ──
|
|
388
|
+
const running = tools.filter(t => t.status === "running");
|
|
389
|
+
for (const t of running.slice(-2)) {
|
|
390
|
+
const elapsed = fmtDuration(Date.now() - t.startTime);
|
|
391
|
+
const target = t.target ? `: ${shortenDisplayPath(t.target, homedir(), 22)}` : "";
|
|
392
|
+
lines.push(`${c(I_RUN, YELLOW)} ${c(t.name, CYAN)}${target} ${c(`(${elapsed})`, COMMENT)}`);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ── Agent activity ──
|
|
396
|
+
const agentRunning = agents.filter(a => a.status === "running");
|
|
397
|
+
const agentCompleted = agents.filter(a => a.status === "completed");
|
|
398
|
+
if (agentRunning.length > 0 || agentCompleted.length > 0) {
|
|
399
|
+
lines.push(`${COMMENT}${"─".repeat(67)}${R}`);
|
|
400
|
+
const parts: string[] = [];
|
|
401
|
+
for (const a of agentRunning) {
|
|
402
|
+
parts.push(`${c(I_RUN, YELLOW)} ${c("agent", PURPLE)} ${c(`(${fmtDuration(Date.now() - a.startTime)})`, COMMENT)}`);
|
|
403
|
+
}
|
|
404
|
+
if (agentCompleted.length > 0) {
|
|
405
|
+
parts.push(`${c("agent", PURPLE)} ${GREEN} ${c(`×${agentCompleted.length}`, COMMENT)}`);
|
|
406
|
+
}
|
|
407
|
+
lines.push(parts.join(` ${sep} `));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ── Matrix rain (if enabled) ──
|
|
411
|
+
if (true) { // rain always on
|
|
412
|
+
const total = lines.length;
|
|
413
|
+
for (let i = 0; i < total; i++) {
|
|
414
|
+
lines[i] = `${makeRain(i, total)}${lines[i]}`;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return lines;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ═══════════════════════════════════════════════════════════════
|
|
422
|
+
// HUD refresh
|
|
423
|
+
// ═══════════════════════════════════════════════════════════════
|
|
424
|
+
|
|
425
|
+
function refreshHud(ctx: any) {
|
|
426
|
+
buildHud(ctx).then(lines => {
|
|
427
|
+
if (lines.length > 0) ctx.ui.setWidget("shannon-hud", lines, { placement: "belowEditor" });
|
|
428
|
+
}).catch(() => {});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// ═══════════════════════════════════════════════════════════════
|
|
432
|
+
// Extension entry
|
|
433
|
+
// ═══════════════════════════════════════════════════════════════
|
|
434
|
+
|
|
435
|
+
export default function (pi: ExtensionAPI) {
|
|
436
|
+
// No slash commands — always-on cyberpunk HUD
|
|
437
|
+
|
|
438
|
+
// ── Events ──
|
|
439
|
+
pi.on("session_start", (_event, ctx) => {
|
|
440
|
+
sessionStartTime = Date.now();
|
|
441
|
+
turnIndex = 0;
|
|
442
|
+
cwd = ctx.cwd;
|
|
443
|
+
tools = [];
|
|
444
|
+
if (ctx.model) {
|
|
445
|
+
modelProvider = (ctx.model as any).provider ?? "";
|
|
446
|
+
modelId = (ctx.model as any).id ?? "";
|
|
447
|
+
}
|
|
448
|
+
refreshHud(ctx);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
pi.on("model_select", (event, ctx) => {
|
|
452
|
+
if (event.model) {
|
|
453
|
+
modelProvider = (event.model as any).provider ?? "";
|
|
454
|
+
modelId = (event.model as any).id ?? "";
|
|
455
|
+
}
|
|
456
|
+
refreshHud(ctx);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
pi.on("thinking_level_select", (_event) => {
|
|
460
|
+
// Event fires on manual change; no real-time read API in ExtensionContext
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
pi.on("turn_start", (_event, ctx) => {
|
|
464
|
+
turnIndex = (_event as any).turnIndex ?? (turnIndex + 1);
|
|
465
|
+
refreshHud(ctx);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
pi.on("tool_call", (event, ctx) => {
|
|
469
|
+
const tool: ToolRecord = { name: event.toolName, target: null, status: "running", startTime: Date.now() };
|
|
470
|
+
if (event.input && typeof event.input === "object") {
|
|
471
|
+
const inp = event.input as Record<string, unknown>;
|
|
472
|
+
if (typeof inp.path === "string") tool.target = inp.path;
|
|
473
|
+
else if (typeof inp.filePath === "string") tool.target = inp.filePath;
|
|
474
|
+
}
|
|
475
|
+
tools.push(tool);
|
|
476
|
+
refreshHud(ctx);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
pi.on("tool_result", (event, ctx) => {
|
|
480
|
+
for (let i = tools.length - 1; i >= 0; i--) {
|
|
481
|
+
if (tools[i]!.name === event.toolName && tools[i]!.status === "running") {
|
|
482
|
+
tools[i]!.status = event.isError ? "error" : "completed";
|
|
483
|
+
tools[i]!.endTime = Date.now();
|
|
484
|
+
break;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
refreshHud(ctx);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
pi.on("turn_end", (_event, ctx) => refreshHud(ctx));
|
|
491
|
+
pi.on("agent_end", (_event, ctx) => {
|
|
492
|
+
// Mark running agents as completed
|
|
493
|
+
for (const a of agents) {
|
|
494
|
+
if (a.status === "running") {
|
|
495
|
+
a.status = "completed";
|
|
496
|
+
a.endTime = Date.now();
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
refreshHud(ctx);
|
|
500
|
+
});
|
|
501
|
+
pi.on("agent_start", (_event, ctx) => {
|
|
502
|
+
agents.push({ status: "running", startTime: Date.now() });
|
|
503
|
+
refreshHud(ctx);
|
|
504
|
+
});
|
|
505
|
+
}
|