@agnishc/edb-global-footer 0.8.1 → 0.10.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/package.json +1 -1
- package/src/footer.ts +104 -32
- package/src/icons.ts +36 -0
- package/src/index.ts +36 -2
- package/src/tps.ts +109 -0
- package/src/workingIndicator.ts +747 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.10.3] - 2026-05-15
|
|
4
|
+
|
|
5
|
+
## [0.9.0] - 2026-05-15
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **Working indicator** (`src/workingIndicator.ts`) — animated status line shown while the agent is processing
|
|
9
|
+
- Claude-style spinner frames (`· ✢ ✳ ✶ ✻ ✽`) at 150ms
|
|
10
|
+
- 162 rotating verbs ("Reticulating...", "Boondoggling...") with 2s rotation
|
|
11
|
+
- Per-character shimmer effect — accent color sweep across the verb text
|
|
12
|
+
- Live elapsed timer ticking every second
|
|
13
|
+
- Tool suffix with Nerd Font icon + rotating witty label (5–10 phrases per tool, 2s rotation)
|
|
14
|
+
- Completion message on agent end: `✓ · Crystallized · 23s`, shown for 3s then cleared
|
|
15
|
+
- Full tool coverage: `bash`, `read`, `write`, `edit`, `ls`, `find`, `grep`, `TaskCreate`, `TaskList`, `TaskGet`, `TaskUpdate`, `TaskOutput`, `TaskStop`, `Agent`, `get_subagent_result`, `steer_subagent`, `ask_user`
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- Cache read/write indicators now use Nerd Font icons (`` / ``) instead of plain `R`/`W` text, with `R`/`W` fallback when Nerd Fonts are unavailable
|
|
19
|
+
|
|
20
|
+
## [0.8.2] - 2026-05-11
|
|
21
|
+
|
|
3
22
|
## [0.8.1] - 2026-05-11
|
|
4
23
|
|
|
5
24
|
## [0.6.0] - 2026-05-11
|
package/package.json
CHANGED
package/src/footer.ts
CHANGED
|
@@ -8,15 +8,39 @@ import {
|
|
|
8
8
|
sanitizeStatusText,
|
|
9
9
|
shortenPath,
|
|
10
10
|
} from "./format";
|
|
11
|
+
import { iconCacheRead, iconCacheWrite, iconGit, iconThink, withIcon } from "./icons";
|
|
12
|
+
import { formatTps } from "./tps";
|
|
11
13
|
import type { GitStatus } from "./types";
|
|
12
14
|
|
|
15
|
+
// ── Status blacklist ──────────────────────────────────────────────────────────
|
|
16
|
+
// Keys in this set will not be shown in the extension statuses line (line 3).
|
|
17
|
+
// Common uses:
|
|
18
|
+
// - "extmgr" - package manager status ("15 pkgs • auto update off")
|
|
19
|
+
// - Keys for extensions whose status you want to hide
|
|
20
|
+
const STATUS_KEY_BLACKLIST = new Set<string>(["extmgr"]);
|
|
21
|
+
|
|
22
|
+
// ── Live state types ───────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
interface TpsState {
|
|
25
|
+
isStreaming: boolean;
|
|
26
|
+
tps: number;
|
|
27
|
+
lastOutputTokens: number;
|
|
28
|
+
lastInputTokens: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
13
31
|
// ── Footer renderer factory ────────────────────────────────────────────────────
|
|
14
32
|
|
|
15
33
|
/**
|
|
16
34
|
* Returns the footer descriptor object accepted by ctx.ui.setFooter().
|
|
17
35
|
* Encapsulates all render logic for the two-line status footer.
|
|
18
36
|
*/
|
|
19
|
-
export function createFooterRenderer(
|
|
37
|
+
export function createFooterRenderer(
|
|
38
|
+
ctx: any,
|
|
39
|
+
getGitStatus: () => GitStatus | null,
|
|
40
|
+
requestRender: () => void,
|
|
41
|
+
hasNerdFonts: boolean,
|
|
42
|
+
getTpsState: () => TpsState,
|
|
43
|
+
) {
|
|
20
44
|
return (_tui: any, theme: any, footerData: any) => {
|
|
21
45
|
const unsub = footerData.onBranchChange(() => {
|
|
22
46
|
requestRender();
|
|
@@ -29,35 +53,45 @@ export function createFooterRenderer(ctx: any, getGitStatus: () => GitStatus | n
|
|
|
29
53
|
invalidate() {},
|
|
30
54
|
render(width: number): string[] {
|
|
31
55
|
const sep = theme.fg("dim", " · ");
|
|
56
|
+
const groupSep = theme.fg("dim", " | ");
|
|
32
57
|
const sessionName = ctx.sessionManager.getSessionName();
|
|
33
58
|
const gitStatus = getGitStatus();
|
|
59
|
+
const tpsState = getTpsState();
|
|
34
60
|
|
|
35
|
-
// ── Line 1: path · branch
|
|
36
|
-
const
|
|
61
|
+
// ── Line 1: path · branch | session name ─────────────────────
|
|
62
|
+
const leftParts: string[] = [theme.fg("accent", shortenPath(ctx.cwd))];
|
|
37
63
|
|
|
38
64
|
if (gitStatus?.branch) {
|
|
39
65
|
let gitText = theme.fg(gitStatus.dirty ? "warning" : "success", gitStatus.branch);
|
|
40
66
|
if (gitStatus.dirty) gitText += theme.fg("warning", " *");
|
|
41
67
|
if (gitStatus.ahead) gitText += theme.fg("success", ` ↑${gitStatus.ahead}`);
|
|
42
68
|
if (gitStatus.behind) gitText += theme.fg("error", ` ↓${gitStatus.behind}`);
|
|
43
|
-
|
|
69
|
+
leftParts.push(gitText);
|
|
44
70
|
} else {
|
|
45
71
|
const branch = footerData.getGitBranch();
|
|
46
|
-
if (branch)
|
|
72
|
+
if (branch) {
|
|
73
|
+
const branchText = hasNerdFonts ? withIcon(iconGit, branch) : branch;
|
|
74
|
+
leftParts.push(theme.fg("muted", branchText));
|
|
75
|
+
}
|
|
47
76
|
}
|
|
48
77
|
|
|
49
|
-
|
|
50
|
-
|
|
78
|
+
const rightParts: string[] = [];
|
|
79
|
+
if (sessionName) rightParts.push(theme.fg("dim", theme.italic(sessionName)));
|
|
80
|
+
const locationLine = renderFooterLine(width, leftParts.join(sep), rightParts.join(sep));
|
|
81
|
+
|
|
82
|
+
// ── Line 2: token stats · tps · model ────────────────────────
|
|
51
83
|
|
|
52
|
-
//
|
|
84
|
+
// Aggregate token stats from all persisted entries
|
|
53
85
|
let totalInput = 0;
|
|
54
86
|
let totalOutput = 0;
|
|
55
87
|
let totalCacheRead = 0;
|
|
56
88
|
let totalCacheWrite = 0;
|
|
57
89
|
let totalCost = 0;
|
|
90
|
+
|
|
58
91
|
for (const entry of ctx.sessionManager.getEntries()) {
|
|
59
92
|
if (entry.type === "message" && entry.message.role === "assistant") {
|
|
60
93
|
const msg = entry.message as AssistantMessage;
|
|
94
|
+
if (msg.stopReason === "error" || msg.stopReason === "aborted") continue;
|
|
61
95
|
totalInput += msg.usage.input;
|
|
62
96
|
totalOutput += msg.usage.output;
|
|
63
97
|
totalCacheRead += msg.usage.cacheRead;
|
|
@@ -66,51 +100,89 @@ export function createFooterRenderer(ctx: any, getGitStatus: () => GitStatus | n
|
|
|
66
100
|
}
|
|
67
101
|
}
|
|
68
102
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
103
|
+
// Build three groups separated by pipes
|
|
104
|
+
// Group 1: ↑input ↓output ⚡tps
|
|
105
|
+
// Group 2: Rcache Wcache $cost
|
|
106
|
+
// Group 3: context%
|
|
107
|
+
const { tps } = tpsState;
|
|
108
|
+
const showTps = tps > 0;
|
|
109
|
+
|
|
110
|
+
// Group 1: Read/Write + TPS
|
|
111
|
+
const group1Parts: string[] = [];
|
|
112
|
+
if (totalInput) group1Parts.push(`↑${formatTokens(totalInput)}`);
|
|
113
|
+
if (totalOutput) group1Parts.push(`↓${formatTokens(totalOutput)}`);
|
|
114
|
+
if (showTps) group1Parts.push(theme.fg("accent", `⚡${formatTps(tps)}`));
|
|
115
|
+
const group1 = group1Parts.length > 0 ? group1Parts.join(" ") : null;
|
|
74
116
|
|
|
117
|
+
// Group 2: Cache + Price
|
|
118
|
+
const group2Parts: string[] = [];
|
|
119
|
+
if (totalCacheRead) group2Parts.push(`${iconCacheRead}${formatTokens(totalCacheRead)}`);
|
|
120
|
+
if (totalCacheWrite) group2Parts.push(`${iconCacheWrite}${formatTokens(totalCacheWrite)}`);
|
|
75
121
|
const usingSubscription = ctx.model ? ctx.modelRegistry.isUsingOAuth(ctx.model) : false;
|
|
76
122
|
if (totalCost || usingSubscription) {
|
|
77
|
-
|
|
123
|
+
group2Parts.push(`$${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`);
|
|
78
124
|
}
|
|
125
|
+
const group2 = group2Parts.length > 0 ? group2Parts.join(" ") : null;
|
|
79
126
|
|
|
127
|
+
// Group 3: Context usage
|
|
80
128
|
const contextUsage = ctx.getContextUsage();
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
129
|
+
const contextWindow = contextUsage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
|
|
130
|
+
let group3: string | null = null;
|
|
131
|
+
|
|
132
|
+
if (contextUsage && contextUsage.tokens !== null && contextWindow > 0) {
|
|
133
|
+
const percent =
|
|
134
|
+
contextUsage.percent !== null
|
|
135
|
+
? contextUsage.percent.toFixed(1)
|
|
136
|
+
: ((contextUsage.tokens / contextWindow) * 100).toFixed(1);
|
|
137
|
+
const contextText = `${percent}%/${formatTokens(contextWindow)}`;
|
|
138
|
+
const percentValue = contextUsage.percent ?? (contextUsage.tokens / contextWindow) * 100;
|
|
139
|
+
if (percentValue > 90) group3 = theme.fg("error", contextText);
|
|
140
|
+
else if (percentValue > 70) group3 = theme.fg("warning", contextText);
|
|
141
|
+
else group3 = contextText;
|
|
142
|
+
} else if (contextUsage && contextUsage.percent !== null) {
|
|
143
|
+
const percent = contextUsage.percent.toFixed(1);
|
|
144
|
+
const contextText = `${percent}%/${formatTokens(contextWindow)}`;
|
|
145
|
+
if (contextUsage.percent > 90) group3 = theme.fg("error", contextText);
|
|
146
|
+
else if (contextUsage.percent > 70) group3 = theme.fg("warning", contextText);
|
|
147
|
+
else group3 = contextText;
|
|
89
148
|
}
|
|
90
|
-
const leftStats = statsParts.join(" ");
|
|
91
149
|
|
|
150
|
+
// Combine groups with pipes
|
|
151
|
+
const groups = [group1, group2, group3].filter((g) => g !== null) as string[];
|
|
152
|
+
const leftStats = groups.join(groupSep);
|
|
153
|
+
|
|
154
|
+
// Model + thinking level with icons
|
|
92
155
|
const model = ctx.model;
|
|
93
|
-
const
|
|
156
|
+
const rightPartsLine2: string[] = [];
|
|
94
157
|
if (model) {
|
|
95
158
|
if (model.provider && footerData.getAvailableProviderCount() > 1) {
|
|
96
|
-
|
|
159
|
+
rightPartsLine2.push(theme.fg("muted", `(${model.provider})`));
|
|
97
160
|
}
|
|
98
161
|
let modelText = theme.bold(model.id || "unknown");
|
|
99
162
|
if (model.reasoning) {
|
|
100
163
|
const thinking = formatThinkingLabel(getThinkingLevel(ctx));
|
|
101
|
-
if (thinking)
|
|
164
|
+
if (thinking) {
|
|
165
|
+
const thinkLabel = hasNerdFonts ? withIcon(iconThink, thinking) : thinking;
|
|
166
|
+
modelText += `${sep}${theme.fg("dim", thinkLabel)}`;
|
|
167
|
+
}
|
|
102
168
|
}
|
|
103
|
-
|
|
169
|
+
rightPartsLine2.push(modelText);
|
|
104
170
|
}
|
|
105
|
-
const statsLine = renderFooterLine(width, leftStats,
|
|
171
|
+
const statsLine = renderFooterLine(width, leftStats, rightPartsLine2.join(sep));
|
|
106
172
|
|
|
107
173
|
// ── Line 3 (optional): extension statuses ─────────────────────
|
|
108
174
|
const lines = [locationLine, statsLine];
|
|
109
175
|
const extensionStatuses = footerData.getExtensionStatuses();
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
176
|
+
// Filter out blacklisted status keys
|
|
177
|
+
const visibleStatuses = new Map<string, string>(
|
|
178
|
+
Array.from(extensionStatuses.entries() as [string, string][]).filter(
|
|
179
|
+
([key]) => !STATUS_KEY_BLACKLIST.has(key),
|
|
180
|
+
),
|
|
181
|
+
);
|
|
182
|
+
if (visibleStatuses.size > 0) {
|
|
183
|
+
const statusLine = Array.from(visibleStatuses.values())
|
|
184
|
+
.map((text) => sanitizeStatusText(text))
|
|
185
|
+
.sort((a, b) => a.localeCompare(b))
|
|
114
186
|
.join(" ");
|
|
115
187
|
lines.push(truncateToWidth(statusLine, width, theme.fg("dim", "...")));
|
|
116
188
|
}
|
package/src/icons.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// ── Nerd Font detection ────────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Detect if the terminal supports Nerd Fonts.
|
|
5
|
+
* Checks environment variables and common terminal identifiers.
|
|
6
|
+
*/
|
|
7
|
+
export function hasNerdFonts(): boolean {
|
|
8
|
+
if (process.env.POWERLINE_NERD_FONTS === "1") return true;
|
|
9
|
+
if (process.env.POWERLINE_NERD_FONTS === "0") return false;
|
|
10
|
+
if (process.env.GHOSTTY_RESOURCES_DIR) return true;
|
|
11
|
+
const term = (process.env.TERM_PROGRAM || "").toLowerCase();
|
|
12
|
+
return ["iterm", "wezterm", "kitty", "ghostty", "alacritty"].some((t) => term.includes(t));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const NERD = hasNerdFonts();
|
|
16
|
+
|
|
17
|
+
// ── Icons (fallback to text symbols when Nerd Fonts unavailable) ─────────────
|
|
18
|
+
|
|
19
|
+
/** Git branch icon */
|
|
20
|
+
export const iconGit = NERD ? "\uF7A1" : ""; // or fallback to empty (branch name is clear enough)
|
|
21
|
+
|
|
22
|
+
/** Thinking/lightning icon (shown with thinking level) */
|
|
23
|
+
export const iconThink = NERD ? "\uF0E7" : ""; // or fallback to empty
|
|
24
|
+
|
|
25
|
+
/** Cache read icon ( nf-md-cached — circular arrows) */
|
|
26
|
+
export const iconCacheRead = NERD ? "\uDB82\uDE7A" : "R"; // or fallback to R
|
|
27
|
+
|
|
28
|
+
/** Cache write icon ( nf-md-cloud-upload) */
|
|
29
|
+
export const iconCacheWrite = NERD ? "\uDB84\uDC1A" : "W"; // or fallback to W
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Wrap text with an icon prefix if nerd fonts are available.
|
|
33
|
+
*/
|
|
34
|
+
export function withIcon(icon: string, text: string): string {
|
|
35
|
+
return icon ? `${icon} ${text}` : text;
|
|
36
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -3,24 +3,32 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Renders a two-line status footer for every session:
|
|
5
5
|
* Line 1 — working directory · git branch (dirty/ahead/behind) · session name
|
|
6
|
-
* Line 2 — token usage · context window % · model name · thinking level
|
|
6
|
+
* Line 2 — token usage · tps · context window % · model name · thinking level
|
|
7
7
|
* Line 3 — extension statuses (when any are active)
|
|
8
8
|
*
|
|
9
9
|
* Git status is refreshed after every turn and on branch change events.
|
|
10
|
+
* Token stats update in real-time during streaming.
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
13
14
|
import { createFooterRenderer } from "./footer";
|
|
14
15
|
import { readGitStatus } from "./git";
|
|
16
|
+
import { hasNerdFonts } from "./icons";
|
|
17
|
+
import { TpsCalculator } from "./tps";
|
|
15
18
|
import type { GitStatus } from "./types";
|
|
19
|
+
import { installWorkingIndicator } from "./workingIndicator";
|
|
16
20
|
|
|
17
21
|
// ── Extension ──────────────────────────────────────────────────────────────────
|
|
18
22
|
|
|
19
23
|
export default function globalFooterExtension(pi: ExtensionAPI): void {
|
|
24
|
+
installWorkingIndicator(pi);
|
|
20
25
|
let tuiRef: { requestRender: () => void } | null = null;
|
|
21
26
|
let gitStatus: GitStatus | null = null;
|
|
22
27
|
let currentCwd = process.cwd();
|
|
23
28
|
|
|
29
|
+
// TPS calculator for live throughput
|
|
30
|
+
const tpsCalculator = new TpsCalculator();
|
|
31
|
+
|
|
24
32
|
const requestRender = () => tuiRef?.requestRender();
|
|
25
33
|
const refreshGit = () => {
|
|
26
34
|
gitStatus = readGitStatus(currentCwd);
|
|
@@ -34,7 +42,13 @@ export default function globalFooterExtension(pi: ExtensionAPI): void {
|
|
|
34
42
|
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
35
43
|
tuiRef = tui;
|
|
36
44
|
|
|
37
|
-
const descriptor = createFooterRenderer(
|
|
45
|
+
const descriptor = createFooterRenderer(
|
|
46
|
+
ctx,
|
|
47
|
+
() => gitStatus,
|
|
48
|
+
requestRender,
|
|
49
|
+
hasNerdFonts(),
|
|
50
|
+
() => tpsCalculator.getState(),
|
|
51
|
+
)(tui, theme, footerData);
|
|
38
52
|
|
|
39
53
|
return {
|
|
40
54
|
dispose() {
|
|
@@ -57,4 +71,24 @@ export default function globalFooterExtension(pi: ExtensionAPI): void {
|
|
|
57
71
|
currentCwd = ctx.cwd;
|
|
58
72
|
requestRender();
|
|
59
73
|
});
|
|
74
|
+
|
|
75
|
+
// ── TPS tracking during streaming ─────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
pi.on("turn_start", () => {
|
|
78
|
+
tpsCalculator.resetForTurn();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
pi.on("message_update", (event) => {
|
|
82
|
+
if (!tuiRef) return;
|
|
83
|
+
if (event.message.role !== "assistant") return;
|
|
84
|
+
tpsCalculator.onMessageUpdate(event.message);
|
|
85
|
+
requestRender();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
pi.on("message_end", (event) => {
|
|
89
|
+
if (!tuiRef) return;
|
|
90
|
+
if (event.message.role !== "assistant") return;
|
|
91
|
+
tpsCalculator.onMessageEnd(event.message);
|
|
92
|
+
requestRender();
|
|
93
|
+
});
|
|
60
94
|
}
|
package/src/tps.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token Per Second (TPS) Calculator
|
|
3
|
+
*
|
|
4
|
+
* Tracks streaming throughput and calculates TPS in real-time.
|
|
5
|
+
* - During streaming: estimates TPS from character count / 4
|
|
6
|
+
* - On completion: uses actual output tokens from message usage
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
10
|
+
|
|
11
|
+
// ── Types ────────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export interface TpsState {
|
|
14
|
+
/** Whether we're currently streaming */
|
|
15
|
+
isStreaming: boolean;
|
|
16
|
+
/** Estimated TPS during streaming, actual after completion */
|
|
17
|
+
tps: number;
|
|
18
|
+
/** Total output tokens from last message */
|
|
19
|
+
lastOutputTokens: number;
|
|
20
|
+
/** Total input tokens from last message */
|
|
21
|
+
lastInputTokens: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ── Calculator ───────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
export class TpsCalculator {
|
|
27
|
+
private streaming = false;
|
|
28
|
+
private streamStart = 0;
|
|
29
|
+
private tps = 0;
|
|
30
|
+
private lastOutputTokens = 0;
|
|
31
|
+
private lastInputTokens = 0;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Reset state at the start of a new turn.
|
|
35
|
+
*/
|
|
36
|
+
resetForTurn(): void {
|
|
37
|
+
this.streaming = false;
|
|
38
|
+
this.streamStart = 0;
|
|
39
|
+
this.tps = 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Called when streaming starts (first message_update).
|
|
44
|
+
*/
|
|
45
|
+
streamStarted(): void {
|
|
46
|
+
this.streaming = true;
|
|
47
|
+
this.streamStart = performance.now();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Called on each message_update during streaming.
|
|
52
|
+
*/
|
|
53
|
+
onMessageUpdate(message: AssistantMessage): void {
|
|
54
|
+
if (!this.streaming) {
|
|
55
|
+
this.streamStarted();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let chars = 0;
|
|
59
|
+
for (const block of message.content) {
|
|
60
|
+
if (block.type === "text") chars += block.text.length;
|
|
61
|
+
else if (block.type === "thinking") chars += block.thinking.length;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const elapsed = (performance.now() - this.streamStart) / 1000;
|
|
65
|
+
if (elapsed > 0.1) {
|
|
66
|
+
const estimatedTokens = chars / 4;
|
|
67
|
+
this.tps = estimatedTokens / elapsed;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Called on message_end to finalize TPS with actual usage.
|
|
73
|
+
*/
|
|
74
|
+
onMessageEnd(message: AssistantMessage): void {
|
|
75
|
+
const elapsed = this.streamStart > 0 ? (performance.now() - this.streamStart) / 1000 : 0;
|
|
76
|
+
const outputTokens = message.usage?.output ?? 0;
|
|
77
|
+
this.lastInputTokens = message.usage?.input ?? 0;
|
|
78
|
+
this.lastOutputTokens = outputTokens;
|
|
79
|
+
|
|
80
|
+
if (elapsed > 0.1 && outputTokens > 0) {
|
|
81
|
+
this.tps = outputTokens / elapsed;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
this.streaming = false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Get current TPS state for rendering.
|
|
89
|
+
*/
|
|
90
|
+
getState(): TpsState {
|
|
91
|
+
return {
|
|
92
|
+
isStreaming: this.streaming,
|
|
93
|
+
tps: this.tps,
|
|
94
|
+
lastOutputTokens: this.lastOutputTokens,
|
|
95
|
+
lastInputTokens: this.lastInputTokens,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Formatting ───────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Format TPS number for display.
|
|
104
|
+
*/
|
|
105
|
+
export function formatTps(tps: number): string {
|
|
106
|
+
if (tps < 1) return "0";
|
|
107
|
+
if (tps < 10) return tps.toFixed(1);
|
|
108
|
+
return tps.toFixed(0);
|
|
109
|
+
}
|
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Working Indicator
|
|
3
|
+
*
|
|
4
|
+
* Displays an animated working message while the agent is processing:
|
|
5
|
+
* ✳ Reticulating... · running bash · 4s
|
|
6
|
+
*
|
|
7
|
+
* On completion, briefly shows a static completion verb + time taken:
|
|
8
|
+
* ✓ Crystallized · 23s
|
|
9
|
+
*
|
|
10
|
+
* Features:
|
|
11
|
+
* - Claude-style spinner frames (· ✢ ✳ ✶ ✻ ✽) at 150ms
|
|
12
|
+
* - Random verb from Claude's list, rotates every 2s
|
|
13
|
+
* - Per-character shimmer (accent color sweep) across the verb
|
|
14
|
+
* - Active tool suffix (e.g. "running bash", "editing file")
|
|
15
|
+
* - Elapsed timer ticking every 1s
|
|
16
|
+
* - Completion verb (past-tense) + total time, shown for 3s then cleared
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
|
|
21
|
+
// ── Spinner frames (Claude Code style) ────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
const SPINNER_FRAMES = ["·", "✢", "✳", "✶", "✻", "✽"] as const;
|
|
24
|
+
const SPINNER_INTERVAL_MS = 150;
|
|
25
|
+
|
|
26
|
+
// ── Verbs ─────────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
const VERBS: string[] = [
|
|
29
|
+
"Accomplishing",
|
|
30
|
+
"Actioning",
|
|
31
|
+
"Actualizing",
|
|
32
|
+
"Architecting",
|
|
33
|
+
"Baking",
|
|
34
|
+
"Beaming",
|
|
35
|
+
"Beboppin'",
|
|
36
|
+
"Befuddling",
|
|
37
|
+
"Billowing",
|
|
38
|
+
"Blanching",
|
|
39
|
+
"Bloviating",
|
|
40
|
+
"Boogieing",
|
|
41
|
+
"Boondoggling",
|
|
42
|
+
"Booping",
|
|
43
|
+
"Bootstrapping",
|
|
44
|
+
"Brewing",
|
|
45
|
+
"Bunning",
|
|
46
|
+
"Burrowing",
|
|
47
|
+
"Calculating",
|
|
48
|
+
"Canoodling",
|
|
49
|
+
"Caramelizing",
|
|
50
|
+
"Cascading",
|
|
51
|
+
"Catapulting",
|
|
52
|
+
"Cerebrating",
|
|
53
|
+
"Channeling",
|
|
54
|
+
"Choreographing",
|
|
55
|
+
"Churning",
|
|
56
|
+
"Coalescing",
|
|
57
|
+
"Cogitating",
|
|
58
|
+
"Combobulating",
|
|
59
|
+
"Composing",
|
|
60
|
+
"Computing",
|
|
61
|
+
"Concocting",
|
|
62
|
+
"Considering",
|
|
63
|
+
"Contemplating",
|
|
64
|
+
"Cooking",
|
|
65
|
+
"Crafting",
|
|
66
|
+
"Creating",
|
|
67
|
+
"Crunching",
|
|
68
|
+
"Crystallizing",
|
|
69
|
+
"Cultivating",
|
|
70
|
+
"Deciphering",
|
|
71
|
+
"Deliberating",
|
|
72
|
+
"Determining",
|
|
73
|
+
"Dilly-dallying",
|
|
74
|
+
"Discombobulating",
|
|
75
|
+
"Doing",
|
|
76
|
+
"Doodling",
|
|
77
|
+
"Drizzling",
|
|
78
|
+
"Ebbing",
|
|
79
|
+
"Elucidating",
|
|
80
|
+
"Embellishing",
|
|
81
|
+
"Enchanting",
|
|
82
|
+
"Envisioning",
|
|
83
|
+
"Fermenting",
|
|
84
|
+
"Fiddle-faddling",
|
|
85
|
+
"Finagling",
|
|
86
|
+
"Flowing",
|
|
87
|
+
"Flummoxing",
|
|
88
|
+
"Forging",
|
|
89
|
+
"Forming",
|
|
90
|
+
"Frolicking",
|
|
91
|
+
"Gallivanting",
|
|
92
|
+
"Galloping",
|
|
93
|
+
"Generating",
|
|
94
|
+
"Gesticulating",
|
|
95
|
+
"Germinating",
|
|
96
|
+
"Gitifying",
|
|
97
|
+
"Grooving",
|
|
98
|
+
"Harmonizing",
|
|
99
|
+
"Hashing",
|
|
100
|
+
"Hatching",
|
|
101
|
+
"Herding",
|
|
102
|
+
"Hullaballooing",
|
|
103
|
+
"Hyperspacing",
|
|
104
|
+
"Ideating",
|
|
105
|
+
"Imagining",
|
|
106
|
+
"Improvising",
|
|
107
|
+
"Incubating",
|
|
108
|
+
"Inferring",
|
|
109
|
+
"Infusing",
|
|
110
|
+
"Jitterbugging",
|
|
111
|
+
"Kneading",
|
|
112
|
+
"Leavening",
|
|
113
|
+
"Levitating",
|
|
114
|
+
"Lollygagging",
|
|
115
|
+
"Manifesting",
|
|
116
|
+
"Marinating",
|
|
117
|
+
"Meandering",
|
|
118
|
+
"Metamorphosing",
|
|
119
|
+
"Moonwalking",
|
|
120
|
+
"Moseying",
|
|
121
|
+
"Mulling",
|
|
122
|
+
"Mustering",
|
|
123
|
+
"Musing",
|
|
124
|
+
"Nebulizing",
|
|
125
|
+
"Nesting",
|
|
126
|
+
"Noodling",
|
|
127
|
+
"Orbiting",
|
|
128
|
+
"Orchestrating",
|
|
129
|
+
"Perambulating",
|
|
130
|
+
"Percolating",
|
|
131
|
+
"Perusing",
|
|
132
|
+
"Philosophising",
|
|
133
|
+
"Pollinating",
|
|
134
|
+
"Pondering",
|
|
135
|
+
"Pontificating",
|
|
136
|
+
"Precipitating",
|
|
137
|
+
"Prestidigitating",
|
|
138
|
+
"Processing",
|
|
139
|
+
"Propagating",
|
|
140
|
+
"Puttering",
|
|
141
|
+
"Puzzling",
|
|
142
|
+
"Razzle-dazzling",
|
|
143
|
+
"Razzmatazzing",
|
|
144
|
+
"Recombobulating",
|
|
145
|
+
"Reticulating",
|
|
146
|
+
"Roosting",
|
|
147
|
+
"Ruminating",
|
|
148
|
+
"Scampering",
|
|
149
|
+
"Schlepping",
|
|
150
|
+
"Scurrying",
|
|
151
|
+
"Seasoning",
|
|
152
|
+
"Shenaniganing",
|
|
153
|
+
"Shimmying",
|
|
154
|
+
"Simmering",
|
|
155
|
+
"Skedaddling",
|
|
156
|
+
"Sketching",
|
|
157
|
+
"Spelunking",
|
|
158
|
+
"Spinning",
|
|
159
|
+
"Sprouting",
|
|
160
|
+
"Stewing",
|
|
161
|
+
"Sublimating",
|
|
162
|
+
"Swirling",
|
|
163
|
+
"Swooping",
|
|
164
|
+
"Synthesizing",
|
|
165
|
+
"Tempering",
|
|
166
|
+
"Thinking",
|
|
167
|
+
"Tinkering",
|
|
168
|
+
"Tomfoolering",
|
|
169
|
+
"Transfiguring",
|
|
170
|
+
"Transmuting",
|
|
171
|
+
"Twisting",
|
|
172
|
+
"Undulating",
|
|
173
|
+
"Unfurling",
|
|
174
|
+
"Unravelling",
|
|
175
|
+
"Vibing",
|
|
176
|
+
"Waddling",
|
|
177
|
+
"Wandering",
|
|
178
|
+
"Warping",
|
|
179
|
+
"Whirlpooling",
|
|
180
|
+
"Whirring",
|
|
181
|
+
"Whisking",
|
|
182
|
+
"Wibbling",
|
|
183
|
+
"Working",
|
|
184
|
+
"Wrangling",
|
|
185
|
+
"Zesting",
|
|
186
|
+
"Zigzagging",
|
|
187
|
+
];
|
|
188
|
+
|
|
189
|
+
const COMPLETION_VERBS: string[] = [
|
|
190
|
+
"Done",
|
|
191
|
+
"Complete",
|
|
192
|
+
"Finished",
|
|
193
|
+
"Baked",
|
|
194
|
+
"Brewed",
|
|
195
|
+
"Crunched",
|
|
196
|
+
"Crafted",
|
|
197
|
+
"Forged",
|
|
198
|
+
"Generated",
|
|
199
|
+
"Crystallized",
|
|
200
|
+
"Transmuted",
|
|
201
|
+
"Synthesized",
|
|
202
|
+
"Wrangled",
|
|
203
|
+
"Zigzagged",
|
|
204
|
+
"Spelunked",
|
|
205
|
+
"Orchestrated",
|
|
206
|
+
];
|
|
207
|
+
|
|
208
|
+
function randomItem<T>(arr: T[]): T {
|
|
209
|
+
return arr[Math.floor(Math.random() * arr.length)]!;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── Tool label config ─────────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
interface ToolConfig {
|
|
215
|
+
icon: string;
|
|
216
|
+
labels: string[];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const TOOL_CONFIG: Record<string, ToolConfig> = {
|
|
220
|
+
bash: {
|
|
221
|
+
icon: "\uF489", // nf-dev-terminal
|
|
222
|
+
labels: ["piping dreams", "dropping to bash", "herding cats", "petting the shell", "melting faces"],
|
|
223
|
+
},
|
|
224
|
+
read: {
|
|
225
|
+
icon: "\uF0F6", // nf-fa-file_text_o
|
|
226
|
+
labels: [
|
|
227
|
+
"squinting at text",
|
|
228
|
+
"hunting for clues",
|
|
229
|
+
"snuggling with code",
|
|
230
|
+
"cracking open files",
|
|
231
|
+
"devouring content",
|
|
232
|
+
],
|
|
233
|
+
},
|
|
234
|
+
write: {
|
|
235
|
+
icon: "\uF0C7", // nf-fa-floppy_o
|
|
236
|
+
labels: [
|
|
237
|
+
"birthing code",
|
|
238
|
+
"crafting masterpieces",
|
|
239
|
+
"channeling the muse",
|
|
240
|
+
"dropping truth bombs",
|
|
241
|
+
"deflowering files",
|
|
242
|
+
],
|
|
243
|
+
},
|
|
244
|
+
edit: {
|
|
245
|
+
icon: "\uF044", // nf-fa-pencil_square_o
|
|
246
|
+
labels: ["playing god", "twiddling bits", "tucking in code", "surgically altering", "violating the code"],
|
|
247
|
+
},
|
|
248
|
+
ls: {
|
|
249
|
+
icon: "\uF07C", // nf-fa-folder_open_o
|
|
250
|
+
labels: ["peeking at files", "snooping around", "counting sheep", "creeping directories", "pillaging folders"],
|
|
251
|
+
},
|
|
252
|
+
find: {
|
|
253
|
+
icon: "\uF422", // nf-oct-search
|
|
254
|
+
labels: [
|
|
255
|
+
"tracking things down",
|
|
256
|
+
"following breadcrumbs",
|
|
257
|
+
"finding lost socks",
|
|
258
|
+
"hunting high and low",
|
|
259
|
+
"raiding the filesystem",
|
|
260
|
+
],
|
|
261
|
+
},
|
|
262
|
+
grep: {
|
|
263
|
+
icon: "\uF002", // nf-fa-search
|
|
264
|
+
labels: [
|
|
265
|
+
"string hunting",
|
|
266
|
+
"needle in a haystack",
|
|
267
|
+
"patting for patterns",
|
|
268
|
+
"pillaging with regex",
|
|
269
|
+
"gutting files",
|
|
270
|
+
],
|
|
271
|
+
},
|
|
272
|
+
// ── Task tools (shared pool) ───────────────────────────────────────────────
|
|
273
|
+
TaskCreate: {
|
|
274
|
+
icon: "\uF0AE", // nf-fa-tasks
|
|
275
|
+
labels: [
|
|
276
|
+
"scribbling notes",
|
|
277
|
+
"jotting things down",
|
|
278
|
+
"dropping to-dos",
|
|
279
|
+
"tagging along",
|
|
280
|
+
"slinging tasks",
|
|
281
|
+
"making lists",
|
|
282
|
+
"brewing trouble",
|
|
283
|
+
"naming a puppy",
|
|
284
|
+
"spawning tasks",
|
|
285
|
+
"planting seeds",
|
|
286
|
+
],
|
|
287
|
+
},
|
|
288
|
+
TaskList: {
|
|
289
|
+
icon: "\uF0AE",
|
|
290
|
+
labels: [
|
|
291
|
+
"making lists",
|
|
292
|
+
"slinging tasks",
|
|
293
|
+
"scribbling notes",
|
|
294
|
+
"tagging along",
|
|
295
|
+
"jotting things down",
|
|
296
|
+
"dropping to-dos",
|
|
297
|
+
"counting heads",
|
|
298
|
+
"brewing trouble",
|
|
299
|
+
"naming a puppy",
|
|
300
|
+
"herding to-dos",
|
|
301
|
+
],
|
|
302
|
+
},
|
|
303
|
+
TaskGet: {
|
|
304
|
+
icon: "\uF0AE",
|
|
305
|
+
labels: [
|
|
306
|
+
"jotting things down",
|
|
307
|
+
"tagging along",
|
|
308
|
+
"scribbling notes",
|
|
309
|
+
"making lists",
|
|
310
|
+
"dropping to-dos",
|
|
311
|
+
"slinging tasks",
|
|
312
|
+
"peeking at tasks",
|
|
313
|
+
"brewing trouble",
|
|
314
|
+
"naming a puppy",
|
|
315
|
+
"fetching the scroll",
|
|
316
|
+
],
|
|
317
|
+
},
|
|
318
|
+
TaskUpdate: {
|
|
319
|
+
icon: "\uF0AE",
|
|
320
|
+
labels: [
|
|
321
|
+
"dropping to-dos",
|
|
322
|
+
"slinging tasks",
|
|
323
|
+
"tagging along",
|
|
324
|
+
"scribbling notes",
|
|
325
|
+
"jotting things down",
|
|
326
|
+
"making lists",
|
|
327
|
+
"tweaking the plan",
|
|
328
|
+
"brewing trouble",
|
|
329
|
+
"naming a puppy",
|
|
330
|
+
"re-scribbling",
|
|
331
|
+
],
|
|
332
|
+
},
|
|
333
|
+
TaskOutput: {
|
|
334
|
+
icon: "\uF0AE",
|
|
335
|
+
labels: [
|
|
336
|
+
"tagging along",
|
|
337
|
+
"dropping to-dos",
|
|
338
|
+
"making lists",
|
|
339
|
+
"scribbling notes",
|
|
340
|
+
"slinging tasks",
|
|
341
|
+
"jotting things down",
|
|
342
|
+
"stamping done",
|
|
343
|
+
"brewing trouble",
|
|
344
|
+
"naming a puppy",
|
|
345
|
+
"filing the report",
|
|
346
|
+
],
|
|
347
|
+
},
|
|
348
|
+
TaskStop: {
|
|
349
|
+
icon: "\uF0AE",
|
|
350
|
+
labels: [
|
|
351
|
+
"brewing trouble",
|
|
352
|
+
"slinging tasks",
|
|
353
|
+
"dropping to-dos",
|
|
354
|
+
"scribbling notes",
|
|
355
|
+
"tagging along",
|
|
356
|
+
"making lists",
|
|
357
|
+
"pulling the plug",
|
|
358
|
+
"naming a puppy",
|
|
359
|
+
"jotting things down",
|
|
360
|
+
"calling it quits",
|
|
361
|
+
],
|
|
362
|
+
},
|
|
363
|
+
// ── Agent tools ───────────────────────────────────────────────────────────
|
|
364
|
+
Agent: {
|
|
365
|
+
icon: "\uF544", // nf-fa5-robot
|
|
366
|
+
labels: [
|
|
367
|
+
"cloning myself",
|
|
368
|
+
"spreading the workload",
|
|
369
|
+
"making friends",
|
|
370
|
+
"herding agents",
|
|
371
|
+
"summoning bots",
|
|
372
|
+
"pulling strings",
|
|
373
|
+
"spawning minions",
|
|
374
|
+
"delegating chaos",
|
|
375
|
+
"multiplying",
|
|
376
|
+
"outsourcing brilliance",
|
|
377
|
+
],
|
|
378
|
+
},
|
|
379
|
+
get_subagent_result: {
|
|
380
|
+
icon: "\uF544",
|
|
381
|
+
labels: [
|
|
382
|
+
"peeking at results",
|
|
383
|
+
"pulling strings",
|
|
384
|
+
"collecting the loot",
|
|
385
|
+
"herding agents",
|
|
386
|
+
"checking in",
|
|
387
|
+
"fetching the wisdom",
|
|
388
|
+
"reading the oracle",
|
|
389
|
+
"gathering intel",
|
|
390
|
+
"unpacking the goods",
|
|
391
|
+
"seeing what happened",
|
|
392
|
+
],
|
|
393
|
+
},
|
|
394
|
+
steer_subagent: {
|
|
395
|
+
icon: "\uF544",
|
|
396
|
+
labels: [
|
|
397
|
+
"whispering secrets",
|
|
398
|
+
"nudging along",
|
|
399
|
+
"pulling strings",
|
|
400
|
+
"backseat driving",
|
|
401
|
+
"herding agents",
|
|
402
|
+
"micromanaging",
|
|
403
|
+
"sending smoke signals",
|
|
404
|
+
"course correcting",
|
|
405
|
+
"steering the ship",
|
|
406
|
+
"poking the bot",
|
|
407
|
+
],
|
|
408
|
+
},
|
|
409
|
+
// ── User interaction ──────────────────────────────────────────────────────
|
|
410
|
+
ask_user: {
|
|
411
|
+
icon: "\uF27A", // nf-fa-comment_o
|
|
412
|
+
labels: [
|
|
413
|
+
"poking the human",
|
|
414
|
+
"disturbing the peace",
|
|
415
|
+
"breaking the flow",
|
|
416
|
+
"interrogating the human",
|
|
417
|
+
"cornering the user",
|
|
418
|
+
],
|
|
419
|
+
},
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
// Per-tool label rotation state
|
|
423
|
+
const toolLabelIndices = new Map<string, number>();
|
|
424
|
+
|
|
425
|
+
function getToolDisplay(toolName: string): string {
|
|
426
|
+
const config = TOOL_CONFIG[toolName];
|
|
427
|
+
if (!config) return `\uF489 running ${toolName}`; // fallback: terminal icon
|
|
428
|
+
|
|
429
|
+
// Pick next label in rotation
|
|
430
|
+
const idx = toolLabelIndices.get(toolName) ?? Math.floor(Math.random() * config.labels.length);
|
|
431
|
+
toolLabelIndices.set(toolName, (idx + 1) % config.labels.length);
|
|
432
|
+
|
|
433
|
+
return `${config.icon} ${config.labels[idx]}`;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function rotateToolLabel(toolName: string): string {
|
|
437
|
+
const config = TOOL_CONFIG[toolName];
|
|
438
|
+
if (!config) return `\uF489 running ${toolName}`;
|
|
439
|
+
const idx = toolLabelIndices.get(toolName) ?? 0;
|
|
440
|
+
toolLabelIndices.set(toolName, (idx + 1) % config.labels.length);
|
|
441
|
+
return `${config.icon} ${config.labels[idx % config.labels.length]}`;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ── Elapsed time formatting ────────────────────────────────────────────────────
|
|
445
|
+
|
|
446
|
+
function formatElapsed(ms: number): string {
|
|
447
|
+
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
448
|
+
const seconds = totalSeconds % 60;
|
|
449
|
+
const totalMinutes = Math.floor(totalSeconds / 60);
|
|
450
|
+
const minutes = totalMinutes % 60;
|
|
451
|
+
const hours = Math.floor(totalMinutes / 60);
|
|
452
|
+
|
|
453
|
+
if (hours > 0) return `${hours}h ${String(minutes).padStart(2, "0")}m ${String(seconds).padStart(2, "0")}s`;
|
|
454
|
+
if (totalMinutes > 0) return `${totalMinutes}m ${String(seconds).padStart(2, "0")}s`;
|
|
455
|
+
return `${seconds}s`;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ── Shimmer ───────────────────────────────────────────────────────────────────
|
|
459
|
+
|
|
460
|
+
const SHIMMER_BAND_WIDTH = 4;
|
|
461
|
+
|
|
462
|
+
function hexToRgb(hex: string): [number, number, number] {
|
|
463
|
+
const h = hex.replace("#", "");
|
|
464
|
+
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function blendColors(c1: [number, number, number], c2: [number, number, number], t: number): [number, number, number] {
|
|
468
|
+
return [
|
|
469
|
+
Math.round(c1[0] + (c2[0] - c1[0]) * t),
|
|
470
|
+
Math.round(c1[1] + (c2[1] - c1[1]) * t),
|
|
471
|
+
Math.round(c1[2] + (c2[2] - c1[2]) * t),
|
|
472
|
+
];
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function lightenRgb(r: number, g: number, b: number, amount: number): [number, number, number] {
|
|
476
|
+
return [
|
|
477
|
+
Math.min(255, Math.round(r + (255 - r) * amount)),
|
|
478
|
+
Math.min(255, Math.round(g + (255 - g) * amount)),
|
|
479
|
+
Math.min(255, Math.round(b + (255 - b) * amount)),
|
|
480
|
+
];
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function getAccentHex(ctx: ExtensionContext): string | null {
|
|
484
|
+
const sample = ctx.ui.theme.fg("accent", "\u2588");
|
|
485
|
+
const match = sample.match(/\x1b\[38;2;(\d+);(\d+);(\d+)m/);
|
|
486
|
+
if (!match) return null;
|
|
487
|
+
const r = parseInt(match[1]!, 10).toString(16).padStart(2, "0");
|
|
488
|
+
const g = parseInt(match[2]!, 10).toString(16).padStart(2, "0");
|
|
489
|
+
const b = parseInt(match[3]!, 10).toString(16).padStart(2, "0");
|
|
490
|
+
return `#${r}${g}${b}`;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function applyShimmer(text: string, frame: number, baseHex: string, shimmerHex: string): string {
|
|
494
|
+
const base = hexToRgb(baseHex);
|
|
495
|
+
const shimmer = hexToRgb(shimmerHex);
|
|
496
|
+
const totalWidth = text.length + SHIMMER_BAND_WIDTH * 2;
|
|
497
|
+
const pos = frame % totalWidth;
|
|
498
|
+
|
|
499
|
+
let result = "";
|
|
500
|
+
for (let i = 0; i < text.length; i++) {
|
|
501
|
+
const dist = Math.abs(i - pos);
|
|
502
|
+
const t = Math.max(0, 1 - dist / SHIMMER_BAND_WIDTH);
|
|
503
|
+
const color = blendColors(base, shimmer, t);
|
|
504
|
+
result += `\x1b[38;2;${color[0]};${color[1]};${color[2]}m${text[i]}\x1b[0m`;
|
|
505
|
+
}
|
|
506
|
+
return result;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// ── Working Indicator ─────────────────────────────────────────────────────────
|
|
510
|
+
|
|
511
|
+
export function installWorkingIndicator(pi: ExtensionAPI): void {
|
|
512
|
+
let ctx: ExtensionContext | null = null;
|
|
513
|
+
let isActive = false;
|
|
514
|
+
|
|
515
|
+
// Timers
|
|
516
|
+
let spinnerTimer: ReturnType<typeof setInterval> | null = null;
|
|
517
|
+
let verbTimer: ReturnType<typeof setInterval> | null = null;
|
|
518
|
+
let tickTimer: ReturnType<typeof setInterval> | null = null;
|
|
519
|
+
let completionTimer: ReturnType<typeof setTimeout> | null = null;
|
|
520
|
+
|
|
521
|
+
// State
|
|
522
|
+
let spinnerFrame = 0;
|
|
523
|
+
let shimmerFrame = 0;
|
|
524
|
+
let currentVerb = "Working";
|
|
525
|
+
let startedAt = 0;
|
|
526
|
+
let elapsedMs = 0;
|
|
527
|
+
let toolSuffix: string | undefined;
|
|
528
|
+
const activeTools = new Map<string, string>(); // toolCallId -> toolName
|
|
529
|
+
let toolLabelTimer: ReturnType<typeof setInterval> | null = null;
|
|
530
|
+
|
|
531
|
+
// Shimmer colors (resolved once per session from theme)
|
|
532
|
+
let accentHex: string | null = null;
|
|
533
|
+
let shimmerHex: string | null = null;
|
|
534
|
+
|
|
535
|
+
// ── Color resolution ──────────────────────────────────────────────────────
|
|
536
|
+
|
|
537
|
+
function resolveColors(): void {
|
|
538
|
+
if (!ctx) return;
|
|
539
|
+
accentHex = getAccentHex(ctx);
|
|
540
|
+
if (accentHex) {
|
|
541
|
+
const [r, g, b] = hexToRgb(accentHex);
|
|
542
|
+
const [lr, lg, lb] = lightenRgb(r, g, b, 0.55);
|
|
543
|
+
shimmerHex = `#${lr.toString(16).padStart(2, "0")}${lg.toString(16).padStart(2, "0")}${lb.toString(16).padStart(2, "0")}`;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// ── Render ────────────────────────────────────────────────────────────────
|
|
548
|
+
|
|
549
|
+
function render(): void {
|
|
550
|
+
if (!ctx || !isActive) return;
|
|
551
|
+
|
|
552
|
+
const theme = ctx.ui.theme;
|
|
553
|
+
const sep = theme.fg("dim", " · ");
|
|
554
|
+
|
|
555
|
+
// Spinner frame (accent colored)
|
|
556
|
+
const frame = theme.fg("accent", SPINNER_FRAMES[spinnerFrame % SPINNER_FRAMES.length]!);
|
|
557
|
+
|
|
558
|
+
// Verb with shimmer
|
|
559
|
+
const verbText = `${currentVerb}...`;
|
|
560
|
+
let verbStyled: string;
|
|
561
|
+
if (accentHex && shimmerHex) {
|
|
562
|
+
verbStyled = applyShimmer(verbText, shimmerFrame, accentHex, shimmerHex);
|
|
563
|
+
} else {
|
|
564
|
+
verbStyled = theme.fg("accent", verbText);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Timer
|
|
568
|
+
const timer = theme.fg("dim", formatElapsed(elapsedMs));
|
|
569
|
+
|
|
570
|
+
// Assemble parts
|
|
571
|
+
const parts: string[] = [frame, verbStyled];
|
|
572
|
+
if (toolSuffix) parts.push(toolSuffix);
|
|
573
|
+
parts.push(timer);
|
|
574
|
+
|
|
575
|
+
ctx.ui.setWorkingMessage(parts.join(sep));
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// ── Timer management ──────────────────────────────────────────────────────
|
|
579
|
+
|
|
580
|
+
function startTimers(): void {
|
|
581
|
+
// Spinner + shimmer: 150ms
|
|
582
|
+
spinnerTimer = setInterval(() => {
|
|
583
|
+
spinnerFrame++;
|
|
584
|
+
shimmerFrame++;
|
|
585
|
+
render();
|
|
586
|
+
}, SPINNER_INTERVAL_MS);
|
|
587
|
+
|
|
588
|
+
// Verb rotation: 2s
|
|
589
|
+
verbTimer = setInterval(() => {
|
|
590
|
+
currentVerb = randomItem(VERBS);
|
|
591
|
+
render();
|
|
592
|
+
}, 2000);
|
|
593
|
+
|
|
594
|
+
// Elapsed tick: 1s
|
|
595
|
+
tickTimer = setInterval(() => {
|
|
596
|
+
elapsedMs = Date.now() - startedAt;
|
|
597
|
+
render();
|
|
598
|
+
}, 1000);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function stopTimers(): void {
|
|
602
|
+
if (spinnerTimer) {
|
|
603
|
+
clearInterval(spinnerTimer);
|
|
604
|
+
spinnerTimer = null;
|
|
605
|
+
}
|
|
606
|
+
if (verbTimer) {
|
|
607
|
+
clearInterval(verbTimer);
|
|
608
|
+
verbTimer = null;
|
|
609
|
+
}
|
|
610
|
+
if (tickTimer) {
|
|
611
|
+
clearInterval(tickTimer);
|
|
612
|
+
tickTimer = null;
|
|
613
|
+
}
|
|
614
|
+
if (toolLabelTimer) {
|
|
615
|
+
clearInterval(toolLabelTimer);
|
|
616
|
+
toolLabelTimer = null;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function cancelCompletionTimer(): void {
|
|
621
|
+
if (completionTimer) {
|
|
622
|
+
clearTimeout(completionTimer);
|
|
623
|
+
completionTimer = null;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// ── Completion ────────────────────────────────────────────────────────────
|
|
628
|
+
|
|
629
|
+
function showCompletion(totalMs: number): void {
|
|
630
|
+
if (!ctx) return;
|
|
631
|
+
cancelCompletionTimer();
|
|
632
|
+
|
|
633
|
+
const theme = ctx.ui.theme;
|
|
634
|
+
const check = theme.fg("success", "✓");
|
|
635
|
+
const verb = theme.fg("accent", randomItem(COMPLETION_VERBS));
|
|
636
|
+
const time = theme.fg("dim", formatElapsed(totalMs));
|
|
637
|
+
const sep = theme.fg("dim", " · ");
|
|
638
|
+
|
|
639
|
+
ctx.ui.setWorkingMessage(`${check}${sep}${verb}${sep}${time}`);
|
|
640
|
+
ctx.ui.setStatus("working-indicator", `${check} ${verb}`);
|
|
641
|
+
|
|
642
|
+
completionTimer = setTimeout(() => {
|
|
643
|
+
completionTimer = null;
|
|
644
|
+
ctx?.ui.setWorkingMessage(undefined);
|
|
645
|
+
ctx?.ui.setStatus("working-indicator", "");
|
|
646
|
+
}, 3000);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// ── Spinner frames (apply to pi's working indicator widget) ───────────────
|
|
650
|
+
|
|
651
|
+
function applySpinnerFrames(): void {
|
|
652
|
+
if (!ctx) return;
|
|
653
|
+
const colored = SPINNER_FRAMES.map((f) => ctx!.ui.theme.fg("accent", f));
|
|
654
|
+
ctx.ui.setWorkingIndicator({ frames: colored, intervalMs: SPINNER_INTERVAL_MS });
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// ── Event hooks ───────────────────────────────────────────────────────────
|
|
658
|
+
|
|
659
|
+
pi.on("session_start", async (_event, sessionCtx) => {
|
|
660
|
+
ctx = sessionCtx;
|
|
661
|
+
resolveColors();
|
|
662
|
+
applySpinnerFrames();
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
pi.on("agent_start", async (_event, agentCtx) => {
|
|
666
|
+
ctx = agentCtx;
|
|
667
|
+
cancelCompletionTimer();
|
|
668
|
+
stopTimers();
|
|
669
|
+
|
|
670
|
+
isActive = true;
|
|
671
|
+
startedAt = Date.now();
|
|
672
|
+
elapsedMs = 0;
|
|
673
|
+
spinnerFrame = 0;
|
|
674
|
+
shimmerFrame = 0;
|
|
675
|
+
toolSuffix = undefined;
|
|
676
|
+
activeTools.clear();
|
|
677
|
+
toolLabelIndices.clear();
|
|
678
|
+
currentVerb = randomItem(VERBS);
|
|
679
|
+
|
|
680
|
+
resolveColors();
|
|
681
|
+
applySpinnerFrames();
|
|
682
|
+
render();
|
|
683
|
+
startTimers();
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
pi.on("tool_execution_start", (event) => {
|
|
687
|
+
if (!isActive) return;
|
|
688
|
+
const e = event as { toolCallId?: string; toolName?: string };
|
|
689
|
+
if (e.toolCallId && e.toolName) {
|
|
690
|
+
activeTools.set(e.toolCallId, e.toolName); // store tool name, not display string
|
|
691
|
+
toolSuffix = getToolDisplay(e.toolName);
|
|
692
|
+
|
|
693
|
+
// Start rotating the label for this tool every 2s
|
|
694
|
+
if (toolLabelTimer) {
|
|
695
|
+
clearInterval(toolLabelTimer);
|
|
696
|
+
toolLabelTimer = null;
|
|
697
|
+
}
|
|
698
|
+
const _activeName = e.toolName;
|
|
699
|
+
toolLabelTimer = setInterval(() => {
|
|
700
|
+
// Only rotate if this tool is still active
|
|
701
|
+
const currentToolName = Array.from(activeTools.values()).at(-1);
|
|
702
|
+
if (currentToolName) {
|
|
703
|
+
toolSuffix = rotateToolLabel(currentToolName);
|
|
704
|
+
render();
|
|
705
|
+
}
|
|
706
|
+
}, 2000);
|
|
707
|
+
|
|
708
|
+
render();
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
pi.on("tool_execution_end", (event) => {
|
|
713
|
+
if (!isActive) return;
|
|
714
|
+
const e = event as { toolCallId?: string };
|
|
715
|
+
if (e.toolCallId) activeTools.delete(e.toolCallId);
|
|
716
|
+
const remainingTool = Array.from(activeTools.values()).at(-1);
|
|
717
|
+
if (remainingTool) {
|
|
718
|
+
toolSuffix = getToolDisplay(remainingTool);
|
|
719
|
+
} else {
|
|
720
|
+
toolSuffix = undefined;
|
|
721
|
+
if (toolLabelTimer) {
|
|
722
|
+
clearInterval(toolLabelTimer);
|
|
723
|
+
toolLabelTimer = null;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
render();
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
pi.on("agent_end", async (_event, agentCtx) => {
|
|
730
|
+
ctx = agentCtx;
|
|
731
|
+
const totalMs = startedAt > 0 ? Date.now() - startedAt : 0;
|
|
732
|
+
|
|
733
|
+
stopTimers();
|
|
734
|
+
isActive = false;
|
|
735
|
+
toolSuffix = undefined;
|
|
736
|
+
activeTools.clear();
|
|
737
|
+
|
|
738
|
+
showCompletion(totalMs);
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
pi.on("session_shutdown", async () => {
|
|
742
|
+
stopTimers();
|
|
743
|
+
cancelCompletionTimer();
|
|
744
|
+
isActive = false;
|
|
745
|
+
ctx = null;
|
|
746
|
+
});
|
|
747
|
+
}
|