Skip to content
Open
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
73 changes: 73 additions & 0 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import { Footer } from "./footer.tsx"
import { usePromptRef } from "../../context/prompt"
import { Filesystem } from "@/util/filesystem"
import { DialogSubagent } from "./dialog-subagent.tsx"
import { Flag } from "@/flag/flag.ts"

addDefaultParsers(parsers.parsers)

Expand Down Expand Up @@ -1254,6 +1255,10 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
const sync = useSync()
const messages = createMemo(() => sync.data.message[props.message.sessionID] ?? [])

function getParts(messageID: string) {
return sync.data.part[messageID] ?? []
}

const final = createMemo(() => {
return props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish)
})
Expand All @@ -1266,6 +1271,71 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
return props.message.time.completed - user.time.created
})

const TPS = createMemo(() => {
if (!final()) return 0
if (!props.message.time.completed) return 0
if (!Flag.OPENCODE_EXPERIMENTAL_TPS) return 0

const assistantMessages : AssistantMessage[] = messages().filter((msg) => msg.role === "assistant" && msg.id !== props.message.id) as AssistantMessage[]

const allParts = assistantMessages.flatMap((msg) => getParts(msg.id))

const INVALID_REASONING_TEXTS = ["[REDACTED]", "", null, undefined] as const

// Filter for actual streaming parts (reasoning + text), exclude tool/step markers
const streamingParts = allParts.filter((part): part is TextPart | ReasoningPart => {
// Only text and reasoning parts have streaming time data
if (part.type !== "text" && part.type !== "reasoning") return false

// Skip parts without valid timestamps
if (!part.time?.start || !part.time?.end) return false

// Include text parts with content
if (part.type === "text" && (part.text?.trim().length ?? 0) > 0) return true

// Include reasoning parts with valid (non-empty) text
if (part.type === "reasoning" && !INVALID_REASONING_TEXTS.includes(part.text as any)) {
return true
}

return false
})

if (streamingParts.length === 0) return 0

// Sum individual part durations (excludes tool execution time between parts)
let totalStreamingTimeMs = 0
let hasValidReasoning = false

for (const part of streamingParts) {
totalStreamingTimeMs += part.time!.end! - part.time!.start!
if (part.type === "reasoning") {
hasValidReasoning = true
}
}

if (totalStreamingTimeMs === 0) return 0

const totals = assistantMessages.reduce(
(acc, m) => {
acc.output += m.tokens.output
if (hasValidReasoning) acc.reasoning += m.tokens.reasoning // Only count reasoning tokens if valid reasoning parts exists
return acc
},
{ output: 0, reasoning: 0 },
)

const totalTokens = totals.reasoning + totals.output

if (totalTokens === 0) return 0

// Calculate tokens per second
const totalStreamingTimeSec = totalStreamingTimeMs / 1000
const tokensPerSecond = totalTokens / totalStreamingTimeSec

return Number(tokensPerSecond.toFixed(2))
})

return (
<>
<For each={props.parts}>
Expand Down Expand Up @@ -1307,6 +1377,9 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
<Show when={duration()}>
<span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span>
</Show>
<Show when={Flag.OPENCODE_EXPERIMENTAL_TPS && TPS()}>
<span style={{ fg: theme.textMuted }}> · {TPS()} tps</span>
</Show>
</text>
</box>
</Match>
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/flag/flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export namespace Flag {
export const OPENCODE_EXPERIMENTAL_OXFMT = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_OXFMT")
export const OPENCODE_EXPERIMENTAL_LSP_TY = truthy("OPENCODE_EXPERIMENTAL_LSP_TY")
export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL")
export const OPENCODE_EXPERIMENTAL_TPS = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_TPS")

function truthy(key: string) {
const value = process.env[key]?.toLowerCase()
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ export namespace SessionProcessor {
)
currentText.text = textOutput.text
currentText.time = {
start: Date.now(),
start: currentText.time?.start ?? Date.now(), // No need to set start time here, it's already set in the text-start event
end: Date.now(),
}
if (value.providerMetadata) currentText.metadata = value.providerMetadata
Expand Down