diff --git a/packages/tui/src/component/devtools-bar.tsx b/packages/tui/src/component/devtools-bar.tsx
index fc168260a20..03373110e22 100644
--- a/packages/tui/src/component/devtools-bar.tsx
+++ b/packages/tui/src/component/devtools-bar.tsx
@@ -59,6 +59,7 @@ export function DevToolsBar() {
const canSwitchMode = () => supports(nextMode())
const runtime = createMemo(() => runtimeStatus(frontendSamples()))
const timing = () => config.data.debug?.timing ?? false
+ const turnTokens = () => config.data.debug?.turn_tokens ?? false
const offEscape = keymap.intercept(
"key",
@@ -352,6 +353,16 @@ export function DevToolsBar() {
>
{timing() ? "[x]" : "[ ]"} Time to first draw
+
+ void config.update((draft) => {
+ draft.debug = { ...draft.debug, turn_tokens: !turnTokens() }
+ })
+ }
+ hoverBackground
+ >
+ {turnTokens() ? "[x]" : "[ ]"} Turn token usage
+
{(group) => (
@@ -403,7 +414,7 @@ function PanelBox(props: ParentProps) {
position="absolute"
zIndex={2600}
bottom={1}
- left={0}
+ left={-1}
width={42}
paddingLeft={2}
paddingRight={2}
diff --git a/packages/tui/src/component/dialog-config.tsx b/packages/tui/src/component/dialog-config.tsx
index ab84fdf6233..8ff9db50c70 100644
--- a/packages/tui/src/component/dialog-config.tsx
+++ b/packages/tui/src/component/dialog-config.tsx
@@ -222,14 +222,6 @@ const settings: Setting[] = [
values: [false, true],
labels: ["off", "on"],
},
- {
- title: "DevTools: Timing",
- category: "Debug",
- path: ["debug", "timing"],
- default: true,
- values: [false, true],
- labels: ["off", "on"],
- },
]
export function DialogConfig() {
diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx
index e078eb5113a..424e8dbc9a8 100644
--- a/packages/tui/src/config/index.tsx
+++ b/packages/tui/src/config/index.tsx
@@ -159,6 +159,7 @@ export const Info = Schema.Struct({
Schema.Struct({
devtools: Schema.optional(Schema.Boolean).annotate({ description: "Show the DevTools debug bar" }),
timing: Schema.optional(Schema.Boolean).annotate({ description: "Show time-to-first-draw diagnostics" }),
+ turn_tokens: Schema.optional(Schema.Boolean).annotate({ description: "Show per-turn token usage diagnostics" }),
}),
).annotate({ description: "Debugging settings" }),
animations: Schema.optional(Schema.Boolean).annotate({ description: "Enable interface animations" }),
diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx
index 69c9682ec15..e07947ba1c4 100644
--- a/packages/tui/src/routes/session/index.tsx
+++ b/packages/tui/src/routes/session/index.tsx
@@ -1029,11 +1029,23 @@ export function Session() {
)
}
-function SessionRowView(props: {
+type SessionRowViewProps = {
row: SessionRow
message: (messageID: string) => SessionMessageInfo | undefined
boundaryID?: string
-}) {
+}
+
+function SessionRowView(props: SessionRowViewProps) {
+ const config = useConfig()
+ const hidden = () => props.row.type === "turn-usage" && config.data.debug?.turn_tokens !== true
+ return (
+
+
+
+ )
+}
+
+function SessionRowContent(props: SessionRowViewProps) {
return (
@@ -1072,11 +1084,109 @@ function SessionRowView(props: {
)}
+
+ {(row) => (
+
+ )}
+
)
}
+function TurnTokenUsage(props: {
+ messageIDs: string[]
+ previousCacheRead?: number
+ message: (messageID: string) => SessionMessageInfo | undefined
+}) {
+ const config = useConfig()
+ const { themeV2 } = useTheme()
+ const steps = createMemo(() => {
+ let previousCacheRead = props.previousCacheRead
+ return props.messageIDs.flatMap((messageID) => {
+ const message = props.message(messageID)
+ if (message?.type !== "assistant" || !message.tokens) return []
+ const total =
+ message.tokens.input +
+ message.tokens.output +
+ message.tokens.reasoning +
+ message.tokens.cache.read +
+ message.tokens.cache.write
+ if (total === 0) return []
+ const newTokens = total - message.tokens.cache.read
+ const cacheBust =
+ previousCacheRead !== undefined && message.tokens.cache.read < previousCacheRead
+ ? previousCacheRead - message.tokens.cache.read
+ : undefined
+ previousCacheRead = message.tokens.cache.read
+ return [
+ {
+ finish: message.finish === "tool-calls" ? "tool-call" : (message.finish ?? "unknown"),
+ newTokens,
+ cached: message.tokens.cache.read,
+ total,
+ cacheBust,
+ },
+ ]
+ })
+ })
+ const columns = createMemo(() => ({
+ step: Math.max("Step".length, ...steps().map((item) => item.finish.length)),
+ newTokens: Math.max("New".length, ...steps().map((item) => item.newTokens.toLocaleString().length)),
+ cached: Math.max("Cached".length, ...steps().map((item) => item.cached.toLocaleString().length)),
+ total: Math.max("Total".length, ...steps().map((item) => item.total.toLocaleString().length)),
+ }))
+ return (
+ 0}>
+
+
+
+ ◈
+
+
+ Tokens
+
+
+
+
+ {"Step".padEnd(columns().step + 2)}
+ {"New".padStart(columns().newTokens)}
+ {" "}
+ {"Cached".padStart(columns().cached)}
+ {" "}
+ {"Total".padStart(columns().total)}
+
+
+
+ {(item) => (
+
+
+ {item.finish.padEnd(columns().step + 2)}
+
+ {item.newTokens.toLocaleString().padStart(columns().newTokens)}
+
+ {" "}
+ {item.cached.toLocaleString().padStart(columns().cached)}
+ {" "}
+ {item.total.toLocaleString().padStart(columns().total)}
+
+
+
+ ! Cache bust: {item.cacheBust?.toLocaleString()} fewer cached tokens than the previous step
+
+
+
+ )}
+
+
+
+ )
+}
+
function BackgroundToolHint(props: { messages: SessionMessageInfo[] }) {
const { themeV2 } = useTheme()
const shortcut = Keymap.useShortcut("session.background")
diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts
index dc4a681bb9d..837725faf82 100644
--- a/packages/tui/src/routes/session/rows.ts
+++ b/packages/tui/src/routes/session/rows.ts
@@ -27,6 +27,7 @@ export type SessionRow =
completed: boolean
}
| { type: "assistant-footer"; messageID: string }
+ | { type: "turn-usage"; messageIDs: string[]; previousCacheRead?: number }
export function createSessionRows(sessionID: Accessor) {
const data = useData()
@@ -127,6 +128,26 @@ export function createSessionRows(sessionID: Accessor) {
),
)
+ createEffect(
+ on(
+ () =>
+ data.session.message.list(sessionID()).flatMap((message) =>
+ message.type === "assistant"
+ ? [
+ {
+ id: message.id,
+ finish: message.finish,
+ error: message.error,
+ retry: message.retry,
+ tokens: message.tokens,
+ },
+ ]
+ : [],
+ ),
+ () => setRows(reconcile(reduce())),
+ ),
+ )
+
const appendMessage = (messageID: string) =>
setRows(
produce((draft) => {
@@ -260,6 +281,10 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S
const isInput = (message: SessionMessageInfo) => inputs.has(message.id)
const pendingCompactions = messages.filter((message) => message.type === "compaction" && message.status === "running")
const pending = new Set([...pendingCompactions.map((message) => message.id), ...inputs])
+ const steps: string[] = []
+ let previousCacheRead: number | undefined
+ let turnPreviousCacheRead: number | undefined
+ let measured = false
return [
...messages.filter((message) => !pending.has(message.id)),
...pendingCompactions,
@@ -271,20 +296,42 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S
rows.push({ type: "message", messageID: message.id })
return rows
}
+ if (steps.length === 0) turnPreviousCacheRead = previousCacheRead
+ steps.push(message.id)
+ if (message.tokens && tokenTotal(message.tokens) > 0) {
+ previousCacheRead = message.tokens.cache.read
+ measured = true
+ }
const ordinals = { text: 0, reasoning: 0 }
message.content.forEach((part) => {
const partID = part.type === "tool" ? part.id : `${part.type}:${ordinals[part.type]++}`
if ((part.type === "text" || part.type === "reasoning") && !part.text.trim()) return
append(rows, { messageID: message.id, partID }, part)
})
- if ((message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error || message.retry) {
+ const terminal = (message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error
+ if (terminal || message.retry) {
completePrevious(rows)
rows.push({ type: "assistant-footer", messageID: message.id })
}
+ if (terminal) {
+ if (measured)
+ rows.push({
+ type: "turn-usage",
+ messageIDs: [...steps],
+ ...(turnPreviousCacheRead === undefined ? {} : { previousCacheRead: turnPreviousCacheRead }),
+ })
+ steps.length = 0
+ turnPreviousCacheRead = undefined
+ measured = false
+ }
return rows
}, [])
}
+function tokenTotal(tokens: NonNullable) {
+ return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
+}
+
export function messageBoundaryIDs(rows: SessionRow[], messages: SessionMessageInfo[]) {
const byID = new Map(messages.map((message) => [message.id, message]))
const seen = new Set()
@@ -309,6 +356,8 @@ function rowBoundaryMessageID(row: SessionRow, messages: Map