diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx index 8ace2fff3725..d9df37d05d88 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx @@ -1,10 +1,12 @@ -import { createMemo, Match, onCleanup, onMount, Show, Switch } from "solid-js" +import { createEffect, createMemo, Match, on, onCleanup, onMount, Show, Switch } from "solid-js" import { useTheme } from "../../context/theme" import { useSync } from "../../context/sync" import { useDirectory } from "../../context/directory" import { useConnected } from "../../component/dialog-model" import { createStore } from "solid-js/store" import { useRoute } from "../../context/route" +import type { AssistantMessage } from "@opencode-ai/sdk/v2" +import { formatTokenCount, formatTps } from "../../util/tokens" export function Footer() { const { theme } = useTheme() @@ -20,12 +22,95 @@ export function Footer() { const directory = useDirectory() const connected = useConnected() + // Compute cumulative token stats for the current session + const tokenStats = createMemo(() => { + if (route.data.type !== "session") return null + const messages = sync.data.message[route.data.sessionID] ?? [] + let inputTokens = 0 + let outputTokens = 0 + for (const msg of messages) { + if (msg.role !== "assistant") continue + const am = msg as AssistantMessage + if (!am.tokens) continue + inputTokens += am.tokens.input ?? 0 + outputTokens += am.tokens.output ?? 0 + } + if (inputTokens === 0 && outputTokens === 0) return null + return { inputTokens, outputTokens } + }) + + // Track streaming tokens-per-second + const [tpsStore, setTpsStore] = createStore({ + /** Current or last-computed TPS rate */ + tps: 0, + /** Whether to show TPS in the footer (true during streaming + 3s after) */ + showTps: false, + }) + + // Internal tracking for TPS calculation (not reactive) + let prevOutputTokens = 0 + let prevTimestamp = 0 + let tpsTimeout: ReturnType | undefined + + const sessionStatus = createMemo(() => { + if (route.data.type !== "session") return undefined + return sync.data.session_status[route.data.sessionID] + }) + + const isStreaming = createMemo(() => sessionStatus()?.type === "busy") + + // Find the last assistant message's output token count (reactive) + const lastAssistantOutputTokens = createMemo(() => { + if (route.data.type !== "session") return 0 + const messages = sync.data.message[route.data.sessionID] ?? [] + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i] + if (msg.role === "assistant") { + return (msg as AssistantMessage).tokens?.output ?? 0 + } + } + return 0 + }) + + // Update TPS when output tokens change during streaming (effect, not memo) + createEffect( + on([isStreaming, lastAssistantOutputTokens], ([streaming, outputTokens]) => { + if (!streaming || outputTokens === 0) return + + const now = Date.now() + if (prevOutputTokens > 0 && prevTimestamp > 0 && outputTokens > prevOutputTokens) { + const elapsed = (now - prevTimestamp) / 1000 + if (elapsed > 0) { + setTpsStore("tps", (outputTokens - prevOutputTokens) / elapsed) + setTpsStore("showTps", true) + } + } + prevOutputTokens = outputTokens + prevTimestamp = now + }), + ) + + // When streaming stops, keep TPS visible for 3 seconds then hide + createEffect( + on(isStreaming, (streaming) => { + if (!streaming && tpsStore.showTps) { + clearTimeout(tpsTimeout) + tpsTimeout = setTimeout(() => { + setTpsStore("showTps", false) + prevOutputTokens = 0 + prevTimestamp = 0 + }, 3000) + } + }), + ) + + onCleanup(() => clearTimeout(tpsTimeout)) + const [store, setStore] = createStore({ welcome: false, }) onMount(() => { - // Track all timeouts to ensure proper cleanup const timeouts: ReturnType[] = [] function tick() { @@ -60,6 +145,19 @@ export function Footer() { + + {(stats) => ( + + 0}> + + {formatTps(tpsStore.tps)} tok/s + + {" · "} + + {formatTokenCount(stats().inputTokens)} in · {formatTokenCount(stats().outputTokens)} out + + )} + 0}> {permissions().length} Permission diff --git a/packages/opencode/src/cli/cmd/tui/util/tokens.test.ts b/packages/opencode/src/cli/cmd/tui/util/tokens.test.ts new file mode 100644 index 000000000000..68f930f13d5a --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/tokens.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test" +import { formatTokenCount, formatTps } from "./tokens" + +describe("formatTokenCount", () => { + test("formats small numbers as-is", () => { + expect(formatTokenCount(0)).toBe("0") + expect(formatTokenCount(42)).toBe("42") + expect(formatTokenCount(999)).toBe("999") + }) + + test("formats thousands with K suffix", () => { + expect(formatTokenCount(1_000)).toBe("1.0K") + expect(formatTokenCount(1_500)).toBe("1.5K") + expect(formatTokenCount(10_000)).toBe("10K") + expect(formatTokenCount(10_500)).toBe("11K") + expect(formatTokenCount(99_999)).toBe("100K") + expect(formatTokenCount(500_000)).toBe("500K") + }) + + test("rolls K to M at boundary", () => { + expect(formatTokenCount(999_499)).toBe("999K") + expect(formatTokenCount(999_500)).toBe("1.0M") + expect(formatTokenCount(999_999)).toBe("1.0M") + }) + + test("formats millions with M suffix", () => { + expect(formatTokenCount(1_000_000)).toBe("1.0M") + expect(formatTokenCount(1_500_000)).toBe("1.5M") + expect(formatTokenCount(4_470_000)).toBe("4.5M") + expect(formatTokenCount(10_000_000)).toBe("10M") + }) +}) + +describe("formatTps", () => { + test("formats sub-1 as <1", () => { + expect(formatTps(0)).toBe("<1") + expect(formatTps(0.01)).toBe("<1") + expect(formatTps(0.5)).toBe("<1") + expect(formatTps(0.99)).toBe("<1") + }) + + test("formats single digits with one decimal", () => { + expect(formatTps(1.0)).toBe("1.0") + expect(formatTps(5.7)).toBe("5.7") + expect(formatTps(9.9)).toBe("9.9") + expect(formatTps(9.94)).toBe("9.9") + }) + + test("transitions cleanly at 10 boundary", () => { + expect(formatTps(9.95)).toBe("10") + expect(formatTps(9.99)).toBe("10") + expect(formatTps(10)).toBe("10") + }) + + test("formats 10+ as integers", () => { + expect(formatTps(10.5)).toBe("11") + expect(formatTps(142.3)).toBe("142") + expect(formatTps(1000)).toBe("1000") + }) +}) diff --git a/packages/opencode/src/cli/cmd/tui/util/tokens.ts b/packages/opencode/src/cli/cmd/tui/util/tokens.ts new file mode 100644 index 000000000000..fe995f06b61a --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/tokens.ts @@ -0,0 +1,35 @@ +/** + * Token usage formatting utilities for the TUI footer display. + * + * Provides compact formatting for token counts (K/M suffixes) + * and tokens-per-second calculation during streaming. + */ + +/** + * Format a token count with compact suffixes. + * 0-999: "123" + * 1,000-999,999: "1.2K" (rolls to M at boundary) + * 1,000,000+: "1.2M" + */ +export function formatTokenCount(count: number): string { + if (count < 1_000) return count.toString() + if (count < 1_000_000) { + const k = count / 1_000 + if (Math.round(k) >= 1_000) return "1.0M" + return k >= 10 ? `${Math.round(k)}K` : `${k.toFixed(1)}K` + } + const m = count / 1_000_000 + return m >= 10 ? `${Math.round(m)}M` : `${m.toFixed(1)}M` +} + +/** + * Format tokens-per-second with appropriate precision. + * <1: "<1" + * 1-9.9: "5.7" (one decimal) + * 10+: "142" (integer) + */ +export function formatTps(tps: number): string { + if (tps < 1) return "<1" + if (tps < 9.95) return tps.toFixed(1) + return Math.round(tps).toString() +}