Skip to content

Commit fe3ce6c

Browse files
authored
feat(status-bar): add real-time streaming speed indicator (#30)
* feat(status-bar): add real-time streaming speed indicator * docs: update changelog
1 parent ec538d8 commit fe3ce6c

2 files changed

Lines changed: 70 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,13 @@ All notable changes to agent-stuff are documented here.
5656

5757

5858

59-
## feat/auto-simplify-session-isolation
59+
60+
61+
## feat/streaming-speed-display
62+
63+
Added a real-time streaming speed indicator to the status bar that displays throughput in bytes/second during Claude's message generation (#30). The indicator uses a 1-second sliding window to track bytes from text, thinking, and tool-call deltas, with automatic unit scaling (B/s, kB/s, MB/s) for readability. Speed updates are rendered via a dedicated interval timer that triggers UI refreshes only during active streaming, replacing the previous token count display when streams are active. The feature properly manages lifecycle hooks—clearing timers on message completion and footer disposal to prevent memory leaks.
64+
65+
## [1.0.18](https://github.com/kostyay/agent-stuff/pull/29) - 2026-03-07
6066

6167
Implements session isolation for auto-triggered simplifications (#29), ensuring that files queued from the `agent_end` confirmation create a new isolated session before execution rather than running in the current context. This prevents simplification work from interfering with ongoing analysis and improves context management. The implementation queues files internally and defers their processing to the next `/simplify` invocation, which detects pending auto-simplify work and establishes a fresh session with appropriate naming before proceeding.
6268

pi-extensions/status-bar.ts

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import type { AssistantMessage } from "@mariozechner/pi-ai";
1818
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
1919
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
20+
import { Buffer } from "node:buffer";
2021
import { basename } from "node:path";
2122

2223
/** Hash a string into a 32-bit unsigned integer (djb2). */
@@ -84,6 +85,13 @@ export function buildProfileBadge(ctx: ExtensionContext): { text: string; width:
8485
};
8586
}
8687

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+
8795
/** Format a token count as a compact string (e.g. 1.2k, 3.5M). */
8896
export function formatTokenCount(n: number): string {
8997
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) {
124132
let turnCount = 0;
125133
let agentActive = false;
126134

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+
127142
// Ticket stats (populated via pi.events from ticket extension)
128143
let ticketStats: TicketStats | null = null;
129144
pi.events.on("ticket:stats", (data: unknown) => { ticketStats = data as TicketStats; });
@@ -188,6 +203,36 @@ export default function statusBarExtension(pi: ExtensionAPI) {
188203
diffStatsTimer = setTimeout(() => refreshDiffStats(ctx), 500);
189204
}
190205

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+
191236
pi.on("tool_execution_end", async (event, ctx) => {
192237
counts[event.toolName] = (counts[event.toolName] || 0) + 1;
193238
if (MUTATING_TOOLS.has(event.toolName)) scheduleDiffRefresh(ctx);
@@ -217,10 +262,15 @@ export default function statusBarExtension(pi: ExtensionAPI) {
217262
refreshDiffStats(ctx);
218263

219264
ctx.ui.setFooter((tui, theme, footerData) => {
265+
tuiRef = tui;
220266
const unsub = footerData.onBranchChange(() => tui.requestRender());
221267

222268
return {
223-
dispose: unsub,
269+
dispose() {
270+
unsub();
271+
if (renderTimer) { clearInterval(renderTimer); renderTimer = null; }
272+
tuiRef = null;
273+
},
224274
invalidate() {},
225275
render(width: number): string[] {
226276
let tokIn = 0;
@@ -261,10 +311,14 @@ export default function statusBarExtension(pi: ExtensionAPI) {
261311
theme.fg("dim", " ") +
262312
theme.fg(ctxColor, `${Math.round(pct)}%`);
263313

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+
: "";
268322
if (tokCache > 0) {
269323
l1Right += theme.fg("dim", " ") + theme.fg("muted", `⚡${formatTokenCount(tokCache)}`);
270324
}
@@ -395,6 +449,10 @@ export default function statusBarExtension(pi: ExtensionAPI) {
395449
turnCount = 0;
396450
agentActive = false;
397451
diffStats = null;
452+
windowBytes = 0;
453+
currentBytesPerSec = 0;
454+
isStreaming = false;
455+
if (renderTimer) { clearInterval(renderTimer); renderTimer = null; }
398456
refreshDiffStats(ctx);
399457
}
400458
});

0 commit comments

Comments
 (0)