From 8e1d9512f12a2dd1d8e882243475e68936a8f2c5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 8 May 2026 13:53:17 -0400 Subject: [PATCH 1/3] tui: optimistically render submitted prompts --- .../cli/cmd/tui/component/prompt/index.tsx | 29 ++++--- .../opencode/src/cli/cmd/tui/context/sync.tsx | 82 +++++++++++++++++++ 2 files changed, 101 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index c6bcb89924a6..dabeb34d7bf3 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -1170,6 +1170,23 @@ export function Prompt(props: PromptProps) { })), }) } else { + const parts = [ + ...editorParts, + { + id: PartID.ascending(), + type: "text" as const, + text: inputText, + }, + ...nonTextParts.map(assign), + ] + sync.session.addOptimisticPrompt({ + sessionID, + messageID, + agent: agent.name, + model: selectedModel, + variant, + parts, + }) sdk.client.session .prompt({ sessionID, @@ -1178,17 +1195,9 @@ export function Prompt(props: PromptProps) { agent: agent.name, model: selectedModel, variant, - parts: [ - ...editorParts, - { - id: PartID.ascending(), - type: "text", - text: inputText, - }, - ...nonTextParts.map(assign), - ], + parts, }) - .catch(() => {}) + .catch(() => sync.session.removeOptimisticPrompt(sessionID, messageID)) if (editorParts.length > 0) editor.markSelectionSent() } history.append({ diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 24609dd81e4f..d6738b10ce05 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -4,6 +4,10 @@ import type { Provider, Session, Part, + TextPartInput, + FilePartInput, + AgentPartInput, + SubtaskPartInput, Config, Todo, Command, @@ -33,6 +37,8 @@ import { emptyConsoleState, type ConsoleState } from "@/config/console-state" import path from "path" import { useKV } from "./kv" +type OptimisticPromptPart = (TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput) & { id: string } + export const { use: useSync, provider: SyncProvider } = createSimpleContext({ name: "Sync", init: () => { @@ -512,6 +518,82 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ if (last.role === "user") return "working" return last.time.completed ? "idle" : "working" }, + addOptimisticPrompt(input: { + sessionID: string + messageID: string + agent: string + model: { providerID: string; modelID: string } + variant?: string + parts: OptimisticPromptPart[] + }) { + const messages = store.message[input.sessionID] + const match = messages ? Binary.search(messages, input.messageID, (m) => m.id) : undefined + const info: Message = { + id: input.messageID, + sessionID: input.sessionID, + role: "user", + time: { created: Date.now() }, + agent: input.agent, + model: { + providerID: input.model.providerID, + modelID: input.model.modelID, + ...(input.variant ? { variant: input.variant } : {}), + }, + } + const parts = input.parts.map((part): Part => { + const withIDs = { + ...part, + sessionID: input.sessionID, + messageID: input.messageID, + } + if (withIDs.type !== "text") return withIDs + return { + ...withIDs, + metadata: { + ...withIDs.metadata, + optimistic: true, + }, + } + }) + + batch(() => { + if (!messages) { + setStore("message", input.sessionID, [info]) + } else if (!match?.found) { + setStore( + "message", + input.sessionID, + produce((draft) => { + draft.splice(match?.index ?? draft.length, 0, info) + }), + ) + } + setStore("part", input.messageID, reconcile(parts)) + }) + }, + removeOptimisticPrompt(sessionID: string, messageID: string) { + if (!store.part[messageID]?.some((part) => part.type === "text" && part.metadata?.optimistic === true)) return + const messages = store.message[sessionID] + if (!messages) return + const match = Binary.search(messages, messageID, (m) => m.id) + batch(() => { + if (match.found) { + setStore( + "message", + sessionID, + produce((draft) => { + draft.splice(match.index, 1) + }), + ) + } + setStore( + "part", + produce((draft) => { + delete draft[messageID] + }), + ) + }) + }, async sync(sessionID: string) { if (fullSyncedSessions.has(sessionID)) return const [session, messages, todo, diff] = await Promise.all([ From de800a90e3d624126a34c70f452f5a6f8dcf1b36 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 8 May 2026 14:12:55 -0400 Subject: [PATCH 2/3] tui: simplify optimistic prompt reconciliation --- .../cli/cmd/tui/component/prompt/index.tsx | 17 +++----- .../opencode/src/cli/cmd/tui/context/sync.tsx | 39 ++++++++++++------- 2 files changed, 30 insertions(+), 26 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index dabeb34d7bf3..f84ad1dee8da 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -1179,25 +1179,18 @@ export function Prompt(props: PromptProps) { }, ...nonTextParts.map(assign), ] - sync.session.addOptimisticPrompt({ + const request = { sessionID, messageID, agent: agent.name, model: selectedModel, variant, parts, - }) + } + sync.session.addOptimisticPrompt(request) sdk.client.session - .prompt({ - sessionID, - ...selectedModel, - messageID, - agent: agent.name, - model: selectedModel, - variant, - parts, - }) - .catch(() => sync.session.removeOptimisticPrompt(sessionID, messageID)) + .prompt(request) + .catch(() => sync.session.removeOptimisticPrompt(request.sessionID, request.messageID)) if (editorParts.length > 0) editor.markSelectionSent() } history.append({ diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index d6738b10ce05..cc87e24353b3 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -118,6 +118,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const kv = useKV() const fullSyncedSessions = new Set() + const optimisticMessages = new Set() let syncedWorkspace = project.workspace.current() function sessionListQuery(): { scope?: "project"; path?: string } { @@ -257,6 +258,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } case "message.updated": { + optimisticMessages.delete(event.properties.info.id) const messages = store.message[event.properties.info.sessionID] if (!messages) { setStore("message", event.properties.info.sessionID, [event.properties.info]) @@ -296,6 +298,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ break } case "message.removed": { + optimisticMessages.delete(event.properties.messageID) const messages = store.message[event.properties.sessionID] const result = Binary.search(messages, event.properties.messageID, (m) => m.id) if (result.found) { @@ -526,6 +529,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ variant?: string parts: OptimisticPromptPart[] }) { + optimisticMessages.add(input.messageID) const messages = store.message[input.sessionID] const match = messages ? Binary.search(messages, input.messageID, (m) => m.id) : undefined const info: Message = { @@ -546,14 +550,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ sessionID: input.sessionID, messageID: input.messageID, } - if (withIDs.type !== "text") return withIDs - return { - ...withIDs, - metadata: { - ...withIDs.metadata, - optimistic: true, - }, + if (withIDs.type === "file") { + return { + ...withIDs, + url: "", + } } + return withIDs }) batch(() => { @@ -564,7 +567,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ "message", input.sessionID, produce((draft) => { - draft.splice(match?.index ?? draft.length, 0, info) + Binary.insert(draft, info, (message) => message.id) }), ) } @@ -572,12 +575,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }) }, removeOptimisticPrompt(sessionID: string, messageID: string) { - if (!store.part[messageID]?.some((part) => part.type === "text" && part.metadata?.optimistic === true)) return + if (!optimisticMessages.delete(messageID)) return const messages = store.message[sessionID] - if (!messages) return - const match = Binary.search(messages, messageID, (m) => m.id) + const match = messages ? Binary.search(messages, messageID, (m) => m.id) : undefined batch(() => { - if (match.found) { + if (match?.found) { setStore( "message", sessionID, @@ -605,11 +607,20 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ setStore( produce((draft) => { const match = Binary.search(draft.session, sessionID, (s) => s.id) + const fetched = messages.data! + const fetchedIDs = new Set(fetched.map((message) => message.info.id)) + const optimistic = (draft.message[sessionID] ?? []).filter( + (message) => optimisticMessages.has(message.id) && !fetchedIDs.has(message.id), + ) if (match.found) draft.session[match.index] = session.data! if (!match.found) draft.session.splice(match.index, 0, session.data!) draft.todo[sessionID] = todo.data ?? [] - draft.message[sessionID] = messages.data!.map((x) => x.info) - for (const message of messages.data!) { + draft.message[sessionID] = fetched.map((x) => x.info) + for (const message of optimistic) { + Binary.insert(draft.message[sessionID], message, (item) => item.id) + } + for (const message of fetched) { + optimisticMessages.delete(message.info.id) draft.part[message.info.id] = message.parts } draft.session_diff[sessionID] = diff.data ?? [] From ecb6457ba4bcba17b985a7b0b61690311ca679cd Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 8 May 2026 14:28:00 -0400 Subject: [PATCH 3/3] test: cover TUI optimistic prompt sync --- .../cli/cmd/tui/context/sync-optimistic.ts | 50 ++++++++++ .../opencode/src/cli/cmd/tui/context/sync.tsx | 50 ++++------ .../test/cli/tui-sync-optimistic.test.ts | 92 +++++++++++++++++++ 3 files changed, 158 insertions(+), 34 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/context/sync-optimistic.ts create mode 100644 packages/opencode/test/cli/tui-sync-optimistic.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/context/sync-optimistic.ts b/packages/opencode/src/cli/cmd/tui/context/sync-optimistic.ts new file mode 100644 index 000000000000..cede9bd3131a --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/sync-optimistic.ts @@ -0,0 +1,50 @@ +import type { AgentPartInput, FilePartInput, Message, Part, SubtaskPartInput, TextPartInput } from "@opencode-ai/sdk/v2" +import { Binary } from "@opencode-ai/core/util/binary" + +export type OptimisticPromptPart = (TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput) & { id: string } + +export function optimisticParts(input: { sessionID: string; messageID: string; parts: OptimisticPromptPart[] }) { + return input.parts.map((part): Part => { + const withIDs = { + ...part, + sessionID: input.sessionID, + messageID: input.messageID, + } + if (withIDs.type === "file") return { ...withIDs, url: "" } + return withIDs + }) +} + +export function mergeFetchedMessages(input: { + currentMessages: Message[] + currentParts: Record + fetched: { info: Message; parts: Part[] }[] + optimisticMessages: ReadonlySet +}) { + const fetchedIDs = new Set(input.fetched.map((message) => message.info.id)) + const messages = input.fetched.map((message) => message.info) + const parts = new Map() + const resolved = new Set() + + for (const message of input.currentMessages) { + if (input.optimisticMessages.has(message.id) && !fetchedIDs.has(message.id)) { + Binary.insert(messages, message, (item) => item.id) + } + } + + for (const message of input.fetched) { + if (message.parts.length > 0) { + resolved.add(message.info.id) + parts.set(message.info.id, message.parts) + continue + } + if (input.optimisticMessages.has(message.info.id)) { + const current = input.currentParts[message.info.id] + if (current) parts.set(message.info.id, current) + continue + } + parts.set(message.info.id, message.parts) + } + + return { messages, parts, resolved } +} diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index cc87e24353b3..6afd9aeb6ffc 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -4,10 +4,6 @@ import type { Provider, Session, Part, - TextPartInput, - FilePartInput, - AgentPartInput, - SubtaskPartInput, Config, Todo, Command, @@ -36,8 +32,7 @@ import * as Log from "@opencode-ai/core/util/log" import { emptyConsoleState, type ConsoleState } from "@/config/console-state" import path from "path" import { useKV } from "./kv" - -type OptimisticPromptPart = (TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput) & { id: string } +import { mergeFetchedMessages, optimisticParts, type OptimisticPromptPart } from "./sync-optimistic" export const { use: useSync, provider: SyncProvider } = createSimpleContext({ name: "Sync", @@ -226,6 +221,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ break case "session.deleted": { + for (const message of store.message[event.properties.info.id] ?? []) optimisticMessages.delete(message.id) const result = Binary.search(store.session, event.properties.info.id, (s) => s.id) if (result.found) { setStore( @@ -258,7 +254,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } case "message.updated": { - optimisticMessages.delete(event.properties.info.id) const messages = store.message[event.properties.info.sessionID] if (!messages) { setStore("message", event.properties.info.sessionID, [event.properties.info]) @@ -313,6 +308,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ break } case "message.part.updated": { + optimisticMessages.delete(event.properties.part.messageID) const parts = store.part[event.properties.part.messageID] if (!parts) { setStore("part", event.properties.part.messageID, [event.properties.part]) @@ -386,6 +382,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const workspace = project.workspace.current() if (workspace !== syncedWorkspace) { fullSyncedSessions.clear() + optimisticMessages.clear() syncedWorkspace = workspace } const projectPromise = project.sync() @@ -544,21 +541,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ ...(input.variant ? { variant: input.variant } : {}), }, } - const parts = input.parts.map((part): Part => { - const withIDs = { - ...part, - sessionID: input.sessionID, - messageID: input.messageID, - } - if (withIDs.type === "file") { - return { - ...withIDs, - url: "", - } - } - return withIDs - }) - batch(() => { if (!messages) { setStore("message", input.sessionID, [info]) @@ -571,7 +553,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }), ) } - setStore("part", input.messageID, reconcile(parts)) + setStore("part", input.messageID, reconcile(optimisticParts(input))) }) }, removeOptimisticPrompt(sessionID: string, messageID: string) { @@ -607,21 +589,21 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ setStore( produce((draft) => { const match = Binary.search(draft.session, sessionID, (s) => s.id) - const fetched = messages.data! - const fetchedIDs = new Set(fetched.map((message) => message.info.id)) - const optimistic = (draft.message[sessionID] ?? []).filter( - (message) => optimisticMessages.has(message.id) && !fetchedIDs.has(message.id), - ) + const merged = mergeFetchedMessages({ + currentMessages: draft.message[sessionID] ?? [], + currentParts: draft.part, + fetched: messages.data!, + optimisticMessages, + }) if (match.found) draft.session[match.index] = session.data! if (!match.found) draft.session.splice(match.index, 0, session.data!) draft.todo[sessionID] = todo.data ?? [] - draft.message[sessionID] = fetched.map((x) => x.info) - for (const message of optimistic) { - Binary.insert(draft.message[sessionID], message, (item) => item.id) + draft.message[sessionID] = merged.messages + for (const messageID of merged.resolved) { + optimisticMessages.delete(messageID) } - for (const message of fetched) { - optimisticMessages.delete(message.info.id) - draft.part[message.info.id] = message.parts + for (const [messageID, parts] of merged.parts) { + draft.part[messageID] = parts } draft.session_diff[sessionID] = diff.data ?? [] }), diff --git a/packages/opencode/test/cli/tui-sync-optimistic.test.ts b/packages/opencode/test/cli/tui-sync-optimistic.test.ts new file mode 100644 index 000000000000..576d9ae7a511 --- /dev/null +++ b/packages/opencode/test/cli/tui-sync-optimistic.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from "bun:test" +import type { Message, Part } from "@opencode-ai/sdk/v2" +import { mergeFetchedMessages, optimisticParts } from "@/cli/cmd/tui/context/sync-optimistic" + +function user(id: string): Message { + return { + id, + sessionID: "ses_test", + role: "user", + time: { created: 1 }, + agent: "build", + model: { providerID: "test", modelID: "model" }, + } +} + +function text(messageID: string, text: string): Part { + return { + id: `part_${messageID}`, + sessionID: "ses_test", + messageID, + type: "text", + text, + } +} + +describe("TUI optimistic prompt sync", () => { + test("keeps an optimistic message while session sync has not fetched it yet", () => { + const merged = mergeFetchedMessages({ + currentMessages: [user("msg_2")], + currentParts: { msg_2: [text("msg_2", "optimistic")] }, + fetched: [{ info: user("msg_1"), parts: [text("msg_1", "persisted")] }], + optimisticMessages: new Set(["msg_2"]), + }) + + expect(merged.messages.map((message) => message.id)).toEqual(["msg_1", "msg_2"]) + expect(merged.parts.get("msg_1")?.map((part) => (part.type === "text" ? part.text : ""))).toEqual(["persisted"]) + expect(merged.resolved.has("msg_2")).toBe(false) + }) + + test("preserves optimistic parts when sync fetches the message before its parts", () => { + const merged = mergeFetchedMessages({ + currentMessages: [user("msg_1")], + currentParts: { msg_1: [text("msg_1", "optimistic")] }, + fetched: [{ info: user("msg_1"), parts: [] }], + optimisticMessages: new Set(["msg_1"]), + }) + + expect(merged.messages.map((message) => message.id)).toEqual(["msg_1"]) + expect(merged.parts.get("msg_1")?.map((part) => (part.type === "text" ? part.text : ""))).toEqual(["optimistic"]) + expect(merged.resolved.has("msg_1")).toBe(false) + }) + + test("replaces optimistic parts once real fetched parts arrive", () => { + const merged = mergeFetchedMessages({ + currentMessages: [user("msg_1")], + currentParts: { msg_1: [text("msg_1", "optimistic")] }, + fetched: [{ info: user("msg_1"), parts: [text("msg_1", "persisted")] }], + optimisticMessages: new Set(["msg_1"]), + }) + + expect(merged.parts.get("msg_1")?.map((part) => (part.type === "text" ? part.text : ""))).toEqual(["persisted"]) + expect(merged.resolved.has("msg_1")).toBe(true) + }) + + test("strips file URLs from optimistic render parts", () => { + const parts = optimisticParts({ + sessionID: "ses_test", + messageID: "msg_1", + parts: [ + { + id: "part_file", + type: "file", + mime: "image/png", + filename: "image.png", + url: "data:image/png;base64,large", + }, + ], + }) + + expect(parts).toEqual([ + { + id: "part_file", + sessionID: "ses_test", + messageID: "msg_1", + type: "file", + mime: "image/png", + filename: "image.png", + url: "", + }, + ]) + }) +})