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 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
+ }