Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ function init() {
if (suspended()) return
if (dialog.stack.length > 0) return
for (const option of options()) {
if (option.keybind && keybind.match(option.keybind, evt)) {
if (option.keybind && !option.disabled && keybind.match(option.keybind, evt)) {
evt.preventDefault()
option.onSelect?.(dialog)
return
Expand Down
126 changes: 126 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export type PromptProps = {
export type PromptRef = {
focused: boolean
current: PromptInfo
editingQueuedMessageID: string | undefined
set(prompt: PromptInfo): void
reset(): void
blur(): void
Expand Down Expand Up @@ -73,6 +74,16 @@ export function Prompt(props: PromptProps) {
const { theme, syntax } = useTheme()
const kv = useKV()

const queuedMessages = createMemo(() => {
if (!props.sessionID) return []
const messages = sync.data.message[props.sessionID] ?? []
if (status().type !== "busy") return []
const sorted = [...messages].sort((a, b) => a.id.localeCompare(b.id))
const pending = sorted.findLast((m) => m.role === "assistant" && m.time.completed === undefined)
if (!pending) return []
return sorted.filter((m) => m.role === "user" && m.id > pending.id)
})

function promptModelWarning() {
toast.show({
variant: "warning",
Expand Down Expand Up @@ -118,6 +129,7 @@ export function Prompt(props: PromptProps) {
extmarkToPartIndex: Map<number, number>
interrupt: number
placeholder: number
editingQueuedMessageID: string | undefined
}>({
placeholder: Math.floor(Math.random() * PLACEHOLDERS.length),
prompt: {
Expand All @@ -127,6 +139,7 @@ export function Prompt(props: PromptProps) {
mode: "normal",
extmarkToPartIndex: new Map(),
interrupt: 0,
editingQueuedMessageID: undefined,
})

// Initialize agent/model/variant from last user message when session changes
Expand Down Expand Up @@ -160,6 +173,7 @@ export function Prompt(props: PromptProps) {
onSelect: (dialog) => {
input.extmarks.clear()
input.clear()
setStore("editingQueuedMessageID", undefined)
dialog.clear()
},
},
Expand Down Expand Up @@ -307,6 +321,79 @@ export function Prompt(props: PromptProps) {
input.cursorOffset = Bun.stringWidth(content)
},
},
{
title: "Edit queued message",
category: "Queue",
keybind: "queue_edit",
value: "queue.edit",
disabled: queuedMessages().length === 0 || store.editingQueuedMessageID !== undefined,
onSelect: async (dialog) => {
dialog.clear()
if (!props.sessionID) return
const queued = queuedMessages()
const last = queued.at(-1)
if (!last) return
const response = await sdk.client.session.getQueue({
sessionID: props.sessionID,
messageID: last.id,
})
if (!response.data) return
const textParts = response.data.parts.filter((p) => p.type === "text")
const fileParts = response.data.parts.filter((p) => p.type === "file")
const text = textParts.map((p) => p.text).join("")

let fullText = text
const parts: typeof store.prompt.parts = []

for (const file of fileParts) {
const start = fullText.length + (fullText.length > 0 ? 1 : 0)
const virtualText = file.filename ? `[File: ${file.filename}]` : `[Image ${parts.length + 1}]`
const end = start + virtualText.length
fullText += (fullText.length > 0 ? " " : "") + virtualText
parts.push({
type: "file",
mime: file.mime,
filename: file.filename,
url: file.url,
source: {
type: "file",
path: file.filename ?? "",
text: { start, end, value: virtualText },
},
})
}

input.setText(fullText)
input.cursorOffset = fullText.length
setStore("prompt", { input: fullText, parts })
restoreExtmarksFromParts(parts)
setStore("editingQueuedMessageID", last.id)
},
},
{
title: "Discard queued message",
category: "Queue",
keybind: "queue_discard",
value: "queue.discard",
disabled: queuedMessages().length === 0,
onSelect: async (dialog) => {
dialog.clear()
if (!props.sessionID) return
const messageID = store.editingQueuedMessageID ?? queuedMessages().at(-1)?.id
if (!messageID) return
await sdk.client.session.cancelQueue({
sessionID: props.sessionID,
messageID,
})
if (store.editingQueuedMessageID) {
input.clear()
input.extmarks.clear()
setStore("prompt", { input: "", parts: [] })
setStore("extmarkToPartIndex", new Map())
setStore("editingQueuedMessageID", undefined)
}
},
},
]
})

Expand All @@ -315,6 +402,24 @@ export function Prompt(props: PromptProps) {
if (props.visible === false) input?.blur()
})

// Clear editing state if the message being edited is no longer in the queue (was processed)
createEffect(() => {
if (!store.editingQueuedMessageID) return
const stillQueued = queuedMessages().some((m) => m.id === store.editingQueuedMessageID)
if (!stillQueued) {
input.clear()
input.extmarks.clear()
setStore("prompt", { input: "", parts: [] })
setStore("extmarkToPartIndex", new Map())
setStore("editingQueuedMessageID", undefined)
toast.show({
variant: "info",
message: "Queued message was processed",
duration: 3000,
})
}
})

onMount(() => {
promptPartTypeId = input.extmarks.registerType("prompt-part")
})
Expand Down Expand Up @@ -459,6 +564,9 @@ export function Prompt(props: PromptProps) {
get current() {
return store.prompt
},
get editingQueuedMessageID() {
return store.editingQueuedMessageID
},
focus() {
input.focus()
},
Expand All @@ -479,6 +587,7 @@ export function Prompt(props: PromptProps) {
parts: [],
})
setStore("extmarkToPartIndex", new Map())
setStore("editingQueuedMessageID", undefined)
},
submit() {
submit()
Expand Down Expand Up @@ -567,6 +676,12 @@ export function Prompt(props: PromptProps) {
})),
})
} else {
if (store.editingQueuedMessageID) {
await sdk.client.session.cancelQueue({
sessionID,
messageID: store.editingQueuedMessageID,
})
}
sdk.client.session.prompt({
sessionID,
...selectedModel,
Expand Down Expand Up @@ -597,6 +712,7 @@ export function Prompt(props: PromptProps) {
parts: [],
})
setStore("extmarkToPartIndex", new Map())
setStore("editingQueuedMessageID", undefined)
props.onSubmit?.()

// temporary hack to make sure the message is sent
Expand Down Expand Up @@ -804,9 +920,19 @@ export function Prompt(props: PromptProps) {
parts: [],
})
setStore("extmarkToPartIndex", new Map())
setStore("editingQueuedMessageID", undefined)
return
}
if (keybind.match("app_exit", e)) {
if (store.editingQueuedMessageID) {
input.clear()
input.extmarks.clear()
setStore("prompt", { input: "", parts: [] })
setStore("extmarkToPartIndex", new Map())
setStore("editingQueuedMessageID", undefined)
e.preventDefault()
return
}
if (store.prompt.input === "") {
await exit()
// Don't preventDefault - let textarea potentially handle the event
Expand Down
24 changes: 22 additions & 2 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1009,6 +1009,7 @@ export function Session() {
message={message as UserMessage}
parts={sync.data.part[message.id] ?? []}
pending={pending()}
editingMessageID={prompt?.editingQueuedMessageID}
/>
</Match>
<Match when={message.role === "assistant"}>
Expand Down Expand Up @@ -1076,15 +1077,18 @@ function UserMessage(props: {
onMouseUp: () => void
index: number
pending?: string
editingMessageID?: string
}) {
const ctx = use()
const local = useLocal()
const keybind = useKeybind()
const text = createMemo(() => props.parts.flatMap((x) => (x.type === "text" && !x.synthetic ? [x] : []))[0])
const files = createMemo(() => props.parts.flatMap((x) => (x.type === "file" ? [x] : [])))
const sync = useSync()
const { theme } = useTheme()
const [hover, setHover] = createSignal(false)
const queued = createMemo(() => props.pending && props.message.id > props.pending)
const editing = createMemo(() => props.editingMessageID === props.message.id)
const color = createMemo(() => (queued() ? theme.accent : local.agent.color(props.message.agent)))
const metadataVisible = createMemo(() => queued() || ctx.showTimestamps())

Expand Down Expand Up @@ -1148,8 +1152,24 @@ function UserMessage(props: {
>
<text fg={theme.textMuted}>
<span style={{ bg: theme.accent, fg: theme.backgroundPanel, bold: true }}> QUEUED </span>
</text>
</Show>
<Show
when={editing()}
fallback={
<span style={{ fg: theme.textMuted }}>
{" "}
{keybind.print("queue_edit")} edit · {keybind.print("queue_discard")} discard
</span>
}
>
<span> </span>
<span style={{ bg: theme.primary, fg: theme.backgroundPanel, bold: true }}> EDITING </span>
<span style={{ fg: theme.textMuted }}>
{" "}
enter submit · ctrl+c cancel · {keybind.print("queue_discard")} discard
</span>
</Show>
</Show>
</text>
</box>
</box>
</Show>
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,8 @@ export namespace Config {
.describe("Delete word backward in input"),
history_previous: z.string().optional().default("up").describe("Previous history item"),
history_next: z.string().optional().default("down").describe("Next history item"),
queue_edit: z.string().optional().default("<leader>i").describe("Edit queued message"),
queue_discard: z.string().optional().default("<leader>d").describe("Discard queued message"),
session_child_cycle: z.string().optional().default("<leader>right").describe("Next child session"),
session_child_cycle_reverse: z.string().optional().default("<leader>left").describe("Previous child session"),
session_parent: z.string().optional().default("<leader>up").describe("Go to parent session"),
Expand Down
Loading
Loading