|
17 | 17 | import type { AssistantMessage } from "@mariozechner/pi-ai"; |
18 | 18 | import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; |
19 | 19 | import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; |
| 20 | +import { Buffer } from "node:buffer"; |
20 | 21 | import { basename } from "node:path"; |
21 | 22 |
|
22 | 23 | /** Hash a string into a 32-bit unsigned integer (djb2). */ |
@@ -84,6 +85,13 @@ export function buildProfileBadge(ctx: ExtensionContext): { text: string; width: |
84 | 85 | }; |
85 | 86 | } |
86 | 87 |
|
| 88 | +/** Format bytes/sec as a compact string (e.g. 512B/s, 1.2kB/s). */ |
| 89 | +export function formatBytesPerSec(bps: number): string { |
| 90 | + if (bps >= 1_000_000) return `${(bps / 1_000_000).toFixed(1)}MB/s`; |
| 91 | + if (bps >= 1_000) return `${(bps / 1_000).toFixed(1)}kB/s`; |
| 92 | + return `${Math.round(bps)}B/s`; |
| 93 | +} |
| 94 | + |
87 | 95 | /** Format a token count as a compact string (e.g. 1.2k, 3.5M). */ |
88 | 96 | export function formatTokenCount(n: number): string { |
89 | 97 | if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`; |
@@ -124,6 +132,13 @@ export default function statusBarExtension(pi: ExtensionAPI) { |
124 | 132 | let turnCount = 0; |
125 | 133 | let agentActive = false; |
126 | 134 |
|
| 135 | + // Real-time streaming speed (bytes/sec, 1s sliding window) |
| 136 | + let windowBytes = 0; |
| 137 | + let currentBytesPerSec = 0; |
| 138 | + let isStreaming = false; |
| 139 | + let renderTimer: ReturnType<typeof setInterval> | null = null; |
| 140 | + let tuiRef: { requestRender: () => void } | null = null; |
| 141 | + |
127 | 142 | // Ticket stats (populated via pi.events from ticket extension) |
128 | 143 | let ticketStats: TicketStats | null = null; |
129 | 144 | pi.events.on("ticket:stats", (data: unknown) => { ticketStats = data as TicketStats; }); |
@@ -188,6 +203,36 @@ export default function statusBarExtension(pi: ExtensionAPI) { |
188 | 203 | diffStatsTimer = setTimeout(() => refreshDiffStats(ctx), 500); |
189 | 204 | } |
190 | 205 |
|
| 206 | + pi.on("message_start", async (event) => { |
| 207 | + if (event.message.role !== "assistant") return; |
| 208 | + windowBytes = 0; |
| 209 | + currentBytesPerSec = 0; |
| 210 | + isStreaming = true; |
| 211 | + if (tuiRef && !renderTimer) { |
| 212 | + renderTimer = setInterval(() => { |
| 213 | + currentBytesPerSec = windowBytes; |
| 214 | + windowBytes = 0; |
| 215 | + tuiRef?.requestRender(); |
| 216 | + }, 1000); |
| 217 | + } |
| 218 | + }); |
| 219 | + |
| 220 | + pi.on("message_update", async (event) => { |
| 221 | + const e = event.assistantMessageEvent; |
| 222 | + if (e.type === "text_delta" || e.type === "thinking_delta" || e.type === "toolcall_delta") { |
| 223 | + windowBytes += Buffer.byteLength(e.delta, "utf8"); |
| 224 | + } |
| 225 | + }); |
| 226 | + |
| 227 | + pi.on("message_end", async (event) => { |
| 228 | + if (event.message.role !== "assistant") return; |
| 229 | + isStreaming = false; |
| 230 | + if (renderTimer) { |
| 231 | + clearInterval(renderTimer); |
| 232 | + renderTimer = null; |
| 233 | + } |
| 234 | + }); |
| 235 | + |
191 | 236 | pi.on("tool_execution_end", async (event, ctx) => { |
192 | 237 | counts[event.toolName] = (counts[event.toolName] || 0) + 1; |
193 | 238 | if (MUTATING_TOOLS.has(event.toolName)) scheduleDiffRefresh(ctx); |
@@ -217,10 +262,15 @@ export default function statusBarExtension(pi: ExtensionAPI) { |
217 | 262 | refreshDiffStats(ctx); |
218 | 263 |
|
219 | 264 | ctx.ui.setFooter((tui, theme, footerData) => { |
| 265 | + tuiRef = tui; |
220 | 266 | const unsub = footerData.onBranchChange(() => tui.requestRender()); |
221 | 267 |
|
222 | 268 | return { |
223 | | - dispose: unsub, |
| 269 | + dispose() { |
| 270 | + unsub(); |
| 271 | + if (renderTimer) { clearInterval(renderTimer); renderTimer = null; } |
| 272 | + tuiRef = null; |
| 273 | + }, |
224 | 274 | invalidate() {}, |
225 | 275 | render(width: number): string[] { |
226 | 276 | let tokIn = 0; |
@@ -261,10 +311,14 @@ export default function statusBarExtension(pi: ExtensionAPI) { |
261 | 311 | theme.fg("dim", " ") + |
262 | 312 | theme.fg(ctxColor, `${Math.round(pct)}%`); |
263 | 313 |
|
264 | | - let l1Right = |
265 | | - theme.fg("success", `↑${formatTokenCount(tokIn)}`) + |
266 | | - theme.fg("dim", " ") + |
267 | | - theme.fg("accent", `↓${formatTokenCount(tokOut)}`); |
| 314 | + const speedLabel = isStreaming && currentBytesPerSec === 0 |
| 315 | + ? "…" |
| 316 | + : currentBytesPerSec > 0 || isStreaming |
| 317 | + ? formatBytesPerSec(currentBytesPerSec) |
| 318 | + : ""; |
| 319 | + let l1Right = speedLabel |
| 320 | + ? theme.fg("muted", `⏱${speedLabel}`) |
| 321 | + : ""; |
268 | 322 | if (tokCache > 0) { |
269 | 323 | l1Right += theme.fg("dim", " ") + theme.fg("muted", `⚡${formatTokenCount(tokCache)}`); |
270 | 324 | } |
@@ -395,6 +449,10 @@ export default function statusBarExtension(pi: ExtensionAPI) { |
395 | 449 | turnCount = 0; |
396 | 450 | agentActive = false; |
397 | 451 | diffStats = null; |
| 452 | + windowBytes = 0; |
| 453 | + currentBytesPerSec = 0; |
| 454 | + isStreaming = false; |
| 455 | + if (renderTimer) { clearInterval(renderTimer); renderTimer = null; } |
398 | 456 | refreshDiffStats(ctx); |
399 | 457 | } |
400 | 458 | }); |
|
0 commit comments