Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 100 additions & 2 deletions packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -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<typeof setTimeout> | 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<typeof setTimeout>[] = []

function tick() {
Expand Down Expand Up @@ -60,6 +145,19 @@ export function Footer() {
</text>
</Match>
<Match when={connected()}>
<Show when={tokenStats()}>
{(stats) => (
<text fg={theme.textMuted}>
<Show when={tpsStore.showTps && tpsStore.tps > 0}>
<span style={{ fg: isStreaming() ? theme.text : theme.textMuted }}>
{formatTps(tpsStore.tps)} tok/s
</span>
{" · "}
</Show>
{formatTokenCount(stats().inputTokens)} in · {formatTokenCount(stats().outputTokens)} out
</text>
)}
</Show>
<Show when={permissions().length > 0}>
<text fg={theme.warning}>
<span style={{ fg: theme.warning }}>△</span> {permissions().length} Permission
Expand Down
60 changes: 60 additions & 0 deletions packages/opencode/src/cli/cmd/tui/util/tokens.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
35 changes: 35 additions & 0 deletions packages/opencode/src/cli/cmd/tui/util/tokens.ts
Original file line number Diff line number Diff line change
@@ -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()
}
Loading