feat(prompt): undo queued prompts back into the input (#51124)

Co-authored-by: vimtor <36263538+vimtor@users.noreply.github.com>
Co-authored-by: vimtor <vn4varro@gmail.com>
This commit is contained in:
opencode-agent[bot]
2026-09-25 14:36:29 +02:00
committed by GitHub
parent aae42e2e75
commit beeb14e910
22 changed files with 606 additions and 18 deletions
@@ -1,5 +1,5 @@
import { expect, test, type Page } from "@playwright/test"
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise"
import type { OpenCodeEvent, SessionInboxInfo, SessionMessageInfo } from "@opencode/client/promise"
import { base64Encode } from "@opencode/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
@@ -14,7 +14,12 @@ type InboxRow = {
sessionID: string
time: { created: number }
type: "user"
payload: { text: string; metadata?: Record<string, unknown> }
payload: {
text: string
metadata?: Record<string, unknown>
files?: Extract<SessionInboxInfo, { type: "user" }>["payload"]["files"]
agents?: Extract<SessionInboxInfo, { type: "user" }>["payload"]["agents"]
}
delivery: "steer" | "queue"
}
@@ -29,7 +34,7 @@ function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
}))
const events: OpenCodeEvent[] = []
const prompts: Record<string, unknown>[] = []
const changes: { inboxID: string; action: "cancel" | "steer" }[] = []
const changes: { inboxID: string; action: "cancel" | "steer" | "queue" }[] = []
const log: string[] = []
let sequence = 0
const emit = <Type extends OpenCodeEvent["type"]>(
@@ -234,6 +239,108 @@ test("editing restores the existing draft and replaces only the original queue p
expect(mock.log[0]).toBe("prompt:queue")
})
test("Undo cancels only the selected queued prompt and focuses the restored input", async ({ page }) => {
const mock = createQueueMock(["first queued prompt", "second queued prompt", "third queued prompt"])
const view = await openSession(page, mock)
await expect(view.rows).toHaveCount(3)
const row = view.rows.filter({ hasText: "second queued prompt" })
const actions = row.locator('[data-slot="session-queue-actions"] button')
await expect(actions).toHaveCount(3)
expect(
await actions.evaluateAll((buttons) =>
buttons.map((button) => button.getAttribute("aria-label") ?? button.textContent?.trim()),
),
).toEqual(["Steer", "Undo", "Remove"])
const undo = row.getByRole("button", { name: "Undo" })
await expect(undo).toHaveText("")
await expect(undo.locator("svg use")).toHaveAttribute("href", "#opencode-v2-icon-arrow-down-to-line")
await undo.hover()
await expect(page.getByRole("tooltip")).toHaveText("Undo")
await undo.click()
await expect(view.rows.locator('[data-action="session-queue-edit"]')).toHaveText([
"first queued prompt",
"third queued prompt",
])
await expect(view.input).toHaveText("second queued prompt")
await expect(view.input).toBeFocused()
expect(mock.changes).toEqual([{ inboxID: "inb_seed_2", action: "cancel" }])
expect(mock.prompts).toEqual([])
})
test("Undo appends to an existing draft and restores inline attachments", async ({ page }) => {
const mock = createQueueMock(["queued with image"])
mock.rows[0].payload.files = [
{
data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScL/nwAAAABJRU5ErkJggg==",
mime: "image/png",
source: { type: "inline" },
name: "shot.png",
},
]
const view = await openSession(page, mock)
await view.input.fill("my draft")
await view.rows.getByRole("button", { name: "Undo" }).click()
await expect(view.rows).toHaveCount(0)
await expect(view.input).toHaveText("my draft\n\nqueued with image")
await expect(view.input).toBeFocused()
await expect(view.composer.getByRole("img", { name: "shot.png" })).toBeVisible()
expect(mock.changes).toEqual([{ inboxID: "inb_seed_1", action: "cancel" }])
})
test("Undo stays usable with a long queue on a narrow screen", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 })
const text = "Review the detailed error report and check every step of the retry path ".repeat(4)
const mock = createQueueMock([text, ...Array.from({ length: 6 }, (_, index) => `queued follow-up ${index + 1}`)])
const view = await openSession(page, mock)
await expect(view.rows).toHaveCount(7)
const row = view.rows.filter({ hasText: text })
await row.getByRole("button", { name: "Undo" }).hover()
await expect(page.getByRole("tooltip")).toHaveText("Undo")
await page.screenshot({ path: testInfo.outputPath("undo-narrow-queue.png") })
await row.getByRole("button", { name: "Undo" }).click()
await expect(view.rows).toHaveCount(6)
await expect(view.input).toHaveText(text)
await expect(view.input).toBeFocused()
expect(mock.changes).toEqual([{ inboxID: "inb_seed_1", action: "cancel" }])
})
test("Undo preserves mentioned file and agent references on resubmission", async ({ page }) => {
const mock = createQueueMock(["inspect @main.ts with @build"])
mock.rows[0].payload.files = [
{
data: "aGk=",
mime: "text/plain",
source: { type: "uri", uri: "file:///repo/main.ts" },
name: "main.ts",
mention: { start: 8, end: 16, text: "@main.ts" },
},
]
mock.rows[0].payload.agents = [{ name: "build", mention: { start: 22, end: 28, text: "@build" } }]
const view = await openSession(page, mock)
await view.rows.getByRole("button", { name: "Undo" }).click()
await expect(view.input).toHaveText("inspect @main.ts with @build")
await view.input.press("Enter")
await expect.poll(() => mock.prompts.length).toBe(1)
expect(mock.prompts[0].files).toMatchObject([
{ uri: "data:text/plain;base64,aGk=", mention: { text: "@main.ts", start: 8, end: 16 } },
])
expect(mock.prompts[0].agents).toMatchObject([{ name: "build", mention: { text: "@build" } }])
})
test("Undo does not discard hidden file context", async ({ page }) => {
const mock = createQueueMock(["inspect this file"])
mock.rows[0].payload.files = [
{ data: "aGk=", mime: "text/plain", source: { type: "uri", uri: "file:///repo/main.ts" }, name: "main.ts" },
]
const view = await openSession(page, mock)
await view.rows.getByRole("button", { name: "Undo" }).click()
await expect(page.getByText("Edit this prompt in the queue to preserve its file context")).toBeVisible()
await expect(view.rows).toHaveCount(1)
await expect(view.input).toHaveText("")
expect(mock.changes).toEqual([])
})
for (const delivery of ["steer", "queue"] as const) {
test(`keeps finished tools above a pending ${delivery === "queue" ? "queue-to-steer" : "steer"} follow-up`, async ({
page,
+1
View File
@@ -47,6 +47,7 @@ export type ComposerDelivery = "steer" | "queue"
// is loaded in the editor.
export type ComposerQueue = {
count: Accessor<number>
undoing: Accessor<boolean>
// Delivery a plain submit uses right now.
delivery: Accessor<ComposerDelivery>
// Delivery offered on Mod+Enter and the toolbar hint button; undefined hides the hint.
@@ -168,6 +168,7 @@ function ComposerStory(props: {
alternate: () => props.alternate,
editing: () => undefined,
confirmEdit() {},
undoing: () => false,
cancelEdit() {},
editFirst: () => false,
}
+2
View File
@@ -16,6 +16,7 @@ export function Composer(props: {
class?: string
model: ComposerModel
borderUnderlay?: boolean
readOnly?: boolean
suggestionBoundary?: () => HTMLElement | undefined
}) {
const dialog = useDialog()
@@ -27,6 +28,7 @@ export function Composer(props: {
<ComposerEditor
controller={props.model}
borderUnderlay={props.borderUnderlay}
readOnly={props.readOnly}
class={props.class}
modelControlsVisible={!props.model.model.loading}
attachKeybind={command.keybindParts("file.attach")}
+1
View File
@@ -371,6 +371,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
onSubmit: (submitOptions) => {
if (!available()) return
const queue = options?.queue
if (queue?.undoing()) return
// Confirming an edit re-admits the queued prompt instead of sending
// the composer value as a new prompt. Enter keeps it queued in
// place; the alternate action sends it as a steer.
+3
View File
@@ -874,6 +874,9 @@ export const dict = {
"session.queue.send": "Send",
"session.queue.steerTooltip": "Send without interrupting",
"session.queue.remove": "Remove",
"session.queue.undo": "Undo",
"session.queue.undoShell": "Leave shell mode before undoing a queued prompt",
"session.queue.undoUnavailable": "Edit this prompt in the queue to preserve its file context",
"session.queue.reorder": "Reorder queued prompt",
"session.queue.attachments.one": "{{count}} attachment",
"session.queue.attachments.other": "{{count}} attachments",
@@ -175,6 +175,18 @@ function SessionQueueRow(props: { queue: SessionQueueView; id: string; index: nu
{props.queue.working() ? language.t("session.queue.steer") : language.t("session.queue.send")}
</Button>
</Tooltip>
<Tooltip placement="top" value={language.t("session.queue.undo")}>
<IconButton
data-action="session-queue-undo"
type="button"
size="small"
variant="ghost-muted"
icon={<Icon name="arrow-down-to-line" />}
disabled={props.queue.busy()}
aria-label={language.t("session.queue.undo")}
onClick={() => props.queue.undo(props.id)}
/>
</Tooltip>
</Show>
<Tooltip placement="top" value={language.t("session.queue.remove")}>
<IconButton
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { SessionInboxInfo } from "@opencode/client/promise"
import { queuedPromptAttachments, queuedPromptRows } from "./queue"
import { queuedPromptAttachments, queuedPromptUndoDraft, queuedPromptRows } from "./queue"
const queued = [
{
@@ -104,3 +104,43 @@ describe("queuedPromptAttachments", () => {
expect(queuedPromptAttachments(item)).toEqual([])
})
})
describe("queuedPromptUndoDraft", () => {
test("keeps full text, structured mentions, and inline images", () => {
const item = {
...queued[0],
payload: {
text: "inspect @main.ts with @build",
files: [
{
data: "aGk=",
mime: "text/plain",
source: { type: "uri" as const, uri: "file:///repo/main.ts" },
name: "main.ts",
mention: { start: 8, end: 16, text: "@main.ts" },
},
{ data: "aGk=", mime: "image/png", source: { type: "inline" as const }, name: "shot.png" },
],
agents: [{ name: "build", mention: { start: 22, end: 28, text: "@build" } }],
},
} satisfies SessionInboxInfo
expect(queuedPromptUndoDraft(item)).toMatchObject([
{ type: "text", content: "inspect " },
{ type: "file", content: "@main.ts", url: "data:text/plain;base64,aGk=" },
{ type: "text", content: " with " },
{ type: "agent", content: "@build", name: "build" },
{ type: "image", filename: "shot.png" },
])
})
test("does not drop hidden file context", () => {
const item = {
...queued[0],
payload: {
text: "inspect this",
files: [{ data: "aGk=", mime: "text/plain", source: { type: "uri" as const, uri: "file:///repo/main.ts" } }],
},
} satisfies SessionInboxInfo
expect(queuedPromptUndoDraft(item)).toBeUndefined()
})
})
+115 -3
View File
@@ -3,10 +3,11 @@ import { createStore } from "solid-js/store"
import { useMutation } from "@tanstack/solid-query"
import type { SessionInboxInfo } from "@opencode/client/promise"
import { SessionMessage } from "@opencode/schema/session-message"
import { Skill } from "@opencode/schema/skill"
import type { ComposerDelivery } from "@/composer/adapter"
import type { ComposerStateTarget } from "@/composer/submission-state"
import type { ImageAttachmentPart, PathAttachmentPart, Prompt } from "@/composer/state"
import { clonePrompt, isAttachment, promptLength } from "@/composer/prompt-parts"
import { appendPrompt, clonePrompt, isAttachment, promptLength } from "@/composer/prompt-parts"
import { buildPromptRequest } from "@/composer/request"
import { blobDataUrl, createLegacyBlobReference } from "@/runtime/persistence/drafts"
import { readPromptPresentation } from "@/composer/comment-note"
@@ -42,6 +43,7 @@ export function createSessionQueue(input: {
mutationFn: async (
change:
| { type: "reorder"; inboxIDs: string[] }
| { type: "undo"; item: QueuedPrompt; prompt: Prompt }
| {
type: "edit"
inboxIDs: string[]
@@ -54,6 +56,16 @@ export function createSessionQueue(input: {
},
) => {
if (change.type === "reorder") return rewrite(change.inboxIDs)
if (change.type === "undo") {
await server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: change.item.id })
const draft = input.draft.current()
const prompt = promptLength(draft)
? appendPrompt(draft, change.prompt)
: [...change.prompt, ...draft.filter(isAttachment)]
input.draft.set(prompt, promptLength(prompt))
input.restoreFocus(promptLength(prompt))
return
}
const replacement = await editedPromptInput(
input.sessionID,
location().directory,
@@ -139,6 +151,21 @@ export function createSessionQueue(input: {
if (state.editing?.id === id) cancelEdit()
return server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: id }).catch(() => notify())
}
const undo = (id: string) => {
if (mutation.isPending || state.editing) return
const item = queued().find((entry) => entry.id === id)
if (!item) return
if (input.draft.mode.current() !== "normal") {
showToast({ title: language.t("session.queue.undoShell") })
return
}
const prompt = queuedPromptUndoDraft(item)
if (!prompt) {
showToast({ title: language.t("session.queue.undoUnavailable") })
return
}
mutation.mutate({ type: "undo", item, prompt })
}
const reorder = (inboxIDs: string[]) => {
if (mutation.isPending) return Promise.resolve()
return mutation.mutateAsync({ type: "reorder", inboxIDs }).catch(() => undefined)
@@ -226,9 +253,11 @@ export function createSessionQueue(input: {
editFirst,
rows,
busy: () => mutation.isPending,
undoing: () => mutation.isPending && mutation.variables?.type === "undo",
working: input.working,
steer,
remove,
undo,
edit,
reorder,
}
@@ -239,7 +268,7 @@ export type SessionQueue = ReturnType<typeof createSessionQueue>
// The slice of the queue the panel renders and drives.
export type SessionQueueView = Pick<
SessionQueue,
"rows" | "editing" | "working" | "busy" | "steer" | "remove" | "edit" | "reorder"
"rows" | "editing" | "working" | "busy" | "steer" | "remove" | "undo" | "edit" | "reorder"
>
export function queuedPromptRows(items: QueuedPrompt[], replacement?: { original: string; replacement: string }) {
@@ -249,7 +278,8 @@ export function queuedPromptRows(items: QueuedPrompt[], replacement?: { original
.map((item) => ({
id: item.id,
text: queuedPromptText(item),
attachments: (item.payload.files?.length ?? 0) + (readPromptPresentation(item.payload.metadata)?.attachments.length ?? 0),
attachments:
(item.payload.files?.length ?? 0) + (readPromptPresentation(item.payload.metadata)?.attachments.length ?? 0),
}))
}
@@ -287,6 +317,88 @@ export function queuedPromptAttachments(item: QueuedPrompt): (ImageAttachmentPar
]
}
// Use the full model-visible text so comment notes and path references remain
// in the draft. Convert mentioned files, agents, and skills back into editor
// parts; a detached draft cannot represent non-mentioned file context.
export function queuedPromptUndoDraft(item: QueuedPrompt): Prompt | undefined {
if (
item.payload.files?.some((file) => !isComposerAttachment(file) && !file.mention) ||
item.payload.agents?.some((agent) => !agent.mention) ||
item.payload.skills?.some((skill) => !skill.mention)
)
return
const text = item.payload.text
const references = [
...(item.payload.files ?? []).flatMap((file) =>
file.mention
? [
{
type: "file" as const,
content: file.mention.text,
start: file.mention.start,
end: file.mention.end,
path: file.name ?? file.mention.text.replace(/^@/, ""),
filename: file.name,
mime: file.mime,
url: `data:${file.mime};base64,${file.data}`,
},
]
: [],
),
...(item.payload.agents ?? []).flatMap((agent) =>
agent.mention
? [
{
type: "agent" as const,
content: agent.mention.text,
start: agent.mention.start,
end: agent.mention.end,
name: agent.name,
},
]
: [],
),
...(item.payload.skills ?? []).flatMap((skill) =>
skill.mention
? [
{
type: "skill" as const,
content: skill.mention.text,
start: skill.mention.start,
end: skill.mention.end,
id: Skill.ID.make(skill.id),
name: Skill.Name.make(skill.name),
},
]
: [],
),
].sort((left, right) => left.start - right.start)
if (
references.some(
(part, index) =>
part.start < (references[index - 1]?.end ?? 0) || text.slice(part.start, part.end) !== part.content,
)
)
return
const parts: Prompt = references.flatMap((part, index) => {
const start = references[index - 1]?.end ?? 0
return [
...(part.start > start
? [{ type: "text" as const, content: text.slice(start, part.start), start, end: part.start }]
: []),
part,
]
})
const start = references.at(-1)?.end ?? 0
return [
...parts,
...(text.length > start || !parts.length
? [{ type: "text" as const, content: text.slice(start), start, end: text.length }]
: []),
...queuedPromptAttachments(item).filter((part) => part.type === "image"),
]
}
function isComposerAttachment(file: NonNullable<QueuedPrompt["payload"]["files"]>[number]) {
return !file.mention && file.source.type === "inline"
}
+6 -1
View File
@@ -224,7 +224,12 @@ export function ActiveSessionComposerRegion(props: {
<div class="relative">
<SessionQueuePanel queue={props.model.queue} />
<div class="relative z-10">
<Composer model={props.model.composer} borderUnderlay suggestionBoundary={props.suggestionBoundary} />
<Composer
model={props.model.composer}
borderUnderlay
readOnly={props.model.queue.undoing()}
suggestionBoundary={props.suggestionBoundary}
/>
</div>
</div>
}
@@ -90,6 +90,8 @@ export type PromptProps = {
export type PromptRef = {
focused: boolean
current: PromptInfo
mode: "normal" | "shell"
setMode(mode: "normal" | "shell"): void
set(prompt: PromptInfo): void
reset(): void
blur(): void
@@ -672,6 +674,9 @@ export function Prompt(props: PromptProps) {
get current() {
return store.prompt
},
get mode() {
return store.mode
},
focus() {
if (disabled()) return
input.focus()
@@ -679,6 +684,9 @@ export function Prompt(props: PromptProps) {
blur() {
input.blur()
},
setMode(mode) {
setStore("mode", mode)
},
set(prompt) {
input.setText(prompt.text)
setStore("prompt", prompt)
+1
View File
@@ -129,6 +129,7 @@ export const Definitions = {
"session.aside": keybind("none", "Ask a side question"),
"session.cd": keybind("none", "Change working directory"),
"session.queued_prompts": keybind("<leader>q", "Manage queued prompts"),
"queued_prompt.undo": keybind("ctrl+u", "Undo queued prompt"),
"queued_prompt.delete": keybind("ctrl+d", "Delete queued prompt"),
"session.toggle.exploration_grouping": keybind("none", "Toggle related tool call grouping"),
"session.verbosity.cycle": keybind("none", "Cycle transcript verbosity"),
+13 -1
View File
@@ -539,7 +539,6 @@ export function RunCommandMenuBody(props: {
return
}
if (item.action === "subagent") {
props.onSubagent()
return
@@ -949,6 +948,7 @@ export function RunQueuedPromptSelectBody(props: {
prompts: Accessor<FooterQueuedPrompt[]>
onClose: () => void
onSelect: (prompt: FooterQueuedPrompt) => void
onUndo: (prompt: FooterQueuedPrompt) => void
onDelete: (prompt: FooterQueuedPrompt) => void
onRows?: (rows: number) => void
mono?: boolean
@@ -970,10 +970,21 @@ export function RunQueuedPromptSelectBody(props: {
onRows: props.onRows,
})
const shortcuts = Keymap.useShortcuts()
const undoShortcut = () => monoShortcut(shortcuts.get("queued_prompt.undo") ?? "", props.mono ?? false)
const deleteShortcut = () => monoShortcut(shortcuts.get("queued_prompt.delete") ?? "", props.mono ?? false)
Keymap.createLayer(() => ({
priority: 1,
commands: [
{
id: "queued_prompt.undo",
title: "Undo",
group: "Prompt",
run() {
const item = controller.items()[controller.menu.selected()]
if (!item) return false
props.onUndo(item.prompt)
},
},
{
id: "queued_prompt.delete",
title: "Delete pending prompt",
@@ -1001,6 +1012,7 @@ export function RunQueuedPromptSelectBody(props: {
hint={[
controller.items()[controller.menu.selected()]?.prompt.delivery === "steer" ? "enter queue" : "enter steer",
deleteShortcut() ? `${deleteShortcut()} delete` : undefined,
undoShortcut() ? `${undoShortcut()} undo` : undefined,
]
.filter(Boolean)
.join(" · ")}
+6 -1
View File
@@ -160,7 +160,8 @@ export type PromptState = {
onPaste: (event: PasteEvent) => Promise<void>
onContentChange: () => void
onSizeChange: () => void
replacePrompt: (prompt: RunPrompt) => void
current: () => RunPrompt
replacePrompt: (prompt: RunPrompt, cursor?: number) => void
bind: (area?: TextareaRenderable) => void
}
@@ -1544,6 +1545,10 @@ export function createPromptState(input: PromptInput): PromptState {
scheduleRows()
},
onSizeChange: scheduleRows,
current: () => {
syncDraft()
return promptCopy(draft)
},
replacePrompt: restore,
bind,
}
+17 -2
View File
@@ -27,6 +27,8 @@ import {
import { FOOTER_MENU_ROWS, RunFooterMenu } from "./footer.menu"
import { RunFooterSubagentBody } from "./footer.subagent"
import { RunPromptBody, createPromptState } from "./footer.prompt"
import { promptAppend } from "./prompt.shared"
import { promptOffsetWidth } from "../prompt/display"
import { RunPermissionBody } from "./footer.permission"
import { RunFormBody } from "./footer.form"
import { createFormBodyState, type FormBodyState } from "./form.shared"
@@ -320,7 +322,7 @@ export function RunFooterView(props: RunFooterViewProps) {
}
const runQueuedAction = createSingleFlight<string>()
const queuedPromptAction = async (action: QueuedPromptAction, inboxID: string) => {
const queuedPromptAction = async (action: QueuedPromptAction, inboxID: string, failureLabel?: string) => {
const run = props.onQueuedPromptAction
if (!run) return false
const result = await runQueuedAction(inboxID, async () => {
@@ -329,7 +331,9 @@ export function RunFooterView(props: RunFooterViewProps) {
(error) => error,
)
if (!error) return true
props.onStatus(`failed to ${action === "cancel" ? "delete" : action} pending prompt: ${errorMessage(error)}`)
props.onStatus(
`failed to ${failureLabel ?? (action === "cancel" ? "delete" : action)} pending prompt: ${errorMessage(error)}`,
)
return false
})
return result ?? false
@@ -795,6 +799,17 @@ export function RunFooterView(props: RunFooterViewProps) {
)
closePanel()
}}
onUndo={async (item) => {
const current = composer.current()
if (current.mode === "shell" && current.text) {
props.onStatus("leave shell mode before undoing a queued prompt")
return
}
if (!(await queuedPromptAction("cancel", item.messageID, "undo"))) return
closePanel()
const next = promptAppend(composer.current(), item.prompt)
composer.replacePrompt(next, promptOffsetWidth(next.text))
}}
onDelete={(item) => {
void queuedPromptAction("cancel", item.messageID)
}}
+25
View File
@@ -8,6 +8,7 @@
// the current draft is saved and history begins. Arrowing past the end
// restores the draft.
export { displayCharAt, displaySlice, mentionTriggerIndex, slashTriggerIndex } from "../prompt/display"
import { promptOffsetWidth } from "../prompt/display"
import { stringWidth } from "../util/string-width"
import type { RunPrompt } from "./types"
@@ -35,6 +36,30 @@ export function promptCopy(prompt: RunPrompt): RunPrompt {
}
}
// Part ranges are textarea offsets, so shift by display width rather than string length.
export function promptAppend(prompt: RunPrompt, following: RunPrompt): RunPrompt {
const text = prompt.text ? `${prompt.text}\n\n` : ""
const offset = promptOffsetWidth(text)
const shift = <T extends { start: number; end: number }>(range: T) => ({
...range,
start: range.start + offset,
end: range.end + offset,
})
const command = prompt.text ? prompt.command : following.command
return {
text: text + following.text,
parts: [
...structuredClone(prompt.parts),
...structuredClone(following.parts).map((part) => {
if (!part.source) return part
if (part.type === "file") return { ...part, source: { ...part.source, text: shift(part.source.text) } }
return { ...part, source: shift(part.source) }
}),
],
...(command ? { command } : {}),
}
}
export function promptSame(a: RunPrompt, b: RunPrompt): boolean {
return (
a.mode === b.mode &&
+29
View File
@@ -6,6 +6,7 @@ import type { Types } from "effect"
import { createSimpleContext } from "../context/helper"
import { useTuiPaths } from "../context/runtime"
import { appendText, readText, writeText } from "../util/persistence"
import { promptOffsetWidth } from "./display"
export type PastedText = {
text: string
@@ -28,6 +29,34 @@ export type PromptPartRef = {
export const emptyPrompt = (): PromptInfo => ({ text: "", files: [], agents: [], skills: [], pasted: [] })
// Part ranges are textarea offsets, so shift by display width rather than string length.
export function appendPrompt(prompt: PromptInfo, following: PromptInfo): PromptInfo {
const text = prompt.text ? `${prompt.text}\n\n` : ""
const offset = promptOffsetWidth(text)
const shift = <T extends { start: number; end: number }>(range: T, by: number) => ({
...range,
start: range.start + by,
end: range.end + by,
})
const mentions = <T extends { mention?: { start: number; end: number } }>(
items: T[] | undefined,
next: T[] | undefined,
) => [
...(items ?? []).map((item) => ({ ...item, mention: item.mention && shift(item.mention, 0) })),
...(next ?? []).map((item) => ({ ...item, mention: item.mention && shift(item.mention, offset) })),
]
return {
text: text + following.text,
files: mentions(prompt.files, following.files),
agents: mentions(prompt.agents, following.agents),
skills: mentions(prompt.skills, following.skills),
pasted: [
...prompt.pasted.map((part) => ({ ...part, source: shift(part.source, 0) })),
...following.pasted.map((part) => ({ ...part, source: shift(part.source, offset) })),
],
}
}
export const MAX_HISTORY_ENTRIES = 50
export function parsePromptHistory(text: string) {
+26 -3
View File
@@ -70,6 +70,7 @@ import { useToast } from "../../ui/toast"
import stripAnsi from "strip-ansi"
import { usePromptRef } from "../../context/prompt"
import { projectedPromptInput } from "../../prompt/codec"
import { appendPrompt } from "../../prompt/history"
import { deduplicateVisibleImages } from "../../prompt/attachment"
import { useEpilogue } from "../../context/epilogue"
import { normalizePath } from "../../util/path"
@@ -211,7 +212,9 @@ export function Session(props: {
)
const pendingDeliveries = createMemo(() => new Map(pendingUsers().map((item) => [item.id, item.delivery])))
const queuedPrompts = createMemo(() =>
pendingUsers().flatMap((item) => (item.delivery === "queue" ? [{ id: item.id, text: item.payload.text }] : [])),
pendingUsers().flatMap((item) =>
item.delivery === "queue" ? [{ id: item.id, text: item.payload.text, payload: item.payload }] : [],
),
)
const [composer, setComposer] = createStore({
open: false,
@@ -608,7 +611,7 @@ export function Session(props: {
const dialog = useDialog()
const renderer = useRenderer()
const runPendingAction = createSingleFlight<string>()
const mutatePending = async (action: PendingAction, inboxID: string) => {
const mutatePending = async (action: PendingAction, inboxID: string, failureLabel?: string) => {
const result = await runPendingAction(inboxID, async () => {
const request =
action === "steer"
@@ -621,7 +624,7 @@ export function Session(props: {
(error) => error,
)
if (!error) return true
const label = action === "cancel" ? "delete" : action
const label = failureLabel ?? (action === "cancel" ? "delete" : action)
toast.show({ title: `Failed to ${label} pending prompt`, message: errorMessage(error), variant: "error" })
return false
})
@@ -652,6 +655,26 @@ export function Session(props: {
})
},
},
{
command: "queued_prompt.undo",
title: "undo",
onTrigger: (option) => {
const target = prompt()
const queued = queuedPrompts().find((item) => item.id === option.value)
if (!target || !queued) return
if (target.mode === "shell" && target.current.text) {
toast.show({ message: "Leave shell mode before undoing a queued prompt", variant: "error" })
return
}
void mutatePending("cancel", queued.id, "undo").then((undone) => {
if (!undone) return
target.setMode("normal")
target.set(appendPrompt(target.current, { ...projectedPromptInput(queued.payload), pasted: [] }))
dialog.clear()
target.focus()
})
},
},
]}
footerHints={[{ title: "steer", label: "enter" }]}
/>
+132 -2
View File
@@ -1,6 +1,13 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { BoxRenderable, ImageRenderable, RGBA, type CliRenderer, type RootRenderable } from "@opentui/core"
import {
BoxRenderable,
ImageRenderable,
RGBA,
TextareaRenderable,
type CliRenderer,
type RootRenderable,
} from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"
import { testRender } from "@opentui/solid"
import { createSignal } from "solid-js"
@@ -1268,7 +1275,7 @@ test.each(["queue", "steer"] as const)("direct footer toggles and deletes pendin
expect(frame).toContain("Pending prompts")
expect(frame).toContain("follow up")
expect(frame).toContain(delivery === "queue" ? "queued" : "steering")
expect(frame).toContain(`enter ${delivery === "queue" ? "steer" : "queue"} · ctrl+d delete`)
expect(frame).toContain(`enter ${delivery === "queue" ? "steer" : "queue"} · ctrl+d delete · ctrl+u undo`)
expect(frame).not.toContain("┌")
expect(frame).not.toContain("┃")
expectPaletteList(list, 0)
@@ -1294,6 +1301,129 @@ test.each(["queue", "steer"] as const)("direct footer toggles and deletes pendin
}
})
test("undo appends a pending prompt to the draft", async () => {
const actions: string[] = []
const submitted: RunPrompt[] = []
const queued: FooterQueuedPrompt = {
messageID: "m-1",
prompt: {
messageID: "m-1",
text: "look at main.ts",
parts: [],
},
delivery: "queue",
}
const app = await renderFooter({
queuedPrompts: [queued],
onSubmit: (prompt) => {
submitted.push(prompt)
return true
},
onQueuedPromptAction: async (action, inboxID) => {
actions.push(`${action}:${inboxID}`)
app.setQueuedPrompts([])
},
})
try {
await app.renderOnce()
await app.mockInput.typeText("existing draft")
app.mockInput.pressKey("x", { ctrl: true })
app.mockInput.pressKey("q")
await app.renderOnce()
app.mockInput.pressKey("u", { ctrl: true })
await Bun.sleep(0)
await app.renderOnce()
expect(actions).toEqual(["cancel:m-1"])
expect(app.captureCharFrame()).toContain("existing draft")
expect(app.captureCharFrame()).toContain("look at main.ts")
expect(app.captureCharFrame()).not.toContain("Pending prompts")
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof TextareaRenderable)
app.mockInput.pressEnter()
await Bun.sleep(0)
await app.renderOnce()
expect(submitted).toMatchObject([{ text: "existing draft\n\nlook at main.ts" }])
expect(submitted[0].messageID).toBeUndefined()
} finally {
app.cleanup()
}
})
test("undo leaves the queue and input alone when cancellation fails", async () => {
const statuses: string[] = []
const app = await renderFooter({
queuedPrompts: [{ messageID: "m-1", prompt: { text: "still queued", parts: [] }, delivery: "queue" }],
onStatus: (status) => statuses.push(status),
onQueuedPromptAction: async () => {
throw new Error("cancel failed")
},
})
try {
await app.renderOnce()
app.mockInput.pressKey("x", { ctrl: true })
app.mockInput.pressKey("q")
await app.renderOnce()
app.mockInput.pressKey("u", { ctrl: true })
await Bun.sleep(0)
await app.renderOnce()
expect(statuses.at(-1)).toContain("failed to undo pending prompt: cancel failed")
expect(app.captureCharFrame()).toContain("Pending prompts")
app.mockInput.pressKey("ESCAPE")
await app.renderOnce()
expect(app.captureCharFrame()).not.toContain("still queued")
} finally {
app.cleanup()
}
})
test("undo retains mentioned files when the prompt is sent again", async () => {
const submitted: RunPrompt[] = []
const app = await renderFooter({
queuedPrompts: [
{
messageID: "m-1",
delivery: "queue",
prompt: {
messageID: "m-1",
text: "inspect @src/main.ts please",
parts: [
{
type: "file",
url: "file:///src/main.ts",
filename: "main.ts",
source: { type: "file", path: "src/main.ts", text: { start: 8, end: 20, value: "@src/main.ts" } },
},
],
},
},
],
onQueuedPromptAction: async () => {
app.setQueuedPrompts([])
},
onSubmit: (prompt) => {
submitted.push(prompt)
return true
},
})
try {
await app.renderOnce()
app.mockInput.pressKey("x", { ctrl: true })
app.mockInput.pressKey("q")
await app.renderOnce()
app.mockInput.pressKey("u", { ctrl: true })
await Bun.sleep(0)
await app.renderOnce()
expect(app.captureCharFrame()).toContain("inspect @src/main.ts please")
app.mockInput.pressEnter()
await Bun.sleep(0)
expect(submitted).toMatchObject([
{ text: "inspect @src/main.ts please", parts: [{ type: "file", url: "file:///src/main.ts" }] },
])
expect(submitted[0].messageID).toBeUndefined()
} finally {
app.cleanup()
}
})
test("direct footer steers the oldest queued prompt from an empty composer", async () => {
const steered: string[] = []
const app = await renderFooter({
@@ -5,6 +5,7 @@ import {
isExitCommand,
isNewCommand,
movePromptHistory,
promptAppend,
pushPromptHistory,
} from "../../src/mini/prompt.shared"
import type { RunPrompt } from "../../src/mini/types"
@@ -106,3 +107,28 @@ describe("run prompt shared", () => {
expect(isCompactCommand("/summarize")).toBe(false)
})
})
describe("promptAppend", () => {
test("appends on a new line and shifts part ranges by display width", () => {
const output = promptAppend(
prompt("日本", [{ type: "agent", name: "plan", source: { start: 0, end: 4, value: "日本" } }]),
{
messageID: "m-1",
...prompt("@a.ts /x", [
{ type: "file", url: "file:///a.ts", source: { type: "file", text: { start: 0, end: 5, value: "@a.ts" } } },
{ type: "skill", id: "x", source: { start: 6, end: 8, value: "/x" } },
]),
},
)
expect(output).toEqual({
text: "日本\n\n@a.ts /x",
parts: [
{ type: "agent", name: "plan", source: { start: 0, end: 4, value: "日本" } },
{ type: "file", url: "file:///a.ts", source: { type: "file", text: { start: 6, end: 11, value: "@a.ts" } } },
{ type: "skill", id: "x", source: { start: 12, end: 14, value: "/x" } },
],
})
expect(promptAppend(prompt(""), prompt("next"))).toEqual(prompt("next"))
})
})
+30 -1
View File
@@ -1,5 +1,11 @@
import { describe, expect, test } from "bun:test"
import { isDuplicateEntry, MAX_HISTORY_ENTRIES, parsePromptHistory, type PromptInfo } from "../../src/prompt/history"
import {
appendPrompt,
isDuplicateEntry,
MAX_HISTORY_ENTRIES,
parsePromptHistory,
type PromptInfo,
} from "../../src/prompt/history"
const entry = (text: string, files: PromptInfo["files"] = []): PromptInfo => ({
text,
@@ -58,4 +64,27 @@ describe("prompt history", () => {
expect(parsePromptHistory(JSON.stringify(value))).toEqual([value])
})
test("appends a prompt on a new line and shifts its ranges by display width", () => {
const output = appendPrompt(
{ ...entry("日本"), pasted: [{ text: "long", source: { start: 0, end: 4, text: "日本" } }] },
{
...entry("@a.ts [Pasted]", [{ uri: "file:///a.ts", mention: { start: 0, end: 5, text: "@a.ts" } }]),
agents: [{ name: "build" }],
pasted: [{ text: "more", source: { start: 6, end: 14, text: "[Pasted]" } }],
},
)
expect(output).toEqual({
text: "日本\n\n@a.ts [Pasted]",
files: [{ uri: "file:///a.ts", mention: { start: 6, end: 11, text: "@a.ts" } }],
agents: [{ name: "build", mention: undefined }],
skills: [],
pasted: [
{ text: "long", source: { start: 0, end: 4, text: "日本" } },
{ text: "more", source: { start: 12, end: 20, text: "[Pasted]" } },
],
})
expect(appendPrompt(entry(""), entry("next")).text).toBe("next")
})
})
@@ -197,6 +197,7 @@ The retired `diff.toggle`, `diff.expand`, `diff.expand_all`, `diff.collapse`, an
| `session.aside` | `none` | Ask a side question |
| `session.cd` | `none` | Change working directory |
| `session.queued_prompts` | `<leader>q` | Manage queued prompts |
| `queued_prompt.undo` | `ctrl+u` | Undo queued prompt |
| `queued_prompt.delete` | `ctrl+d` | Delete queued prompt |
| `session.toggle.exploration_grouping` | `none` | Toggle related tool call grouping |
| `session.child.first` | `down` | Toggle subagent picker |