From f5c770d65e71338aeef703eefb77786cfa76ba52 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 8 May 2026 13:53:17 -0400 Subject: [PATCH] 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 64b7181e63..a90b7bca33 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -1178,6 +1178,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, @@ -1186,17 +1203,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 9f8a384f77..0049e6326d 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, @@ -34,6 +38,8 @@ import path from "path" import { useKV } from "./kv" import { aggregateFailures } from "./aggregate-failures" +type OptimisticPromptPart = (TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput) & { id: string } + export const { use: useSync, provider: SyncProvider } = createSimpleContext({ name: "Sync", init: () => { @@ -518,6 +524,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([