Skip to content

Commit cf4c095

Browse files
committed
feat: token display in timeline/session and message details
1 parent 8fbba8d commit cf4c095

14 files changed

Lines changed: 1028 additions & 172 deletions

File tree

packages/app/src/pages/session.tsx

Lines changed: 69 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ import { DialogFork } from "@/components/dialog-fork"
5050
import { useCommand } from "@/context/command"
5151
import { useLanguage } from "@/context/language"
5252
import { useNavigate, useParams } from "@solidjs/router"
53-
import { UserMessage } from "@opencode-ai/sdk/v2"
53+
import { UserMessage, AssistantMessage } from "@opencode-ai/sdk/v2"
5454
import type { FileDiff } from "@opencode-ai/sdk/v2/client"
5555
import { useSDK } from "@/context/sdk"
5656
import { usePrompt } from "@/context/prompt"
@@ -241,6 +241,8 @@ export default function Page() {
241241
const comments = useComments()
242242
const permission = usePermission()
243243

244+
const [pendingAssistantMessage, setPendingAssistantMessage] = createSignal<string | undefined>(undefined)
245+
244246
const request = createMemo(() => {
245247
const sessionID = params.id
246248
if (!sessionID) return
@@ -279,6 +281,7 @@ export default function Page() {
279281
})
280282
.finally(() => setUi("responding", false))
281283
}
284+
282285
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
283286
const tabs = createMemo(() => layout.tabs(sessionKey))
284287
const view = createMemo(() => layout.view(sessionKey))
@@ -1530,6 +1533,38 @@ export default function Page() {
15301533
updateHash(message.id)
15311534
}
15321535

1536+
const scrollToAnyMessage = (messageID: string, behavior: ScrollBehavior = "smooth") => {
1537+
const allMsgs = messages()
1538+
const message = allMsgs.find((m) => m.id === messageID)
1539+
if (!message) return
1540+
1541+
if (message.role === "user") {
1542+
scrollToMessage(message as UserMessage, behavior)
1543+
return
1544+
}
1545+
1546+
const assistantMsg = message as AssistantMessage
1547+
const parentUserMsg = userMessages().find((m) => m.id === assistantMsg.parentID)
1548+
if (!parentUserMsg) return
1549+
1550+
setStore("expanded", parentUserMsg.id, true)
1551+
1552+
requestAnimationFrame(() => {
1553+
const el = document.getElementById(anchor(messageID))
1554+
if (!el) {
1555+
requestAnimationFrame(() => {
1556+
const next = document.getElementById(anchor(messageID))
1557+
if (!next) return
1558+
scrollToElement(next, behavior)
1559+
})
1560+
return
1561+
}
1562+
scrollToElement(el, behavior)
1563+
})
1564+
1565+
updateHash(messageID)
1566+
}
1567+
15331568
const applyHash = (behavior: ScrollBehavior) => {
15341569
const hash = window.location.hash.slice(1)
15351570
if (!hash) {
@@ -1540,14 +1575,18 @@ export default function Page() {
15401575
const match = hash.match(/^message-(.+)$/)
15411576
if (match) {
15421577
autoScroll.pause()
1543-
const msg = visibleUserMessages().find((m) => m.id === match[1])
1544-
if (msg) {
1545-
scrollToMessage(msg, behavior)
1578+
const msg = messages().find((m) => m.id === match[1])
1579+
if (!msg) {
1580+
if (visibleUserMessages().find((m) => m.id === match[1])) return
15461581
return
15471582
}
15481583

1549-
// If we have a message hash but the message isn't loaded/rendered yet,
1550-
// don't fall back to "bottom". We'll retry once messages arrive.
1584+
if (msg.role === "assistant") {
1585+
setPendingAssistantMessage(match[1])
1586+
return
1587+
}
1588+
1589+
scrollToMessage(msg as UserMessage, behavior)
15511590
return
15521591
}
15531592

@@ -1642,7 +1681,10 @@ export default function Page() {
16421681
const hash = window.location.hash.slice(1)
16431682
const match = hash.match(/^message-(.+)$/)
16441683
if (!match) return undefined
1645-
return match[1]
1684+
const hashId = match[1]
1685+
const msg = messages().find((m) => m.id === hashId)
1686+
if (msg && msg.role === "assistant") return undefined
1687+
return hashId
16461688
})()
16471689
if (!targetId) return
16481690
if (store.messageId === targetId) return
@@ -1654,6 +1696,26 @@ export default function Page() {
16541696
requestAnimationFrame(() => scrollToMessage(msg, "auto"))
16551697
})
16561698

1699+
// Handle pending assistant message navigation
1700+
createEffect(() => {
1701+
const sessionID = params.id
1702+
const ready = messagesReady()
1703+
if (!sessionID || !ready) return
1704+
1705+
// dependencies
1706+
messages().length
1707+
store.turnStart
1708+
1709+
const targetId = pendingAssistantMessage()
1710+
if (!targetId) return
1711+
if (store.messageId === targetId) return
1712+
1713+
const msg = messages().find((m) => m.id === targetId)
1714+
if (!msg) return
1715+
if (pendingAssistantMessage() === targetId) setPendingAssistantMessage(undefined)
1716+
requestAnimationFrame(() => scrollToAnyMessage(targetId, "auto"))
1717+
})
1718+
16571719
createEffect(() => {
16581720
const sessionID = params.id
16591721
const ready = messagesReady()
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
import { TextAttributes, ScrollBoxRenderable } from "@opentui/core"
2+
import { useKeyboard } from "@opentui/solid"
3+
import { useDialog } from "../../ui/dialog"
4+
import { useTheme } from "@tui/context/theme"
5+
import type { Part, AssistantMessage } from "@opencode-ai/sdk/v2"
6+
import { useSync } from "@tui/context/sync"
7+
import { Clipboard } from "../../util/clipboard"
8+
import { useToast } from "../../ui/toast"
9+
import { createSignal, For, Show } from "solid-js"
10+
import { Token } from "@/util/token"
11+
12+
interface DialogInspectProps {
13+
message: AssistantMessage | any
14+
parts: Part[]
15+
}
16+
17+
const PartCard = (props: { title: string; children: any; theme: any }) => (
18+
<box flexDirection="column" borderColor={props.theme.borderSubtle} borderStyle="single" padding={1}>
19+
<text attributes={TextAttributes.BOLD} fg={props.theme.textMuted}>
20+
{props.title}
21+
</text>
22+
{props.children}
23+
</box>
24+
)
25+
26+
export function DialogInspect(props: DialogInspectProps) {
27+
const sync = useSync()
28+
const { theme, syntax } = useTheme()
29+
const dialog = useDialog()
30+
const toast = useToast()
31+
const msg = () => sync.data.message[props.message.sessionID]?.find((m) => m.id === props.message.id)
32+
const parts = () => sync.data.part[props.message.id] ?? props.parts
33+
34+
const [showRaw, setShowRaw] = createSignal(true)
35+
dialog.setSize("xlarge")
36+
37+
let scrollRef: ScrollBoxRenderable | undefined
38+
39+
const copy = () =>
40+
Clipboard.copy(JSON.stringify(props.parts, null, 2))
41+
.then(() => toast.show({ message: "Copied", variant: "success" }))
42+
.catch(() => toast.show({ message: "Failed", variant: "error" }))
43+
44+
const toggleRaw = () => setShowRaw((p) => !p)
45+
46+
useKeyboard((evt) => {
47+
const h = {
48+
down: () => scrollRef?.scrollBy(1),
49+
up: () => scrollRef?.scrollBy(-1),
50+
pagedown: () => scrollRef?.scrollBy(scrollRef?.height ?? 20),
51+
pageup: () => scrollRef?.scrollBy(-(scrollRef?.height ?? 20)),
52+
}
53+
const k: Record<string, () => void> = {
54+
c: copy,
55+
s: toggleRaw,
56+
down: h.down,
57+
up: h.up,
58+
pagedown: h.pagedown,
59+
pageup: h.pageup,
60+
}
61+
if (k[evt.name]) {
62+
evt.preventDefault()
63+
k[evt.name]()
64+
}
65+
})
66+
67+
const toolEstimate = () => {
68+
const p = parts()
69+
let sum = 0
70+
for (const part of p) {
71+
if (part.type === "tool") {
72+
const state = (part as any).state
73+
if (state?.output) {
74+
const output = typeof state.output === "string" ? state.output : JSON.stringify(state.output)
75+
sum += Token.estimate(output)
76+
}
77+
}
78+
}
79+
return sum
80+
}
81+
82+
const tokenFields =
83+
msg()?.role === "assistant"
84+
? {
85+
line1: [
86+
{ l: "Input", v: (msg() as any).tokens?.input },
87+
{ l: "Output", v: (msg() as any).tokens?.output },
88+
{ l: "Reasoning", v: (msg() as any).tokens?.reasoning },
89+
{ l: "Tool", v: toolEstimate(), estimated: true },
90+
],
91+
line2: [
92+
{ l: "Cache Write", v: (msg() as any).tokens?.cache?.write },
93+
{ l: "Cache Read", v: (msg() as any).tokens?.cache?.read },
94+
],
95+
}
96+
: null
97+
98+
const tokenTotal = tokenFields ? [...tokenFields.line1, ...tokenFields.line2].reduce((s, f) => s + (f.v || 0), 0) : 0
99+
100+
const renderPart = (part: Part) => {
101+
if (part.type === "text")
102+
return (
103+
<PartCard title="Text" theme={theme}>
104+
<text fg={theme.text}>{part.text}</text>
105+
</PartCard>
106+
)
107+
if (part.type === "patch")
108+
return (
109+
<PartCard title={`Patch (${part.hash?.substring(0, 7)})`} theme={theme}>
110+
<text fg={theme.text}>Updated: {part.files?.join(", ")}</text>
111+
</PartCard>
112+
)
113+
if (part.type === "tool")
114+
return (
115+
<PartCard title={`Tool: ${part.tool} (${part.state?.status})`} theme={theme}>
116+
<text fg={theme.textMuted}>Input: {JSON.stringify(part.state?.input)}</text>
117+
<Show when={part.state?.status === "completed" && (part.state as any).output}>
118+
<text fg={theme.text}>{JSON.stringify((part.state as any).output)}</text>
119+
</Show>
120+
<Show when={part.state?.status === "error" && (part.state as any).error}>
121+
<text fg={theme.error}>{(part.state as any).error}</text>
122+
</Show>
123+
</PartCard>
124+
)
125+
if (part.type === "file")
126+
return (
127+
<PartCard title="File" theme={theme}>
128+
<text fg={theme.text}>
129+
{part.filename} ({part.mime})
130+
</text>
131+
</PartCard>
132+
)
133+
return (
134+
<PartCard title={part.type} theme={theme}>
135+
<code
136+
filetype="json"
137+
content={JSON.stringify(part, null, 2)}
138+
syntaxStyle={syntax()}
139+
drawUnstyledText
140+
fg={theme.text}
141+
/>
142+
</PartCard>
143+
)
144+
}
145+
146+
return (
147+
<box paddingLeft={2} paddingRight={2} gap={1} height="100%">
148+
<box flexDirection="row" justifyContent="space-between" flexShrink={0}>
149+
<text attributes={TextAttributes.BOLD} fg={theme.text}>
150+
Inspection ({props.message.id})
151+
</text>
152+
<box onMouseUp={() => dialog.clear()}>
153+
<text fg={theme.textMuted}>[esc]</text>
154+
</box>
155+
</box>
156+
157+
<Show when={tokenFields}>
158+
<box borderStyle="single" borderColor={theme.borderSubtle} padding={1} flexShrink={0}>
159+
<box flexDirection="row" gap={2} flexWrap="wrap">
160+
<For each={tokenFields!.line1}>
161+
{(f) => (
162+
<text fg={theme.textMuted}>
163+
{f.l}:{" "}
164+
<span style={{ fg: theme.text }}>
165+
{f.estimated && f.v ? "~" : ""}
166+
{(f.v || 0).toLocaleString()}
167+
</span>
168+
</text>
169+
)}
170+
</For>
171+
</box>
172+
<box flexDirection="row" gap={2} flexWrap="wrap">
173+
<For each={tokenFields!.line2}>
174+
{(f) => (
175+
<text fg={theme.textMuted}>
176+
{f.l}: <span style={{ fg: theme.text }}>{(f.v || 0).toLocaleString()}</span>
177+
</text>
178+
)}
179+
</For>
180+
</box>
181+
<text fg={theme.accent} marginTop={1}>
182+
Total: ~{tokenTotal.toLocaleString()} tokens
183+
</text>
184+
</box>
185+
</Show>
186+
187+
<scrollbox
188+
ref={(r: ScrollBoxRenderable) => (scrollRef = r)}
189+
flexGrow={1}
190+
border={["bottom", "top"]}
191+
borderColor={theme.borderSubtle}
192+
>
193+
<Show
194+
when={!showRaw()}
195+
fallback={
196+
<code
197+
filetype="json"
198+
content={JSON.stringify(parts(), null, 2)}
199+
syntaxStyle={syntax()}
200+
drawUnstyledText
201+
fg={theme.text}
202+
/>
203+
}
204+
>
205+
<box flexDirection="column" gap={1}>
206+
<For each={parts().filter((p) => !["step-start", "step-finish", "reasoning"].includes(p.type))}>
207+
{(p) => renderPart(p)}
208+
</For>
209+
</box>
210+
</Show>
211+
</scrollbox>
212+
213+
<box flexDirection="row" justifyContent="space-between" paddingBottom={1} flexShrink={0} gap={1}>
214+
<box flexDirection="row" gap={2}>
215+
<text fg={theme.textMuted}>↑↓ PgUp/Dn</text>
216+
<text fg={theme.textMuted}>S raw</text>
217+
<text fg={theme.textMuted}>C copy</text>
218+
</box>
219+
<box flexDirection="row" gap={1}>
220+
<box
221+
paddingLeft={2}
222+
paddingRight={2}
223+
borderStyle="single"
224+
borderColor={theme.borderSubtle}
225+
onMouseUp={toggleRaw}
226+
>
227+
<text fg={theme.text}>{showRaw() ? "Parsed" : "Raw"}</text>
228+
</box>
229+
<box paddingLeft={2} paddingRight={2} borderStyle="single" borderColor={theme.border} onMouseUp={copy}>
230+
<text fg={theme.text}>Copy</text>
231+
</box>
232+
</box>
233+
</box>
234+
</box>
235+
)
236+
}

0 commit comments

Comments
 (0)