Compare commits

...

2 Commits

Author SHA1 Message Date
James Long 716f235a75 refactor(tui): derive turn usage from steps 2026-07-23 03:08:23 +00:00
James Long c26ef364c9 feat(tui): add turn token usage diagnostics 2026-07-22 22:15:58 +00:00
6 changed files with 186 additions and 5 deletions
@@ -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
</Action>
<Action
onClick={() =>
void config.update((draft) => {
draft.debug = { ...draft.debug, turn_tokens: !turnTokens() }
})
}
hoverBackground
>
{turnTokens() ? "[x]" : "[ ]"} Turn token usage
</Action>
</box>
<For each={groups()}>
{(group) => (
+1
View File
@@ -153,6 +153,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" }),
+87 -1
View File
@@ -1034,8 +1034,10 @@ function SessionRowView(props: {
message: (messageID: string) => SessionMessageInfo | undefined
boundaryID?: string
}) {
const config = useConfig()
const hidden = () => props.row.type === "turn-usage" && config.data.debug?.turn_tokens !== true
return (
<box id={props.boundaryID} marginTop={1} flexShrink={0}>
<box id={props.boundaryID} height={hidden() ? 0 : undefined} marginTop={hidden() ? 0 : 1} flexShrink={0}>
<Switch>
<Match when={props.row.type === "message" ? props.row : undefined}>
{(row) => (
@@ -1072,11 +1074,95 @@ function SessionRowView(props: {
</Show>
)}
</Match>
<Match when={props.row.type === "turn-usage" ? props.row : undefined}>
{(row) => (
<TurnTokenUsage
messageIDs={row().messageIDs}
previousCacheRead={row().previousCacheRead}
message={props.message}
/>
)}
</Match>
</Switch>
</box>
)
}
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, index) => {
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 breakdown = [
message.tokens.input > 0 ? `${message.tokens.input.toLocaleString()} input` : undefined,
message.tokens.output > 0 ? `${message.tokens.output.toLocaleString()} output` : undefined,
message.tokens.reasoning > 0 ? `${message.tokens.reasoning.toLocaleString()} reasoning` : undefined,
message.tokens.cache.write > 0 ? `${message.tokens.cache.write.toLocaleString()} cache write` : undefined,
]
.filter((value): value is string => value !== undefined)
.join(", ")
const cacheBust =
previousCacheRead !== undefined && message.tokens.cache.read < previousCacheRead
? previousCacheRead - message.tokens.cache.read
: undefined
previousCacheRead = message.tokens.cache.read
return [
{
label: `Step ${index + 1} [${message.finish ?? "unknown"}]`,
newTokens,
cached: message.tokens.cache.read,
total,
breakdown,
cacheBust,
},
]
})
})
return (
<Show when={config.data.debug?.turn_tokens === true && steps().length > 0}>
<box paddingLeft={3} flexDirection="column">
<box flexDirection="row">
<text width={INLINE_TOOL_ICON_WIDTH} fg={themeV2.text.subdued}>
</text>
<text fg={themeV2.text.subdued}>Turn token usage:</text>
</box>
<For each={steps()}>
{(item) => (
<box paddingLeft={INLINE_TOOL_ICON_WIDTH} flexDirection="column">
<text fg={themeV2.text.subdued}>
{item.label}:{" "}
<span style={{ attributes: TextAttributes.BOLD }}>New tokens: {item.newTokens.toLocaleString()}</span>
{` · Cached: ${item.cached.toLocaleString()} · Total: ${item.total.toLocaleString()}${item.breakdown ? ` [${item.breakdown}]` : ""}`}
</text>
<Show when={item.cacheBust !== undefined}>
<text fg={themeV2.text.feedback.error.default}>
! Cache bust: {item.cacheBust?.toLocaleString()} fewer cached tokens than the previous step
</text>
</Show>
</box>
)}
</For>
</box>
</Show>
)
}
function BackgroundToolHint(props: { messages: SessionMessageInfo[] }) {
const { themeV2 } = useTheme()
const shortcut = Keymap.useShortcut("session.background")
+50 -1
View File
@@ -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<string>) {
const data = useData()
@@ -127,6 +128,26 @@ export function createSessionRows(sessionID: Accessor<string>) {
),
)
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<SessionMessageAssistant["tokens"]>) {
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<string>()
@@ -309,6 +356,8 @@ function rowBoundaryMessageID(row: SessionRow, messages: Map<string, SessionMess
? row.refs[0]?.messageID
: row.type === "assistant-footer"
? row.messageID
: row.type === "turn-usage"
? row.messageIDs[0]
: undefined
if (!messageID) return undefined
const message = messages.get(messageID)
+35 -1
View File
@@ -255,6 +255,35 @@ test("renders synthetic messages with descriptions", () => {
])
})
test("derives turn usage rows from completed assistant steps", () => {
const usage = (read: number) => ({
input: 100,
output: 16,
reasoning: 0,
cache: { read, write: 0 },
})
const messages: SessionMessageInfo[] = [
{ type: "user", id: "user-1", text: "First", time: { created: 0 } },
assistant("assistant-1", [], { finish: "stop", tokens: usage(10) }),
{ type: "user", id: "user-2", text: "Second", time: { created: 2 } },
assistant("assistant-2", [], { finish: "tool-calls", tokens: usage(8) }),
assistant("assistant-3", [], { finish: "stop", tokens: usage(12) }),
]
expect(reduceSessionRows(messages)).toEqual([
{ type: "message", messageID: "user-1" },
{ type: "assistant-footer", messageID: "assistant-1" },
{ type: "turn-usage", messageIDs: ["assistant-1"] },
{ type: "message", messageID: "user-2" },
{ type: "assistant-footer", messageID: "assistant-3" },
{
type: "turn-usage",
messageIDs: ["assistant-2", "assistant-3"],
previousCacheRead: 10,
},
])
})
test("renders a footer for a pre-output retry assistant after replay", () => {
const message = assistant("assistant-retry", [])
message.retry = {
@@ -294,7 +323,11 @@ test("places a running compaction barrier before every queued user message", ()
])
})
function assistant(id: string, content: SessionMessageAssistant["content"]): SessionMessageAssistant {
function assistant(
id: string,
content: SessionMessageAssistant["content"],
info: Partial<SessionMessageAssistant> = {},
): SessionMessageAssistant {
return {
type: "assistant",
id,
@@ -302,6 +335,7 @@ function assistant(id: string, content: SessionMessageAssistant["content"]): Ses
model: { id: "model", providerID: "provider" },
content,
time: { created: 1 },
...info,
}
}
+2 -2
View File
@@ -15,7 +15,7 @@ test("resolves nested config and keybind defaults", () => {
leader: { timeout: 500 },
scroll: { speed: 2, acceleration: true },
diffs: { view: "split" },
debug: { devtools: true },
debug: { devtools: true, turn_tokens: true },
},
{ terminalSuspend: true },
)
@@ -24,7 +24,7 @@ test("resolves nested config and keybind defaults", () => {
expect(config.keybinds.get("leader")?.[0]?.key).toBe("ctrl+o")
expect(config.scroll).toEqual({ speed: 2, acceleration: true })
expect(config.diffs).toEqual({ view: "split" })
expect(config.debug).toEqual({ devtools: true })
expect(config.debug).toEqual({ devtools: true, turn_tokens: true })
})
test("provides config and its host interface", async () => {