diff --git a/packages/app/e2e/regression/session-queue.spec.ts b/packages/app/e2e/regression/session-queue.spec.ts index 81ac9c88fd1..c855320c254 100644 --- a/packages/app/e2e/regression/session-queue.spec.ts +++ b/packages/app/e2e/regression/session-queue.spec.ts @@ -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 } + payload: { + text: string + metadata?: Record + files?: Extract["payload"]["files"] + agents?: Extract["payload"]["agents"] + } delivery: "steer" | "queue" } @@ -29,7 +34,7 @@ function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) { })) const events: OpenCodeEvent[] = [] const prompts: Record[] = [] - const changes: { inboxID: string; action: "cancel" | "steer" }[] = [] + const changes: { inboxID: string; action: "cancel" | "steer" | "queue" }[] = [] const log: string[] = [] let sequence = 0 const emit = ( @@ -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, diff --git a/packages/app/src/composer/adapter.ts b/packages/app/src/composer/adapter.ts index ffc50788d01..cadfcf0462b 100644 --- a/packages/app/src/composer/adapter.ts +++ b/packages/app/src/composer/adapter.ts @@ -47,6 +47,7 @@ export type ComposerDelivery = "steer" | "queue" // is loaded in the editor. export type ComposerQueue = { count: Accessor + undoing: Accessor // Delivery a plain submit uses right now. delivery: Accessor // Delivery offered on Mod+Enter and the toolbar hint button; undefined hides the hint. diff --git a/packages/app/src/composer/composer.stories.tsx b/packages/app/src/composer/composer.stories.tsx index 5e1e3a091a3..492b30f1bc8 100644 --- a/packages/app/src/composer/composer.stories.tsx +++ b/packages/app/src/composer/composer.stories.tsx @@ -168,6 +168,7 @@ function ComposerStory(props: { alternate: () => props.alternate, editing: () => undefined, confirmEdit() {}, + undoing: () => false, cancelEdit() {}, editFirst: () => false, } diff --git a/packages/app/src/composer/composer.tsx b/packages/app/src/composer/composer.tsx index 1762ace07b9..5b648e5300b 100644 --- a/packages/app/src/composer/composer.tsx +++ b/packages/app/src/composer/composer.tsx @@ -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: { { 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. diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 3a061853699..0c44e05dad1 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -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", diff --git a/packages/app/src/session/composer/queue-panel.tsx b/packages/app/src/session/composer/queue-panel.tsx index ac23f8ecc38..56e8ba0354b 100644 --- a/packages/app/src/session/composer/queue-panel.tsx +++ b/packages/app/src/session/composer/queue-panel.tsx @@ -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")} + + } + disabled={props.queue.busy()} + aria-label={language.t("session.queue.undo")} + onClick={() => props.queue.undo(props.id)} + /> + { 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() + }) +}) diff --git a/packages/app/src/session/composer/queue.ts b/packages/app/src/session/composer/queue.ts index 793b29f0541..f0255a7fa70 100644 --- a/packages/app/src/session/composer/queue.ts +++ b/packages/app/src/session/composer/queue.ts @@ -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 // 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[number]) { return !file.mention && file.source.type === "inline" } diff --git a/packages/app/src/session/composer/region.tsx b/packages/app/src/session/composer/region.tsx index a95e3d2eb70..86fefd7b4d4 100644 --- a/packages/app/src/session/composer/region.tsx +++ b/packages/app/src/session/composer/region.tsx @@ -224,7 +224,12 @@ export function ActiveSessionComposerRegion(props: {
- +
} diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 99566e83862..db549661ad5 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -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) diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index a4b285fc8bf..db07953549e 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -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("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"), diff --git a/packages/tui/src/mini/footer.command.tsx b/packages/tui/src/mini/footer.command.tsx index 00f76faa836..254aafd9101 100644 --- a/packages/tui/src/mini/footer.command.tsx +++ b/packages/tui/src/mini/footer.command.tsx @@ -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 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(" · ")} diff --git a/packages/tui/src/mini/footer.prompt.tsx b/packages/tui/src/mini/footer.prompt.tsx index af5af2d4e24..d698f12a33f 100644 --- a/packages/tui/src/mini/footer.prompt.tsx +++ b/packages/tui/src/mini/footer.prompt.tsx @@ -160,7 +160,8 @@ export type PromptState = { onPaste: (event: PasteEvent) => Promise 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, } diff --git a/packages/tui/src/mini/footer.view.tsx b/packages/tui/src/mini/footer.view.tsx index 2fcd5113dba..d0b8807bd45 100644 --- a/packages/tui/src/mini/footer.view.tsx +++ b/packages/tui/src/mini/footer.view.tsx @@ -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() - 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) }} diff --git a/packages/tui/src/mini/prompt.shared.ts b/packages/tui/src/mini/prompt.shared.ts index ac9bf0f4026..022a989f69a 100644 --- a/packages/tui/src/mini/prompt.shared.ts +++ b/packages/tui/src/mini/prompt.shared.ts @@ -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 = (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 && diff --git a/packages/tui/src/prompt/history.tsx b/packages/tui/src/prompt/history.tsx index 4efd34f6e5a..f4ea5c56e83 100644 --- a/packages/tui/src/prompt/history.tsx +++ b/packages/tui/src/prompt/history.tsx @@ -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 = (range: T, by: number) => ({ + ...range, + start: range.start + by, + end: range.end + by, + }) + const mentions = ( + 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) { diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 88bd2b42360..72a3a9dfb2e 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -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() - 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" }]} /> diff --git a/packages/tui/test/mini/footer.view.test.tsx b/packages/tui/test/mini/footer.view.test.tsx index 7c9e7d88753..3c98b7b60a7 100644 --- a/packages/tui/test/mini/footer.view.test.tsx +++ b/packages/tui/test/mini/footer.view.test.tsx @@ -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({ diff --git a/packages/tui/test/mini/prompt.shared.test.ts b/packages/tui/test/mini/prompt.shared.test.ts index cec46600b75..d16c800a72a 100644 --- a/packages/tui/test/mini/prompt.shared.test.ts +++ b/packages/tui/test/mini/prompt.shared.test.ts @@ -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")) + }) +}) diff --git a/packages/tui/test/prompt/history.test.ts b/packages/tui/test/prompt/history.test.ts index a505ccac929..18ee1334c85 100644 --- a/packages/tui/test/prompt/history.test.ts +++ b/packages/tui/test/prompt/history.test.ts @@ -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") + }) }) diff --git a/services/www/src/docs/content/cli/keybinds.mdx b/services/www/src/docs/content/cli/keybinds.mdx index 0d2311b4431..07ad4e8c97b 100644 --- a/services/www/src/docs/content/cli/keybinds.mdx +++ b/services/www/src/docs/content/cli/keybinds.mdx @@ -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` | `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 |