From 284441f8d22fc9a0083639ef492ec6d215195358 Mon Sep 17 00:00:00 2001 From: ops Date: Thu, 15 Jan 2026 18:04:32 +0100 Subject: [PATCH 1/3] timeline tokens --- .../tui/routes/session/dialog-timeline.tsx | 241 +++++++++++++++++- .../src/cli/cmd/tui/ui/dialog-select.tsx | 14 +- .../opencode/src/cli/cmd/tui/ui/dialog.tsx | 2 +- packages/opencode/src/provider/transform.ts | 53 +++- packages/opencode/src/session/index.ts | 1 + packages/opencode/src/session/llm.ts | 2 +- packages/opencode/src/util/token.ts | 6 + .../opencode/test/provider/transform.test.ts | 128 +++++----- packages/sdk/js/src/v2/gen/types.gen.ts | 1 + 9 files changed, 365 insertions(+), 83 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx index 87248a6a8ba..edb7cb6e402 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx @@ -1,37 +1,183 @@ import { createMemo, onMount } from "solid-js" import { useSync } from "@tui/context/sync" import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" -import type { TextPart } from "@opencode-ai/sdk/v2" +import type { Part, Message, AssistantMessage, ToolPart, FilePart } from "@opencode-ai/sdk/v2" import { Locale } from "@/util/locale" import { DialogMessage } from "./dialog-message" import { useDialog } from "../../ui/dialog" import type { PromptInfo } from "../../component/prompt/history" +import { Token } from "@/util/token" +import { useTheme } from "@tui/context/theme" +import { useSDK } from "@tui/context/sdk" +import fs from "fs" +import path from "path" +import { produce } from "solid-js/store" +import { Binary } from "@opencode-ai/util/binary" +import { Global } from "@/global" + +function formatTokenCount(tokens: number): string { + return tokens.toString().padStart(7) +} + +function getMessageTokens(message: Message, parts: Part[], isCompaction: boolean = false): number { + if (message.role === "assistant") { + const assistantMsg = message as AssistantMessage + let total = 0 + + // Calculate tokens for this message turn only (not cumulative) + if (assistantMsg.tokens) { + const input = assistantMsg.tokens.input || 0 + const output = assistantMsg.tokens.output || 0 + const cacheWrite = assistantMsg.tokens.cache?.write || 0 + const reasoning = assistantMsg.tokens.reasoning || 0 + + // Exclude cacheRead as it represents cumulative context, not this message's cost + total = input + output + cacheWrite + reasoning + } else { + // Fall back to aggregating from step-finish parts + for (const part of parts) { + if (part.type === "step-finish" && (part as any).tokens) { + const tokens = (part as any).tokens + total += tokens.input + tokens.output + (tokens.reasoning || 0) + } + } + } + + // Add tool output tokens (not included in message.tokens) + for (const part of parts) { + if (part.type === "tool") { + const toolPart = part as ToolPart + const state = toolPart.state as any + if (state?.output) { + const output = typeof state.output === "string" ? state.output : JSON.stringify(state.output) + total += Token.estimate(output) + } + } + } + + return total + } + + // User message - estimate from parts + let estimate = 0 + for (const part of parts) { + if (part.type === "text" && !part.synthetic && !part.ignored) { + estimate += Token.estimate(part.text) + } + if (part.type === "file") { + const filePart = part as FilePart + if (filePart.source?.text?.value) { + estimate += Token.estimate(filePart.source.text.value) + } else if (filePart.mime.startsWith("image/")) { + estimate += Token.estimateImage(filePart.url) + } + } + } + return estimate +} + +function getMessageSummary(parts: Part[]): string { + const textPart = parts.find((x) => x.type === "text" && !x.synthetic && !x.ignored) + if (textPart && textPart.type === "text") { + return textPart.text.replace(/\n/g, " ") + } + + const toolParts = parts.filter((x) => x.type === "tool") as ToolPart[] + if (toolParts.length > 0) { + const tools = toolParts.map((p) => p.tool).join(", ") + return `[${tools}]` + } + + const fileParts = parts.filter((x) => x.type === "file") as FilePart[] + if (fileParts.length > 0) { + const files = fileParts.map((p) => p.filename || "file").join(", ") + return `[files: ${files}]` + } + + return "[no content]" +} export function DialogTimeline(props: { sessionID: string onMove: (messageID: string) => void setPrompt?: (prompt: PromptInfo) => void }) { - const sync = useSync() + const syncCtx = useSync() + const sync = syncCtx.data + const setStore = syncCtx.set const dialog = useDialog() + const { theme } = useTheme() + const sdk = useSDK() onMount(() => { dialog.setSize("large") }) const options = createMemo((): DialogSelectOption[] => { - const messages = sync.data.message[props.sessionID] ?? [] + const messages = sync.message[props.sessionID] ?? [] const result = [] as DialogSelectOption[] + for (const message of messages) { - if (message.role !== "user") continue - const part = (sync.data.part[message.id] ?? []).find( - (x) => x.type === "text" && !x.synthetic && !x.ignored, - ) as TextPart - if (!part) continue + const parts = sync.part[message.id] ?? [] + + // Check if this is a compaction summary message + const isCompactionSummary = message.role === "assistant" && (message as AssistantMessage).summary === true + + // Get the token count for this specific message (delta only, not cumulative) + const messageTokens = getMessageTokens(message, parts, isCompactionSummary) + + // Display the tokens directly (no cumulative calculation needed) + const delta = messageTokens + + const formatted = formatTokenCount(delta) + + // Token count color based on thresholds (cold to hot gradient) + // Using delta for color coding + let tokenColor = theme.textMuted // grey < 1k + if (delta >= 20000) { + tokenColor = theme.error // red 20k+ + } else if (delta >= 10000) { + tokenColor = theme.warning // orange 10k+ + } else if (delta >= 5000) { + tokenColor = theme.accent // purple 5k+ + } else if (delta >= 2000) { + tokenColor = theme.secondary // blue 2k+ + } else if (delta >= 1000) { + tokenColor = theme.info // cyan 1k+ + } + + const summary = getMessageSummary(parts) + + // Debug: Extract token breakdown for assistant messages + let tokenDebug = "" + if (message.role === "assistant") { + const assistantMsg = message as AssistantMessage + if (assistantMsg.tokens) { + const input = assistantMsg.tokens.input || 0 + const output = assistantMsg.tokens.output || 0 + const cacheRead = assistantMsg.tokens.cache?.read || 0 + const cacheWrite = assistantMsg.tokens.cache?.write || 0 + const reasoning = assistantMsg.tokens.reasoning || 0 + tokenDebug = `(${input}/${output}/${cacheRead}/${cacheWrite}/${reasoning}) ` + } + } + + const prefix = isCompactionSummary ? "[compaction] " : message.role === "assistant" ? "agent: " : "" + const title = tokenDebug + prefix + summary + + const gutter = [{formatted}] + + // Normal assistant messages use textMuted for title + const isAssistant = message.role === "assistant" && !isCompactionSummary + result.push({ - title: part.text.replace(/\n/g, " "), + title, + gutter: isCompactionSummary ? [{formatted}] : gutter, value: message.id, footer: Locale.time(message.time.created), + titleColor: isCompactionSummary ? theme.success : isAssistant ? theme.textMuted : undefined, + footerColor: isCompactionSummary ? theme.success : undefined, + bg: isCompactionSummary ? theme.success : undefined, onSelect: (dialog) => { dialog.replace(() => ( @@ -39,9 +185,84 @@ export function DialogTimeline(props: { }, }) } + result.reverse() return result }) - return props.onMove(option.value)} title="Timeline" options={options()} /> + const handleDelete = async (messageID: string) => { + try { + const storageBase = path.join(Global.Path.data, "storage") + + // Delete message file + const messagePath = path.join(storageBase, "message", props.sessionID, `${messageID}.json`) + if (fs.existsSync(messagePath)) { + fs.unlinkSync(messagePath) + } + + // Delete all part files + const partsDir = path.join(storageBase, "part", messageID) + if (fs.existsSync(partsDir)) { + const partFiles = fs.readdirSync(partsDir) + for (const file of partFiles) { + fs.unlinkSync(path.join(partsDir, file)) + } + fs.rmdirSync(partsDir) + } + + // Invalidate session cache by setting the flag in storage + const sessionPath = path.join( + storageBase, + "session", + "project_" + sync.session.find((s) => s.id === props.sessionID)?.projectID || "", + `${props.sessionID}.json`, + ) + if (fs.existsSync(sessionPath)) { + const sessionData = JSON.parse(fs.readFileSync(sessionPath, "utf-8")) + sessionData.cacheInvalidated = true + fs.writeFileSync(sessionPath, JSON.stringify(sessionData, null, 2)) + } + + // Update the UI store to remove the message + const messages = sync.message[props.sessionID] + const result = Binary.search(messages, messageID, (m) => m.id) + if (result.found) { + setStore( + "message", + props.sessionID, + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + } + + // Also remove parts from UI + setStore("part", messageID, []) + + // Update session in UI store to reflect cache invalidation + const sessionIndex = sync.session.findIndex((s) => s.id === props.sessionID) + if (sessionIndex >= 0) { + setStore("session", sessionIndex, "cacheInvalidated", true) + } + } catch (error) { + // Silent fail + } + } + + return ( + props.onMove(option.value)} + title="Timeline" + options={options()} + keybind={[ + { + keybind: { name: "delete", ctrl: false, meta: false, shift: false, leader: false }, + title: "Delete", + onTrigger: (option) => { + handleDelete(option.value) + }, + }, + ]} + /> + ) } diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index 8ff5a3b23b1..82e2e313d47 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -38,7 +38,9 @@ export interface DialogSelectOption { disabled?: boolean bg?: RGBA gutter?: JSX.Element - onSelect?: (ctx: DialogContext) => void + titleColor?: RGBA + footerColor?: RGBA + onSelect?: (ctx: DialogContext, trigger?: "prompt") => void } export type DialogSelectRef = { @@ -310,6 +312,8 @@ export function DialogSelect(props: DialogSelectProps) { active={active()} current={current()} gutter={option.gutter} + titleColor={option.titleColor} + footerColor={option.footerColor} /> ) @@ -345,6 +349,8 @@ function Option(props: { current?: boolean footer?: JSX.Element | string gutter?: JSX.Element + titleColor?: RGBA + footerColor?: RGBA onMouseOver?: () => void }) { const { theme } = useTheme() @@ -364,20 +370,20 @@ function Option(props: { - {Locale.truncate(props.title, 61)} + {Locale.truncate(props.title, 60)} {props.description} - {props.footer} + {props.footer} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx index 57375ba09db..809f98d101e 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx @@ -36,7 +36,7 @@ export function Dialog( if (renderer.getSelection()) return e.stopPropagation() }} - width={props.size === "large" ? 80 : 60} + width={props.size === "large" ? 90 : 60} maxWidth={dimensions().width - 2} backgroundColor={theme.backgroundPanel} paddingTop={1} diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 39eef6c9165..1a3dc35688c 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -44,6 +44,35 @@ export namespace ProviderTransform { model: Provider.Model, options: Record, ): ModelMessage[] { + // Strip openai itemId metadata following what codex does + if (model.api.npm === "@ai-sdk/openai" || options.store === false) { + msgs = msgs.map((msg) => { + if (msg.providerOptions) { + for (const options of Object.values(msg.providerOptions)) { + if (options && typeof options === "object") { + delete options["itemId"] + delete options["reasoningEncryptedContent"] + } + } + } + if (!Array.isArray(msg.content)) { + return msg + } + const content = msg.content.map((part) => { + if (part.providerOptions) { + for (const options of Object.values(part.providerOptions)) { + if (options && typeof options === "object") { + delete options["itemId"] + delete options["reasoningEncryptedContent"] + } + } + } + return part + }) + return { ...msg, content } as typeof msg + }) + } + // Anthropic rejects messages with empty content - filter out empty string messages // and remove empty text/reasoning parts from array content if (model.api.npm === "@ai-sdk/anthropic") { @@ -162,7 +191,20 @@ export namespace ProviderTransform { return msgs } - function applyCaching(msgs: ModelMessage[], providerID: string): ModelMessage[] { + async function applyCaching(msgs: ModelMessage[], providerID: string, sessionID?: string): Promise { + // Skip caching if session cache was invalidated (e.g., message deletion) + if (sessionID) { + const { Session } = await import("../session") + const session = await Session.get(sessionID).catch(() => null) + if (session?.cacheInvalidated) { + // Clear flag and return without cache control markers + await Session.update(sessionID, (draft) => { + delete draft.cacheInvalidated + }).catch(() => {}) + return msgs + } + } + const system = msgs.filter((msg) => msg.role === "system").slice(0, 2) const final = msgs.filter((msg) => msg.role !== "system").slice(-2) @@ -236,7 +278,12 @@ export namespace ProviderTransform { }) } - export function message(msgs: ModelMessage[], model: Provider.Model, options: Record) { + export async function message( + msgs: ModelMessage[], + model: Provider.Model, + options: Record = {}, + sessionID?: string, + ) { msgs = unsupportedParts(msgs, model) msgs = normalizeMessages(msgs, model, options) if ( @@ -247,7 +294,7 @@ export namespace ProviderTransform { model.id.includes("claude") || model.api.npm === "@ai-sdk/anthropic" ) { - msgs = applyCaching(msgs, model.providerID) + msgs = await applyCaching(msgs, model.providerID, sessionID) } // Remap providerOptions keys from stored providerID to expected SDK key diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index fb0836bfb78..d714c7b1bb2 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -76,6 +76,7 @@ export namespace Session { diff: z.string().optional(), }) .optional(), + cacheInvalidated: z.boolean().optional(), }) .meta({ ref: "Session", diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index d651308032e..be2ecf52a7c 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -262,7 +262,7 @@ export namespace LLM { async transformParams(args) { if (args.type === "stream") { // @ts-expect-error - args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options) + args.params.prompt = await ProviderTransform.message(args.params.prompt, input.model, input.sessionID) } return args.params }, diff --git a/packages/opencode/src/util/token.ts b/packages/opencode/src/util/token.ts index cee5adc3771..dcd8c4c97cc 100644 --- a/packages/opencode/src/util/token.ts +++ b/packages/opencode/src/util/token.ts @@ -4,4 +4,10 @@ export namespace Token { export function estimate(input: string) { return Math.max(0, Math.round((input || "").length / CHARS_PER_TOKEN)) } + + export function estimateImage(urlOrData: string): number { + // Estimate tokens for image data/URLs since providers don't return image token counts + // Uses string length as proxy: data URLs contain base64 image data, file paths are small + return Math.max(100, Math.round(urlOrData.length / 170)) + } } diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 037083d5e30..061b2b563fa 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -222,7 +222,7 @@ describe("ProviderTransform.schema - gemini array items", () => { }) describe("ProviderTransform.message - DeepSeek reasoning content", () => { - test("DeepSeek with tool calls includes reasoning_content in providerOptions", () => { + test("DeepSeek with tool calls includes reasoning_content in providerOptions", async () => { const msgs = [ { role: "assistant", @@ -238,7 +238,7 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => { }, ] as any[] - const result = ProviderTransform.message( + const result = await ProviderTransform.message( msgs, { id: "deepseek/deepseek-chat", @@ -289,7 +289,7 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => { expect(result[0].providerOptions?.openaiCompatible?.reasoning_content).toBe("Let me think about this...") }) - test("Non-DeepSeek providers leave reasoning content unchanged", () => { + test("Non-DeepSeek providers leave reasoning content unchanged", async () => { const msgs = [ { role: "assistant", @@ -300,7 +300,7 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => { }, ] as any[] - const result = ProviderTransform.message( + const result = await ProviderTransform.message( msgs, { id: "openai/gpt-4", @@ -378,7 +378,7 @@ describe("ProviderTransform.message - empty image handling", () => { headers: {}, } as any - test("should replace empty base64 image with error text", () => { + test("should replace empty base64 image with error text", async () => { const msgs = [ { role: "user", @@ -389,7 +389,7 @@ describe("ProviderTransform.message - empty image handling", () => { }, ] as any[] - const result = ProviderTransform.message(msgs, mockModel, {}) + const result = await ProviderTransform.message(msgs, mockModel, {}) expect(result).toHaveLength(1) expect(result[0].content).toHaveLength(2) @@ -400,7 +400,7 @@ describe("ProviderTransform.message - empty image handling", () => { }) }) - test("should keep valid base64 images unchanged", () => { + test("should keep valid base64 images unchanged", async () => { const validBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" const msgs = [ @@ -413,7 +413,7 @@ describe("ProviderTransform.message - empty image handling", () => { }, ] as any[] - const result = ProviderTransform.message(msgs, mockModel, {}) + const result = await ProviderTransform.message(msgs, mockModel, {}) expect(result).toHaveLength(1) expect(result[0].content).toHaveLength(2) @@ -421,7 +421,7 @@ describe("ProviderTransform.message - empty image handling", () => { expect(result[0].content[1]).toEqual({ type: "image", image: `data:image/png;base64,${validBase64}` }) }) - test("should handle mixed valid and empty images", () => { + test("should handle mixed valid and empty images", async () => { const validBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" const msgs = [ @@ -435,7 +435,7 @@ describe("ProviderTransform.message - empty image handling", () => { }, ] as any[] - const result = ProviderTransform.message(msgs, mockModel, {}) + const result = await ProviderTransform.message(msgs, mockModel, {}) expect(result).toHaveLength(1) expect(result[0].content).toHaveLength(3) @@ -481,21 +481,21 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => headers: {}, } as any - test("filters out messages with empty string content", () => { + test("filters out messages with empty string content", async () => { const msgs = [ { role: "user", content: "Hello" }, { role: "assistant", content: "" }, { role: "user", content: "World" }, ] as any[] - const result = ProviderTransform.message(msgs, anthropicModel, {}) + const result = await ProviderTransform.message(msgs, anthropicModel, {}) expect(result).toHaveLength(2) expect(result[0].content).toBe("Hello") expect(result[1].content).toBe("World") }) - test("filters out empty text parts from array content", () => { + test("filters out empty text parts from array content", async () => { const msgs = [ { role: "assistant", @@ -507,14 +507,14 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => }, ] as any[] - const result = ProviderTransform.message(msgs, anthropicModel, {}) + const result = await ProviderTransform.message(msgs, anthropicModel, {}) expect(result).toHaveLength(1) expect(result[0].content).toHaveLength(1) expect(result[0].content[0]).toEqual({ type: "text", text: "Hello" }) }) - test("filters out empty reasoning parts from array content", () => { + test("filters out empty reasoning parts from array content", async () => { const msgs = [ { role: "assistant", @@ -526,14 +526,14 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => }, ] as any[] - const result = ProviderTransform.message(msgs, anthropicModel, {}) + const result = await ProviderTransform.message(msgs, anthropicModel, {}) expect(result).toHaveLength(1) expect(result[0].content).toHaveLength(1) expect(result[0].content[0]).toEqual({ type: "text", text: "Answer" }) }) - test("removes entire message when all parts are empty", () => { + test("removes entire message when all parts are empty", async () => { const msgs = [ { role: "user", content: "Hello" }, { @@ -546,14 +546,14 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => { role: "user", content: "World" }, ] as any[] - const result = ProviderTransform.message(msgs, anthropicModel, {}) + const result = await ProviderTransform.message(msgs, anthropicModel, {}) expect(result).toHaveLength(2) expect(result[0].content).toBe("Hello") expect(result[1].content).toBe("World") }) - test("keeps non-text/reasoning parts even if text parts are empty", () => { + test("keeps non-text/reasoning parts even if text parts are empty", async () => { const msgs = [ { role: "assistant", @@ -564,7 +564,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => }, ] as any[] - const result = ProviderTransform.message(msgs, anthropicModel, {}) + const result = await ProviderTransform.message(msgs, anthropicModel, {}) expect(result).toHaveLength(1) expect(result[0].content).toHaveLength(1) @@ -576,7 +576,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => }) }) - test("keeps messages with valid text alongside empty parts", () => { + test("keeps messages with valid text alongside empty parts", async () => { const msgs = [ { role: "assistant", @@ -588,7 +588,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => }, ] as any[] - const result = ProviderTransform.message(msgs, anthropicModel, {}) + const result = await ProviderTransform.message(msgs, anthropicModel, {}) expect(result).toHaveLength(1) expect(result[0].content).toHaveLength(2) @@ -596,7 +596,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => expect(result[0].content[1]).toEqual({ type: "text", text: "Result" }) }) - test("does not filter for non-anthropic providers", () => { + test("does not filter for non-anthropic providers", async () => { const openaiModel = { ...anthropicModel, providerID: "openai", @@ -615,7 +615,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => }, ] as any[] - const result = ProviderTransform.message(msgs, openaiModel, {}) + const result = await ProviderTransform.message(msgs, openaiModel, {}) expect(result).toHaveLength(2) expect(result[0].content).toBe("") @@ -649,7 +649,7 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( headers: {}, } as any - test("preserves itemId and reasoningEncryptedContent when store=false", () => { + test("strips itemId and reasoningEncryptedContent when store=false", async () => { const msgs = [ { role: "assistant", @@ -677,14 +677,14 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( }, ] as any[] - const result = ProviderTransform.message(msgs, openaiModel, { store: false }) as any[] + const result = (await ProviderTransform.message(msgs, openaiModel, { store: false })) as any[] expect(result).toHaveLength(1) - expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("rs_123") - expect(result[0].content[1].providerOptions?.openai?.itemId).toBe("msg_456") + expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() + expect(result[0].content[1].providerOptions?.openai?.itemId).toBeUndefined() }) - test("preserves itemId and reasoningEncryptedContent when store=false even when not openai", () => { + test("strips itemId and reasoningEncryptedContent when store=false even when not openai", async () => { const zenModel = { ...openaiModel, providerID: "zen", @@ -716,14 +716,14 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( }, ] as any[] - const result = ProviderTransform.message(msgs, zenModel, { store: false }) as any[] + const result = (await ProviderTransform.message(msgs, zenModel, { store: false })) as any[] expect(result).toHaveLength(1) - expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("rs_123") - expect(result[0].content[1].providerOptions?.openai?.itemId).toBe("msg_456") + expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() + expect(result[0].content[1].providerOptions?.openai?.itemId).toBeUndefined() }) - test("preserves other openai options including itemId", () => { + test("preserves other openai options when stripping itemId", async () => { const msgs = [ { role: "assistant", @@ -742,13 +742,13 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( }, ] as any[] - const result = ProviderTransform.message(msgs, openaiModel, { store: false }) as any[] + const result = (await ProviderTransform.message(msgs, openaiModel, { store: false })) as any[] - expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_123") + expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() expect(result[0].content[0].providerOptions?.openai?.otherOption).toBe("value") }) - test("preserves metadata for openai package when store is true", () => { + test("strips metadata for openai package even when store is true", async () => { const msgs = [ { role: "assistant", @@ -766,13 +766,13 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( }, ] as any[] - // openai package preserves itemId regardless of store value - const result = ProviderTransform.message(msgs, openaiModel, { store: true }) as any[] + // openai package always strips itemId regardless of store value + const result = (await ProviderTransform.message(msgs, openaiModel, { store: true })) as any[] - expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_123") + expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() }) - test("preserves metadata for non-openai packages when store is false", () => { + test("strips metadata for non-openai packages when store is false", async () => { const anthropicModel = { ...openaiModel, providerID: "anthropic", @@ -799,13 +799,13 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( }, ] as any[] - // store=false preserves metadata for non-openai packages - const result = ProviderTransform.message(msgs, anthropicModel, { store: false }) as any[] + // store=false triggers stripping even for non-openai packages + const result = (await ProviderTransform.message(msgs, anthropicModel, { store: false })) as any[] - expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_123") + expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() }) - test("preserves metadata using providerID key when store is false", () => { + test("strips metadata using providerID key when store is false", async () => { const opencodeModel = { ...openaiModel, providerID: "opencode", @@ -833,13 +833,13 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( }, ] as any[] - const result = ProviderTransform.message(msgs, opencodeModel, { store: false }) as any[] + const result = (await ProviderTransform.message(msgs, opencodeModel, { store: false })) as any[] - expect(result[0].content[0].providerOptions?.opencode?.itemId).toBe("msg_123") + expect(result[0].content[0].providerOptions?.opencode?.itemId).toBeUndefined() expect(result[0].content[0].providerOptions?.opencode?.otherOption).toBe("value") }) - test("preserves itemId across all providerOptions keys", () => { + test("strips itemId across all providerOptions keys", async () => { const opencodeModel = { ...openaiModel, providerID: "opencode", @@ -871,17 +871,17 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( }, ] as any[] - const result = ProviderTransform.message(msgs, opencodeModel, { store: false }) as any[] + const result = (await ProviderTransform.message(msgs, opencodeModel, { store: false })) as any[] - expect(result[0].providerOptions?.openai?.itemId).toBe("msg_root") - expect(result[0].providerOptions?.opencode?.itemId).toBe("msg_opencode") - expect(result[0].providerOptions?.extra?.itemId).toBe("msg_extra") - expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_openai_part") - expect(result[0].content[0].providerOptions?.opencode?.itemId).toBe("msg_opencode_part") - expect(result[0].content[0].providerOptions?.extra?.itemId).toBe("msg_extra_part") + expect(result[0].providerOptions?.openai?.itemId).toBeUndefined() + expect(result[0].providerOptions?.opencode?.itemId).toBeUndefined() + expect(result[0].providerOptions?.extra?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.opencode?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.extra?.itemId).toBeUndefined() }) - test("does not strip metadata for non-openai packages when store is not false", () => { + test("does not strip metadata for non-openai packages when store is not false", async () => { const anthropicModel = { ...openaiModel, providerID: "anthropic", @@ -908,7 +908,7 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( }, ] as any[] - const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[] + const result = (await ProviderTransform.message(msgs, anthropicModel, {})) as any[] expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_123") }) @@ -941,7 +941,7 @@ describe("ProviderTransform.message - providerOptions key remapping", () => { headers: {}, }) as any - test("azure keeps 'azure' key and does not remap to 'openai'", () => { + test("azure keeps 'azure' key and does not remap to 'openai'", async () => { const model = createModel("azure", "@ai-sdk/azure") const msgs = [ { @@ -953,13 +953,13 @@ describe("ProviderTransform.message - providerOptions key remapping", () => { }, ] as any[] - const result = ProviderTransform.message(msgs, model, {}) + const result = await ProviderTransform.message(msgs, model, {}) expect(result[0].providerOptions?.azure).toEqual({ someOption: "value" }) expect(result[0].providerOptions?.openai).toBeUndefined() }) - test("openai with github-copilot npm remaps providerID to 'openai'", () => { + test("openai with github-copilot npm remaps providerID to 'openai'", async () => { const model = createModel("github-copilot", "@ai-sdk/github-copilot") const msgs = [ { @@ -971,13 +971,13 @@ describe("ProviderTransform.message - providerOptions key remapping", () => { }, ] as any[] - const result = ProviderTransform.message(msgs, model, {}) + const result = await ProviderTransform.message(msgs, model, {}) expect(result[0].providerOptions?.openai).toEqual({ someOption: "value" }) expect(result[0].providerOptions?.["github-copilot"]).toBeUndefined() }) - test("bedrock remaps providerID to 'bedrock' key", () => { + test("bedrock remaps providerID to 'bedrock' key", async () => { const model = createModel("my-bedrock", "@ai-sdk/amazon-bedrock") const msgs = [ { @@ -989,7 +989,7 @@ describe("ProviderTransform.message - providerOptions key remapping", () => { }, ] as any[] - const result = ProviderTransform.message(msgs, model, {}) + const result = await ProviderTransform.message(msgs, model, {}) expect(result[0].providerOptions?.bedrock).toEqual({ someOption: "value" }) expect(result[0].providerOptions?.["my-bedrock"]).toBeUndefined() @@ -997,7 +997,7 @@ describe("ProviderTransform.message - providerOptions key remapping", () => { }) describe("ProviderTransform.message - claude w/bedrock custom inference profile", () => { - test("adds cachePoint", () => { + test("adds cachePoint", async () => { const model = { id: "amazon-bedrock/custom-claude-sonnet-4.5", providerID: "amazon-bedrock", @@ -1019,7 +1019,7 @@ describe("ProviderTransform.message - claude w/bedrock custom inference profile" }, ] as any[] - const result = ProviderTransform.message(msgs, model, {}) + const result = await ProviderTransform.message(msgs, model, {}) expect(result[0].providerOptions?.bedrock).toEqual( expect.objectContaining({ diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 2a63d721215..d6c7f5dd7b9 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -781,6 +781,7 @@ export type Session = { snapshot?: string diff?: string } + cacheInvalidated?: boolean } export type EventSessionCreated = { From f6312b4e3f5090a2c4ef83c064c5962dfa5dc827 Mon Sep 17 00:00:00 2001 From: Alexey Zaytsev Date: Sun, 25 Jan 2026 01:31:15 -0500 Subject: [PATCH 2/3] Message stats and details dialog Add Details action to timeline dialog with Insert key - Add dialog.push() method to support stacking dialogs - Add Insert keybind in timeline to open message details - Details dialog opens on top of timeline, Esc returns to timeline merged inspect dialog into timelinetokens --- .../cmd/tui/routes/session/dialog-inspect.tsx | 282 ++++++++++++++++++ .../tui/routes/session/dialog-timeline.tsx | 37 ++- .../src/cli/cmd/tui/routes/session/index.tsx | 36 ++- .../src/cli/cmd/tui/ui/dialog-select.tsx | 7 + .../opencode/src/cli/cmd/tui/ui/dialog.tsx | 24 +- 5 files changed, 379 insertions(+), 7 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/routes/session/dialog-inspect.tsx diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-inspect.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-inspect.tsx new file mode 100644 index 00000000000..fdc55bd8b90 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-inspect.tsx @@ -0,0 +1,282 @@ +import { TextAttributes, ScrollBoxRenderable } from "@opentui/core" +import { useKeyboard } from "@opentui/solid" +import { useDialog } from "../../ui/dialog" +import { useTheme } from "@tui/context/theme" +import type { Part, AssistantMessage } from "@opencode-ai/sdk/v2" +import { Clipboard } from "../../util/clipboard" +import { useToast } from "../../ui/toast" +import { createSignal, Show } from "solid-js" + +interface DialogInspectProps { + message: AssistantMessage + parts: Part[] +} + +function toYaml(obj: any, indent = 0): string { + if (obj === null) return "null" + if (obj === undefined) return "undefined" + if (typeof obj !== "object") return String(obj) + + const spaces = " ".repeat(indent) + + if (Array.isArray(obj)) { + if (obj.length === 0) return "[]" + return obj + .map((item) => { + if (typeof item === "object" && item !== null) { + return `\n${spaces}- ${toYaml(item, indent + 2).trimStart()}` + } + return `\n${spaces}- ${String(item)}` + }) + .join("") + } + + const keys = Object.keys(obj) + if (keys.length === 0) return "{}" + + return keys + .map((key) => { + const value = obj[key] + if (typeof value === "object" && value !== null) { + if (Array.isArray(value) && value.length === 0) return `\n${spaces}${key}: []` + if (Object.keys(value).length === 0) return `\n${spaces}${key}: {}` + return `\n${spaces}${key}:${toYaml(value, indent + 2)}` + } + if (typeof value === "string" && value.includes("\n")) { + return `\n${spaces}${key}: |\n${value + .split("\n") + .map((l) => spaces + " " + l) + .join("\n")}` + } + return `\n${spaces}${key}: ${String(value)}` + }) + .join("") +} + +function PartView(props: { part: Part; theme: any; syntax: any }) { + const { part, theme, syntax } = props + + if (part.type === "text") { + return ( + + + Text + + {part.text} + + ) + } + + if (part.type === "patch") { + return ( + + + Patch ({part.hash.substring(0, 7)}) + + Updated files: + + {part.files.map((f) => ( + - {f} + ))} + + + ) + } + + if (part.type === "tool") { + return ( + + + Tool Use: {part.tool} ({part.state.status}) + + + Input: + {toYaml(part.state.input).trim()} + + + + Output: + {(part.state as any).output} + + + + + Error: + {(part.state as any).error} + + + + ) + } + + if (part.type === "reasoning") { + return ( + + + Reasoning + + {part.text} + + ) + } + + if (part.type === "file") { + return ( + + + File Attachment + + Name: {part.filename || "Unknown"} + Mime: {part.mime} + URL: {part.url} + + ) + } + + return ( + + + {part.type} + + + + ) +} + +export function DialogInspect(props: DialogInspectProps) { + const { theme, syntax } = useTheme() + const dialog = useDialog() + const toast = useToast() + + // State for raw mode + const [showRaw, setShowRaw] = createSignal(false) + + // Set dialog size to large + dialog.setSize("xlarge") + + // Ref to scrollbox for keyboard scrolling + let scrollRef: ScrollBoxRenderable | undefined + + const handleCopy = () => { + Clipboard.copy(JSON.stringify(props.parts, null, 2)) + .then(() => toast.show({ message: "Message copied to clipboard", variant: "success" })) + .catch(() => toast.show({ message: "Failed to copy message", variant: "error" })) + } + + const handleToggleRaw = () => { + setShowRaw((prev) => !prev) + } + + // Keyboard shortcuts + useKeyboard((evt) => { + // C - Copy + if (evt.name === "c" && !evt.ctrl && !evt.meta) { + evt.preventDefault() + handleCopy() + } + + // S - Toggle raw/parsed + if (evt.name === "s" && !evt.ctrl && !evt.meta) { + evt.preventDefault() + handleToggleRaw() + } + + // Arrow keys - scroll 1 line + if (evt.name === "down") { + evt.preventDefault() + scrollRef?.scrollBy(1) + } + + if (evt.name === "up") { + evt.preventDefault() + scrollRef?.scrollBy(-1) + } + + // Page keys - scroll page + if (evt.name === "pagedown") { + evt.preventDefault() + if (scrollRef) { + scrollRef.scrollBy(scrollRef.height) + } + } + + if (evt.name === "pageup") { + evt.preventDefault() + if (scrollRef) { + scrollRef.scrollBy(-scrollRef.height) + } + } + }) + + return ( + + + + Message Inspection ({props.message.id}) + + dialog.clear()}> + [esc] + + + + { + scrollRef = r + }} + flexGrow={1} + border={["bottom", "top"]} + borderColor={theme.borderSubtle} + > + + } + > + + {props.parts + .filter((p) => !["step-start", "step-finish", "reasoning"].includes(p.type)) + .map((part) => ( + + ))} + + + + + + + ↑↓ scroll + PgUp/PgDn page + S toggle + C copy + + + + {showRaw() ? "Show Parsed" : "Show Raw"} + + + Copy + + + + + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx index edb7cb6e402..4b608f2f83a 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx @@ -1,9 +1,10 @@ import { createMemo, onMount } from "solid-js" import { useSync } from "@tui/context/sync" -import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" +import { DialogSelect, type DialogSelectOption, type DialogSelectRef } from "@tui/ui/dialog-select" import type { Part, Message, AssistantMessage, ToolPart, FilePart } from "@opencode-ai/sdk/v2" import { Locale } from "@/util/locale" import { DialogMessage } from "./dialog-message" +import { DialogInspect } from "./dialog-inspect" import { useDialog } from "../../ui/dialog" import type { PromptInfo } from "../../component/prompt/history" import { Token } from "@/util/token" @@ -15,6 +16,9 @@ import { produce } from "solid-js/store" import { Binary } from "@opencode-ai/util/binary" import { Global } from "@/global" +// Module-level variable to store the selected message when opening details +let timelineSelection: string | undefined + function formatTokenCount(tokens: number): string { return tokens.toString().padStart(7) } @@ -109,8 +113,21 @@ export function DialogTimeline(props: { const { theme } = useTheme() const sdk = useSDK() + // Capture the stored selection and clear it + const initialSelection = timelineSelection + timelineSelection = undefined + + let selectRef: DialogSelectRef | undefined + onMount(() => { dialog.setSize("large") + + // Restore selection after mount if we have one + if (initialSelection && selectRef) { + setTimeout(() => { + selectRef?.moveToValue(initialSelection) + }, 0) + } }) const options = createMemo((): DialogSelectOption[] => { @@ -251,6 +268,9 @@ export function DialogTimeline(props: { return ( { + selectRef = r + }} onMove={(option) => props.onMove(option.value)} title="Timeline" options={options()} @@ -262,6 +282,21 @@ export function DialogTimeline(props: { handleDelete(option.value) }, }, + { + keybind: { name: "insert", ctrl: false, meta: false, shift: false, leader: false }, + title: "Details", + onTrigger: (option) => { + const messageID = option.value + const message = sync.message[props.sessionID]?.find((m) => m.id === messageID) + const parts = sync.part[messageID] ?? [] + + if (message && message.role === "assistant") { + // Store the current selection before opening details + timelineSelection = messageID + dialog.push(() => ) + } + }, + }, ]} /> ) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 04a8c642256..4dc757609b4 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -56,6 +56,7 @@ import type { PromptInfo } from "../../component/prompt/history" import { DialogConfirm } from "@tui/ui/dialog-confirm" import { DialogTimeline } from "./dialog-timeline" import { DialogForkFromTimeline } from "./dialog-fork-from-timeline" +import { DialogInspect } from "./dialog-inspect" import { DialogSessionRename } from "../../component/dialog-session-rename" import { Sidebar } from "./sidebar" import { Flag } from "@/flag/flag" @@ -1051,6 +1052,11 @@ export function Session() { last={lastAssistant()?.id === message.id} message={message as AssistantMessage} parts={sync.data.part[message.id] ?? []} + next={ + messages() + .slice(index() + 1) + .find((x) => x.role === "assistant") as AssistantMessage | undefined + } /> @@ -1215,11 +1221,13 @@ function UserMessage(props: { ) } -function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; last: boolean }) { +function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; last: boolean; next?: AssistantMessage }) { const local = useLocal() const { theme } = useTheme() const sync = useSync() const messages = createMemo(() => sync.data.message[props.message.sessionID] ?? []) + const dialog = useDialog() + const [hover, setHover] = createSignal(false) const final = createMemo(() => { return props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish) @@ -1250,6 +1258,32 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las ) }} + + + + + {" "} + {(props.message.tokens.input + (props.message.tokens.cache?.read ?? 0)).toLocaleString()} token + {props.next?.tokens + ? ` (+${( + props.next.tokens.input + + (props.next.tokens.cache?.read ?? 0) - + (props.message.tokens.input + (props.message.tokens.cache?.read ?? 0)) + ).toLocaleString()})` + : ""} + + + + setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={() => dialog.replace(() => )} + backgroundColor={hover() ? theme.backgroundElement : undefined} + > + [?] + + + { export type DialogSelectRef = { filter: string filtered: DialogSelectOption[] + moveToValue: (value: T) => void } export function DialogSelect(props: DialogSelectProps) { @@ -216,6 +217,12 @@ export function DialogSelect(props: DialogSelectProps) { get filtered() { return filtered() }, + moveToValue(value: T) { + const index = flat().findIndex((opt) => isDeepEqual(opt.value, value)) + if (index >= 0) { + moveTo(index, true) + } + }, } props.ref?.(ref) diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx index 809f98d101e..43ef362c8d6 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx @@ -8,7 +8,7 @@ import { useToast } from "./toast" export function Dialog( props: ParentProps<{ - size?: "medium" | "large" + size?: "medium" | "large" | "xlarge" onClose: () => void }>, ) { @@ -26,7 +26,7 @@ export function Dialog( height={dimensions().height} alignItems="center" position="absolute" - paddingTop={dimensions().height / 4} + paddingTop={props.size === "xlarge" ? 2 : dimensions().height / 4} left={0} top={0} backgroundColor={RGBA.fromInts(0, 0, 0, 150)} @@ -36,7 +36,8 @@ export function Dialog( if (renderer.getSelection()) return e.stopPropagation() }} - width={props.size === "large" ? 90 : 60} + width={props.size === "xlarge" ? 120 : props.size === "large" ? 80 : 60} + height={props.size === "xlarge" ? dimensions().height - 4 : undefined} maxWidth={dimensions().width - 2} backgroundColor={theme.backgroundPanel} paddingTop={1} @@ -53,7 +54,7 @@ function init() { element: JSX.Element onClose?: () => void }[], - size: "medium" as "medium" | "large", + size: "medium" as "medium" | "large" | "xlarge", }) useKeyboard((evt) => { @@ -113,13 +114,26 @@ function init() { }, ]) }, + push(input: any, onClose?: () => void) { + if (store.stack.length === 0) { + focus = renderer.currentFocusedRenderable + focus?.blur() + } + setStore("stack", [ + ...store.stack, + { + element: input, + onClose, + }, + ]) + }, get stack() { return store.stack }, get size() { return store.size }, - setSize(size: "medium" | "large") { + setSize(size: "medium" | "large" | "xlarge") { setStore("size", size) }, } From f55aecbf9a3578cbd6c5815e8ef24d93acde899f Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Sun, 25 Jan 2026 22:56:45 -0500 Subject: [PATCH 3/3] Fix viewport scrolling for agent messages in session timeline - Added support for scrolling to assistant messages when selected from timeline - Modified AssistantMessage component in TUI to use single root box instead of fragment - Added anchor IDs to assistant messages in web UI components - Extended Message interface to support optional ID prop for DOM anchoring - Added scrollToAnyMessage function to handle both user and assistant message navigation - Added pendingAssistantMessage state for deferred assistant message scrolling - Updated session-turn component to pass anchor IDs to assistant message items This enables timeline to correctly scroll viewport to show selected assistant messages, matching the behavior that already existed for user messages. --- packages/app/src/pages/session.tsx | 75 ++++++++- .../src/cli/cmd/tui/routes/session/index.tsx | 156 +++++++++--------- packages/ui/src/components/message-part.tsx | 19 ++- packages/ui/src/components/session-turn.tsx | 28 ++-- 4 files changed, 174 insertions(+), 104 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 7b4f31c50df..8c14f70ee42 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -51,7 +51,7 @@ import { DialogFork } from "@/components/dialog-fork" import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" import { useNavigate, useParams } from "@solidjs/router" -import { UserMessage } from "@opencode-ai/sdk/v2" +import { UserMessage, AssistantMessage } from "@opencode-ai/sdk/v2" import type { FileDiff } from "@opencode-ai/sdk/v2/client" import { useSDK } from "@/context/sdk" import { usePrompt } from "@/context/prompt" @@ -280,6 +280,8 @@ export default function Page() { }) .finally(() => setUi("responding", false)) } + + const [pendingAssistantMessage, setPendingAssistantMessage] = createSignal(undefined) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey)) const view = createMemo(() => layout.view(sessionKey)) @@ -1558,6 +1560,38 @@ export default function Page() { updateHash(message.id) } + const scrollToAnyMessage = (messageID: string, behavior: ScrollBehavior = "smooth") => { + const allMsgs = messages() + const message = allMsgs.find((m) => m.id === messageID) + if (!message) return + + if (message.role === "user") { + scrollToMessage(message as UserMessage, behavior) + return + } + + const assistantMsg = message as AssistantMessage + const parentUserMsg = userMessages().find((m) => m.id === assistantMsg.parentID) + if (!parentUserMsg) return + + setStore("expanded", parentUserMsg.id, true) + + requestAnimationFrame(() => { + const el = document.getElementById(anchor(messageID)) + if (!el) { + requestAnimationFrame(() => { + const next = document.getElementById(anchor(messageID)) + if (!next) return + scrollToElement(next, behavior) + }) + return + } + scrollToElement(el, behavior) + }) + + updateHash(messageID) + } + const applyHash = (behavior: ScrollBehavior) => { const hash = window.location.hash.slice(1) if (!hash) { @@ -1568,14 +1602,18 @@ export default function Page() { const match = hash.match(/^message-(.+)$/) if (match) { autoScroll.pause() - const msg = visibleUserMessages().find((m) => m.id === match[1]) - if (msg) { - scrollToMessage(msg, behavior) + const msg = messages().find((m) => m.id === match[1]) + if (!msg) { + if (visibleUserMessages().find((m) => m.id === match[1])) return return } - // If we have a message hash but the message isn't loaded/rendered yet, - // don't fall back to "bottom". We'll retry once messages arrive. + if (msg.role === "assistant") { + setPendingAssistantMessage(match[1]) + return + } + + scrollToMessage(msg as UserMessage, behavior) return } @@ -1670,7 +1708,10 @@ export default function Page() { const hash = window.location.hash.slice(1) const match = hash.match(/^message-(.+)$/) if (!match) return undefined - return match[1] + const hashId = match[1] + const msg = messages().find((m) => m.id === hashId) + if (msg && msg.role === "assistant") return undefined + return hashId })() if (!targetId) return if (store.messageId === targetId) return @@ -1682,6 +1723,26 @@ export default function Page() { requestAnimationFrame(() => scrollToMessage(msg, "auto")) }) + // Handle pending assistant message navigation + createEffect(() => { + const sessionID = params.id + const ready = messagesReady() + if (!sessionID || !ready) return + + // dependencies + messages().length + store.turnStart + + const targetId = pendingAssistantMessage() + if (!targetId) return + if (store.messageId === targetId) return + + const msg = messages().find((m) => m.id === targetId) + if (!msg) return + if (pendingAssistantMessage() === targetId) setPendingAssistantMessage(undefined) + requestAnimationFrame(() => scrollToAnyMessage(targetId, "auto")) + }) + createEffect(() => { const sessionID = params.id const ready = messagesReady() diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 4dc757609b4..37b9ac5b02f 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1242,89 +1242,89 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las }) return ( - <> + - {(part, index) => { - const component = createMemo(() => PART_MAPPING[part.type as keyof typeof PART_MAPPING]) - return ( - - - - ) - }} - - - - - - {" "} - {(props.message.tokens.input + (props.message.tokens.cache?.read ?? 0)).toLocaleString()} token - {props.next?.tokens - ? ` (+${( - props.next.tokens.input + - (props.next.tokens.cache?.read ?? 0) - - (props.message.tokens.input + (props.message.tokens.cache?.read ?? 0)) - ).toLocaleString()})` - : ""} - + {(part, index) => { + const component = createMemo(() => PART_MAPPING[part.type as keyof typeof PART_MAPPING]) + return ( + + - - setHover(true)} - onMouseOut={() => setHover(false)} - onMouseUp={() => dialog.replace(() => )} - backgroundColor={hover() ? theme.backgroundElement : undefined} - > - [?] - + ) + }} + + + + + + {" "} + {(props.message.tokens.input + (props.message.tokens.cache?.read ?? 0)).toLocaleString()} token + {props.next?.tokens + ? ` (+${( + props.next.tokens.input + + (props.next.tokens.cache?.read ?? 0) - + (props.message.tokens.input + (props.message.tokens.cache?.read ?? 0)) + ).toLocaleString()})` + : ""} + + + + setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={() => dialog.replace(() => )} + backgroundColor={hover() ? theme.backgroundElement : undefined} + > + [?] + - - - {props.message.error?.data.message} + + + {props.message.error?.data.message} + + + + + + + + ▣{" "} + {" "} + {Locale.titlecase(props.message.mode)} + · {props.message.modelID} + + · {Locale.duration(duration())} + + + · interrupted + + - - - - - - - ▣{" "} - {" "} - {Locale.titlecase(props.message.mode)} - · {props.message.modelID} - - · {Locale.duration(duration())} - - - · interrupted - - - - - - + + + ) } diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 7aad01acea3..8b88a9fb97e 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -92,6 +92,7 @@ function DiagnosticsDisplay(props: { diagnostics: Diagnostic[] }): JSX.Element { export interface MessageProps { message: MessageType parts: PartType[] + id?: string } export interface MessagePartProps { @@ -277,18 +278,18 @@ export function Message(props: MessageProps) { return ( - {(userMessage) => } + {(userMessage) => } {(assistantMessage) => ( - + )} ) } -export function AssistantMessageDisplay(props: { message: AssistantMessage; parts: PartType[] }) { +export function AssistantMessageDisplay(props: { message: AssistantMessage; parts: PartType[]; id?: string }) { const emptyParts: PartType[] = [] const filteredParts = createMemo( () => @@ -298,10 +299,16 @@ export function AssistantMessageDisplay(props: { message: AssistantMessage; part emptyParts, { equals: same }, ) - return {(part) => } + return ( +
+ + {(part) => } + +
+ ) } -export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[] }) { +export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[]; id?: string }) { const dialog = useDialog() const i18n = useI18n() const [copied, setCopied] = createSignal(false) @@ -370,7 +377,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp } return ( -
+
0}>
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 8b20a73b427..8e57a899cc2 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -92,6 +92,7 @@ function AssistantMessageItem(props: { responsePartId: string | undefined hideResponsePart: boolean hideReasoning: boolean + anchorId?: string }) { const data = useData() const emptyParts: PartType[] = [] @@ -121,7 +122,7 @@ function AssistantMessageItem(props: { return parts.filter((part) => part?.id !== responsePartId) }) - return + return } export function SessionTurn( @@ -612,18 +613,19 @@ export function SessionTurn(
{/* Response */} - 0}> -
- - {(assistantMessage) => ( - - )} - + 0}> +
+ + {(assistantMessage) => ( + + )} + {error()?.data?.message as string}