Skip to content

Commit 78c72ba

Browse files
Apply PR #26387: tui: optimistically render submitted prompts
2 parents 02b1d77 + 1462f9c commit 78c72ba

4 files changed

Lines changed: 248 additions & 29 deletions

File tree

packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1100,25 +1100,27 @@ export function Prompt(props: PromptProps) {
11001100
})
11011101
} else {
11021102
move.startSubmit()
1103+
const parts = [
1104+
...editorParts,
1105+
{
1106+
id: PartID.ascending(),
1107+
type: "text" as const,
1108+
text: inputText,
1109+
},
1110+
...nonTextParts.map(assign),
1111+
]
1112+
const request = {
1113+
sessionID,
1114+
messageID,
1115+
agent: agent.name,
1116+
model: selectedModel,
1117+
variant,
1118+
parts,
1119+
}
1120+
sync.session.addOptimisticPrompt(request)
11031121
sdk.client.session
1104-
.prompt({
1105-
sessionID,
1106-
...selectedModel,
1107-
messageID,
1108-
agent: agent.name,
1109-
model: selectedModel,
1110-
variant,
1111-
parts: [
1112-
...editorParts,
1113-
{
1114-
id: PartID.ascending(),
1115-
type: "text",
1116-
text: inputText,
1117-
},
1118-
...nonTextParts.map(assign),
1119-
],
1120-
})
1121-
.catch(() => {})
1122+
.prompt(request)
1123+
.catch(() => sync.session.removeOptimisticPrompt(request.sessionID, request.messageID))
11221124
if (editorParts.length > 0) editor.markSelectionSent()
11231125
}
11241126
history.append({
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { AgentPartInput, FilePartInput, Message, Part, SubtaskPartInput, TextPartInput } from "@opencode-ai/sdk/v2"
2+
import { Binary } from "@opencode-ai/core/util/binary"
3+
4+
export type OptimisticPromptPart = (TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput) & { id: string }
5+
6+
export function optimisticParts(input: { sessionID: string; messageID: string; parts: OptimisticPromptPart[] }) {
7+
return input.parts.map((part): Part => {
8+
const withIDs = {
9+
...part,
10+
sessionID: input.sessionID,
11+
messageID: input.messageID,
12+
}
13+
if (withIDs.type === "file") return { ...withIDs, url: "" }
14+
return withIDs
15+
})
16+
}
17+
18+
export function mergeFetchedMessages(input: {
19+
currentMessages: Message[]
20+
currentParts: Record<string, Part[] | undefined>
21+
fetched: { info: Message; parts: Part[] }[]
22+
optimisticMessages: ReadonlySet<string>
23+
}) {
24+
const fetchedIDs = new Set(input.fetched.map((message) => message.info.id))
25+
const messages = input.fetched.map((message) => message.info)
26+
const parts = new Map<string, Part[]>()
27+
const resolved = new Set<string>()
28+
29+
for (const message of input.currentMessages) {
30+
if (input.optimisticMessages.has(message.id) && !fetchedIDs.has(message.id)) {
31+
Binary.insert(messages, message, (item) => item.id)
32+
}
33+
}
34+
35+
for (const message of input.fetched) {
36+
if (message.parts.length > 0) {
37+
resolved.add(message.info.id)
38+
parts.set(message.info.id, message.parts)
39+
continue
40+
}
41+
if (input.optimisticMessages.has(message.info.id)) {
42+
const current = input.currentParts[message.info.id]
43+
if (current) parts.set(message.info.id, current)
44+
continue
45+
}
46+
parts.set(message.info.id, message.parts)
47+
}
48+
49+
return { messages, parts, resolved }
50+
}

packages/opencode/src/cli/cmd/tui/context/sync.tsx

Lines changed: 86 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import * as Log from "@opencode-ai/core/util/log"
3333
import { emptyConsoleState, type ConsoleState } from "@opencode-ai/core/v1/config/console-state"
3434
import path from "path"
3535
import { aggregateFailures } from "./aggregate-failures"
36+
import { mergeFetchedMessages, optimisticParts, type OptimisticPromptPart } from "./sync-optimistic"
3637

3738
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
3839
name: "Sync",
@@ -122,6 +123,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
122123
const touchPart = (sessionID: string, partID: string) => {
123124
hydratingSessions.get(sessionID)?.parts.add(partID)
124125
}
126+
const optimisticMessages = new Set<string>()
125127

126128
function sessionListQuery(): { scope?: "project"; path?: string } {
127129
if (!kv.get("session_directory_filter_enabled", true)) return { scope: "project" }
@@ -235,6 +237,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
235237
break
236238

237239
case "session.deleted": {
240+
for (const message of store.message[event.properties.info.id] ?? []) optimisticMessages.delete(message.id)
238241
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
239242
if (result.found) {
240243
setStore(
@@ -324,6 +327,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
324327
}
325328
case "message.removed": {
326329
touchMessage(event.properties.sessionID, event.properties.messageID)
330+
optimisticMessages.delete(event.properties.messageID)
327331
const messages = store.message[event.properties.sessionID]
328332
const result = Binary.search(messages, event.properties.messageID, (m) => m.id)
329333
if (result.found) {
@@ -339,6 +343,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
339343
}
340344
case "message.part.updated": {
341345
touchPart(event.properties.part.sessionID, event.properties.part.id)
346+
optimisticMessages.delete(event.properties.part.messageID)
342347
const parts = store.part[event.properties.part.messageID]
343348
if (!parts) {
344349
setStore("part", event.properties.part.messageID, [event.properties.part])
@@ -555,6 +560,66 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
555560
if (last.role === "user") return "working"
556561
return last.time.completed ? "idle" : "working"
557562
},
563+
addOptimisticPrompt(input: {
564+
sessionID: string
565+
messageID: string
566+
agent: string
567+
model: { providerID: string; modelID: string }
568+
variant?: string
569+
parts: OptimisticPromptPart[]
570+
}) {
571+
optimisticMessages.add(input.messageID)
572+
const messages = store.message[input.sessionID]
573+
const match = messages ? Binary.search(messages, input.messageID, (m) => m.id) : undefined
574+
const info: Message = {
575+
id: input.messageID,
576+
sessionID: input.sessionID,
577+
role: "user",
578+
time: { created: Date.now() },
579+
agent: input.agent,
580+
model: {
581+
providerID: input.model.providerID,
582+
modelID: input.model.modelID,
583+
...(input.variant ? { variant: input.variant } : {}),
584+
},
585+
}
586+
batch(() => {
587+
if (!messages) {
588+
setStore("message", input.sessionID, [info])
589+
} else if (!match?.found) {
590+
setStore(
591+
"message",
592+
input.sessionID,
593+
produce((draft) => {
594+
Binary.insert(draft, info, (message) => message.id)
595+
}),
596+
)
597+
}
598+
setStore("part", input.messageID, reconcile(optimisticParts(input)))
599+
})
600+
},
601+
removeOptimisticPrompt(sessionID: string, messageID: string) {
602+
if (!optimisticMessages.delete(messageID)) return
603+
const messages = store.message[sessionID]
604+
const match = messages ? Binary.search(messages, messageID, (m) => m.id) : undefined
605+
batch(() => {
606+
if (match?.found) {
607+
setStore(
608+
"message",
609+
sessionID,
610+
produce((draft) => {
611+
draft.splice(match.index, 1)
612+
}),
613+
)
614+
}
615+
setStore(
616+
"part",
617+
produce((draft) => {
618+
delete draft[messageID]
619+
}),
620+
)
621+
})
622+
},
558623
async sync(sessionID: string) {
559624
if (fullSyncedSessions.has(sessionID)) return
560625
const syncing = syncingSessions.get(sessionID)
@@ -571,13 +636,19 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
571636
setStore(
572637
produce((draft) => {
573638
const match = Binary.search(draft.session, sessionID, (s) => s.id)
639+
const merged = mergeFetchedMessages({
640+
currentMessages: draft.message[sessionID] ?? [],
641+
currentParts: draft.part,
642+
fetched: messages.data ?? [],
643+
optimisticMessages,
644+
})
574645
if (match.found) draft.session[match.index] = session.data!
575646
if (!match.found) draft.session.splice(match.index, 0, session.data!)
576647
draft.todo[sessionID] = todo.data ?? []
577648
const currentMessages = draft.message[sessionID] ?? []
578-
const infos = (messages.data ?? []).flatMap((message) => {
579-
if (!tracker.messages.has(message.info.id)) return [message.info]
580-
const current = currentMessages.find((item) => item.id === message.info.id)
649+
const infos = merged.messages.flatMap((message) => {
650+
if (!tracker.messages.has(message.id)) return [message]
651+
const current = currentMessages.find((item) => item.id === message.id)
581652
return current ? [current] : []
582653
})
583654
infos.push(
@@ -588,13 +659,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
588659
const removed = infos.slice(0, -100)
589660
const visible = infos.slice(-100)
590661
const visibleIDs = new Set(visible.map((message) => message.id))
591-
for (const message of messages.data ?? []) {
592-
if (!visibleIDs.has(message.info.id)) {
593-
delete draft.part[message.info.id]
594-
continue
595-
}
596-
const currentParts = draft.part[message.info.id] ?? []
597-
const parts = message.parts.flatMap((part) => {
662+
for (const messageID of merged.resolved) {
663+
optimisticMessages.delete(messageID)
664+
}
665+
for (const message of visible) {
666+
const fetchedParts = merged.parts.get(message.id)
667+
const currentParts = draft.part[message.id] ?? []
668+
const parts = (fetchedParts ?? currentParts).flatMap((part) => {
598669
const current = currentParts.find((item) => item.id === part.id)
599670
if (tracker.parts.has(part.id)) return current ? [current] : []
600671
if (
@@ -613,7 +684,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
613684
(part) => tracker.parts.has(part.id) && !parts.some((item) => item.id === part.id),
614685
),
615686
)
616-
draft.part[message.info.id] = parts
687+
draft.part[message.id] = parts
688+
}
689+
for (const message of merged.messages) {
690+
if (visibleIDs.has(message.id)) continue
691+
delete draft.part[message.id]
617692
}
618693
for (const message of removed) delete draft.part[message.id]
619694
draft.message[sessionID] = visible
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { describe, expect, test } from "bun:test"
2+
import type { Message, Part } from "@opencode-ai/sdk/v2"
3+
import { mergeFetchedMessages, optimisticParts } from "@/cli/cmd/tui/context/sync-optimistic"
4+
5+
function user(id: string): Message {
6+
return {
7+
id,
8+
sessionID: "ses_test",
9+
role: "user",
10+
time: { created: 1 },
11+
agent: "build",
12+
model: { providerID: "test", modelID: "model" },
13+
}
14+
}
15+
16+
function text(messageID: string, text: string): Part {
17+
return {
18+
id: `part_${messageID}`,
19+
sessionID: "ses_test",
20+
messageID,
21+
type: "text",
22+
text,
23+
}
24+
}
25+
26+
describe("TUI optimistic prompt sync", () => {
27+
test("keeps an optimistic message while session sync has not fetched it yet", () => {
28+
const merged = mergeFetchedMessages({
29+
currentMessages: [user("msg_2")],
30+
currentParts: { msg_2: [text("msg_2", "optimistic")] },
31+
fetched: [{ info: user("msg_1"), parts: [text("msg_1", "persisted")] }],
32+
optimisticMessages: new Set(["msg_2"]),
33+
})
34+
35+
expect(merged.messages.map((message) => message.id)).toEqual(["msg_1", "msg_2"])
36+
expect(merged.parts.get("msg_1")?.map((part) => (part.type === "text" ? part.text : ""))).toEqual(["persisted"])
37+
expect(merged.resolved.has("msg_2")).toBe(false)
38+
})
39+
40+
test("preserves optimistic parts when sync fetches the message before its parts", () => {
41+
const merged = mergeFetchedMessages({
42+
currentMessages: [user("msg_1")],
43+
currentParts: { msg_1: [text("msg_1", "optimistic")] },
44+
fetched: [{ info: user("msg_1"), parts: [] }],
45+
optimisticMessages: new Set(["msg_1"]),
46+
})
47+
48+
expect(merged.messages.map((message) => message.id)).toEqual(["msg_1"])
49+
expect(merged.parts.get("msg_1")?.map((part) => (part.type === "text" ? part.text : ""))).toEqual(["optimistic"])
50+
expect(merged.resolved.has("msg_1")).toBe(false)
51+
})
52+
53+
test("replaces optimistic parts once real fetched parts arrive", () => {
54+
const merged = mergeFetchedMessages({
55+
currentMessages: [user("msg_1")],
56+
currentParts: { msg_1: [text("msg_1", "optimistic")] },
57+
fetched: [{ info: user("msg_1"), parts: [text("msg_1", "persisted")] }],
58+
optimisticMessages: new Set(["msg_1"]),
59+
})
60+
61+
expect(merged.parts.get("msg_1")?.map((part) => (part.type === "text" ? part.text : ""))).toEqual(["persisted"])
62+
expect(merged.resolved.has("msg_1")).toBe(true)
63+
})
64+
65+
test("strips file URLs from optimistic render parts", () => {
66+
const parts = optimisticParts({
67+
sessionID: "ses_test",
68+
messageID: "msg_1",
69+
parts: [
70+
{
71+
id: "part_file",
72+
type: "file",
73+
mime: "image/png",
74+
filename: "image.png",
75+
url: "data:image/png;base64,large",
76+
},
77+
],
78+
})
79+
80+
expect(parts).toEqual([
81+
{
82+
id: "part_file",
83+
sessionID: "ses_test",
84+
messageID: "msg_1",
85+
type: "file",
86+
mime: "image/png",
87+
filename: "image.png",
88+
url: "",
89+
},
90+
])
91+
})
92+
})

0 commit comments

Comments
 (0)