From 4460e4a89c5aeae13dd41af6ee98110d34e3f896 Mon Sep 17 00:00:00 2001 From: ops Date: Thu, 15 Jan 2026 18:04:32 +0100 Subject: [PATCH 1/6] 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 404fbae101d..07670ef047a 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 = { @@ -309,6 +311,8 @@ export function DialogSelect(props: DialogSelectProps) { active={active()} current={current()} gutter={option.gutter} + titleColor={option.titleColor} + footerColor={option.footerColor} /> ) @@ -344,6 +348,8 @@ function Option(props: { current?: boolean footer?: JSX.Element | string gutter?: JSX.Element + titleColor?: RGBA + footerColor?: RGBA onMouseOver?: () => void }) { const { theme } = useTheme() @@ -363,20 +369,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 acccbd1c09f..601a1cea68c 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -43,6 +43,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") { @@ -161,7 +190,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) @@ -235,7 +277,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 ( @@ -246,7 +293,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 b81a21a57be..073b7c8bf03 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 55c9c452473..b51d22bcf7c 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -252,7 +252,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 38a52b325ad..c22b2b30b63 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 1fbb8c221129a8ad8ccf005e55f21a3192b8be5d Mon Sep 17 00:00:00 2001 From: Alexey Zaytsev Date: Sun, 25 Jan 2026 01:31:15 -0500 Subject: [PATCH 2/6] 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 1294ab849e9..9117d14853e 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 { LANGUAGE_EXTENSIONS } from "@/lsp/language" @@ -1071,6 +1072,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 + } /> @@ -1235,11 +1241,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) @@ -1270,6 +1278,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) { @@ -215,6 +216,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 6094ca636474333a7cdf07afd60acc3365a27f2c Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Sun, 25 Jan 2026 22:56:45 -0500 Subject: [PATCH 3/6] 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 | 74 ++++++++- .../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, 173 insertions(+), 104 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 38717317df5..dc0ef0a0a47 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -37,7 +37,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" @@ -185,6 +185,7 @@ export default function Page() { const comments = useComments() const permission = usePermission() const [pendingMessage, setPendingMessage] = createSignal(undefined) + 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)) @@ -1222,6 +1223,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) { @@ -1231,14 +1264,18 @@ export default function Page() { const match = hash.match(/^message-(.+)$/) if (match) { - 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 (msg.role === "assistant") { + setPendingAssistantMessage(match[1]) 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. + scrollToMessage(msg as UserMessage, behavior) return } @@ -1311,7 +1348,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 @@ -1322,6 +1362,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 9117d14853e..f08c83180b0 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1262,89 +1262,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 194d5148afa..230dbea7248 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 1789cbfdc47..469169e1f0a 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( @@ -605,18 +606,19 @@ export function SessionTurn(
{/* Response */} - 0}> -
- - {(assistantMessage) => ( - - )} - + 0}> +
+ + {(assistantMessage) => ( + + )} + {error()?.data?.message as string} From 283f5ab293a114a38d92f7af1ea8d5d868085cf8 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Mon, 26 Jan 2026 03:35:52 -0500 Subject: [PATCH 4/6] Add n and p keybinds for user message navigation in timeline - Added useKeyboard hook to intercept n and p keys in DialogTimeline - Implemented navigation that skips assistant messages and only navigates user messages - Pressing n moves to next user message in timeline - Pressing p moves to previous user message in timeline - Navigation respects message boundaries and works correctly with filters - Tracks selected message ID to maintain state between navigation methods --- .../tui/routes/session/dialog-timeline.tsx | 51 +++++++++++++++++-- 1 file changed, 48 insertions(+), 3 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 4b608f2f83a..f08082d71d2 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,4 +1,4 @@ -import { createMemo, onMount } from "solid-js" +import { createMemo, onMount, createSignal } from "solid-js" import { useSync } from "@tui/context/sync" import { DialogSelect, type DialogSelectOption, type DialogSelectRef } from "@tui/ui/dialog-select" import type { Part, Message, AssistantMessage, ToolPart, FilePart } from "@opencode-ai/sdk/v2" @@ -15,6 +15,7 @@ import path from "path" import { produce } from "solid-js/store" import { Binary } from "@opencode-ai/util/binary" import { Global } from "@/global" +import { useKeyboard } from "@opentui/solid" // Module-level variable to store the selected message when opening details let timelineSelection: string | undefined @@ -118,6 +119,7 @@ export function DialogTimeline(props: { timelineSelection = undefined let selectRef: DialogSelectRef | undefined + const [selectedMessageID, setSelectedMessageID] = createSignal(undefined) onMount(() => { dialog.setSize("large") @@ -130,6 +132,46 @@ export function DialogTimeline(props: { } }) + useKeyboard((evt) => { + // Only handle 'n' and 'p' without any modifiers + if (evt.ctrl || evt.meta || evt.shift) return + + const opts = options() + if (opts.length === 0) return + + const currentIndex = opts.findIndex(opt => opt.value === selectedMessageID()) + + if (evt.name === "n") { + evt.preventDefault() + evt.stopPropagation() + // Find next user message + for (let i = currentIndex + 1; i < opts.length; i++) { + const msgID = opts[i].value + const msg = sync.message[props.sessionID]?.find(m => m.id === msgID) + if (msg && msg.role === "user") { + setSelectedMessageID(msgID) + props.onMove(msgID) + break + } + } + } + + if (evt.name === "p") { + evt.preventDefault() + evt.stopPropagation() + // Find previous user message + for (let i = currentIndex - 1; i >= 0; i--) { + const msgID = opts[i].value + const msg = sync.message[props.sessionID]?.find(m => m.id === msgID) + if (msg && msg.role === "user") { + setSelectedMessageID(msgID) + props.onMove(msgID) + break + } + } + } + }) + const options = createMemo((): DialogSelectOption[] => { const messages = sync.message[props.sessionID] ?? [] const result = [] as DialogSelectOption[] @@ -271,7 +313,10 @@ export function DialogTimeline(props: { ref={(r) => { selectRef = r }} - onMove={(option) => props.onMove(option.value)} + onMove={(option) => { + setSelectedMessageID(option.value) + props.onMove(option.value) + }} title="Timeline" options={options()} keybind={[ @@ -289,7 +334,7 @@ export function DialogTimeline(props: { 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 From 8523d9cf791e770986f3264ddcc401c023436cd9 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Mon, 26 Jan 2026 04:20:25 -0500 Subject: [PATCH 5/6] Add n and p keybinds for user message navigation in timeline Fixed navigation to properly move selection highlight in timeline dialog by using selectRef.moveToValue() instead of calling onMove directly. Keys now appear in help text and skip assistant messages. --- .../tui/routes/session/dialog-timeline.tsx | 81 ++++++++----------- 1 file changed, 33 insertions(+), 48 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 f08082d71d2..98cbb7280ec 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,4 +1,4 @@ -import { createMemo, onMount, createSignal } from "solid-js" +import { createMemo, onMount } from "solid-js" import { useSync } from "@tui/context/sync" import { DialogSelect, type DialogSelectOption, type DialogSelectRef } from "@tui/ui/dialog-select" import type { Part, Message, AssistantMessage, ToolPart, FilePart } from "@opencode-ai/sdk/v2" @@ -15,7 +15,6 @@ import path from "path" import { produce } from "solid-js/store" import { Binary } from "@opencode-ai/util/binary" import { Global } from "@/global" -import { useKeyboard } from "@opentui/solid" // Module-level variable to store the selected message when opening details let timelineSelection: string | undefined @@ -119,7 +118,6 @@ export function DialogTimeline(props: { timelineSelection = undefined let selectRef: DialogSelectRef | undefined - const [selectedMessageID, setSelectedMessageID] = createSignal(undefined) onMount(() => { dialog.setSize("large") @@ -132,46 +130,6 @@ export function DialogTimeline(props: { } }) - useKeyboard((evt) => { - // Only handle 'n' and 'p' without any modifiers - if (evt.ctrl || evt.meta || evt.shift) return - - const opts = options() - if (opts.length === 0) return - - const currentIndex = opts.findIndex(opt => opt.value === selectedMessageID()) - - if (evt.name === "n") { - evt.preventDefault() - evt.stopPropagation() - // Find next user message - for (let i = currentIndex + 1; i < opts.length; i++) { - const msgID = opts[i].value - const msg = sync.message[props.sessionID]?.find(m => m.id === msgID) - if (msg && msg.role === "user") { - setSelectedMessageID(msgID) - props.onMove(msgID) - break - } - } - } - - if (evt.name === "p") { - evt.preventDefault() - evt.stopPropagation() - // Find previous user message - for (let i = currentIndex - 1; i >= 0; i--) { - const msgID = opts[i].value - const msg = sync.message[props.sessionID]?.find(m => m.id === msgID) - if (msg && msg.role === "user") { - setSelectedMessageID(msgID) - props.onMove(msgID) - break - } - } - } - }) - const options = createMemo((): DialogSelectOption[] => { const messages = sync.message[props.sessionID] ?? [] const result = [] as DialogSelectOption[] @@ -313,13 +271,40 @@ export function DialogTimeline(props: { ref={(r) => { selectRef = r }} - onMove={(option) => { - setSelectedMessageID(option.value) - props.onMove(option.value) - }} + onMove={(option) => props.onMove(option.value)} title="Timeline" options={options()} keybind={[ + { + keybind: { name: "n", ctrl: false, meta: false, shift: false, leader: false }, + title: "Next user", + onTrigger: (option) => { + const currentIdx = options().findIndex(opt => opt.value === option.value) + for (let i = currentIdx + 1; i < options().length; i++) { + const msgID = options()[i].value + const msg = sync.message[props.sessionID]?.find(m => m.id === msgID) + if (msg && msg.role === "user") { + selectRef?.moveToValue(msgID) + break + } + } + }, + }, + { + keybind: { name: "p", ctrl: false, meta: false, shift: false, leader: false }, + title: "Previous user", + onTrigger: (option) => { + const currentIdx = options().findIndex(opt => opt.value === option.value) + for (let i = currentIdx - 1; i >= 0; i--) { + const msgID = options()[i].value + const msg = sync.message[props.sessionID]?.find(m => m.id === msgID) + if (msg && msg.role === "user") { + selectRef?.moveToValue(msgID) + break + } + } + }, + }, { keybind: { name: "delete", ctrl: false, meta: false, shift: false, leader: false }, title: "Delete", @@ -334,7 +319,7 @@ export function DialogTimeline(props: { 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 From 760aee0b818b8dda44b4132ab793e97be4c7fb05 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Mon, 26 Jan 2026 04:34:10 -0500 Subject: [PATCH 6/6] tidy: whitespace --- .../opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 98cbb7280ec..07d4d80a17b 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 @@ -319,7 +319,7 @@ export function DialogTimeline(props: { 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