From b8a45be5ef804427a47ee49c2506abe85ad8ad84 Mon Sep 17 00:00:00 2001 From: Kingsword Date: Wed, 23 Sep 2026 20:21:51 +0800 Subject: [PATCH 1/2] feat(tui): manage queued drafts and edit or retry questions --- README.md | 17 +++ package.json | 2 +- packages/zcode-tui/src/index.ts | 117 +++++++++++++++++++- packages/zcode-tui/src/input-queue.ts | 42 ++++++- packages/zcode-tui/src/queued-input-view.ts | 4 +- test/input-queue.test.ts | 27 +++++ test/tui/fixtures/input-actions.ts | 17 +++ test/tui/input-actions.test.ts | 69 ++++++++++++ 8 files changed, 290 insertions(+), 5 deletions(-) create mode 100644 test/tui/fixtures/input-actions.ts create mode 100644 test/tui/input-actions.test.ts diff --git a/README.md b/README.md index 8d13dbd..408e141 100644 --- a/README.md +++ b/README.md @@ -304,6 +304,10 @@ picker to return to input selection, then `Esc` again to close rewind. /status inspect detailed runtime and session status /rename rename the current session /activity inspect every active tool and open task +/queue reorder, delete or edit queued drafts +/queue pause|resume pause or resume automatic draft submission +/edit-message rewind to an earlier question and edit it +/retry rewind to an earlier question and send it again /tasks inspect and manage background tasks /tasks message <id> <text> send guidance to a running background agent /tasks resume <id> [text] resume a stopped or failed background agent @@ -320,6 +324,19 @@ picker to return to input selection, then `Esc` again to close rewind. runtime's own `/clear` is an alias of `/new` and starts a fresh session, so it is forwarded to the runtime unchanged. +`/queue` manages local next-turn drafts and pauses automatic sending while its +picker is open. Inputs already accepted by the runtime cannot be edited here. +`/queue pause` persists across turns until `/queue resume`. Moving a draft to +the editor preserves its original text; Tab queues the edited draft again. + +`/edit-message` and `/retry` require an idle conversation and ask before removing +later turns. Both use conversation-only rewind and keep workspace files as they +are. Editing restores the selected question to the editor; retry sends it again +with the current model. Existing `/fork` remains available for checkpoint-based +branches that preserve the original conversation. +These actions restore question text only; original image attachments are not +resent. Use `/edit-message` and reattach images when they are needed. + The task center keeps autonomous task output out of the foreground transcript. The main conversation receives only compact completion, reply and failure notices; select the task to inspect its output and task-scoped activity. Agent diff --git a/package.json b/package.json index 2c47c3c..cf03f29 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ "test:node": "node --test test/node/*.test.cjs", "test:tui": "bun run test:tui:component && bun run test:tui:e2e", "test:tui:component": "bun test test/tui/scenario-http.test.ts test/tui/scenario-runtime.test.ts test/tui/scenario-shell.test.ts test/tui/scenario-workspace.test.ts test/tui/terminal-screen.test.ts", - "test:tui:e2e": "bun test test/tui/allowlisted-shell.test.ts test/tui/http-mock.test.ts test/tui/model-resume.test.ts test/tui/permission-request-queue.test.ts test/tui/run-scenario.test.ts test/tui/session-rename.test.ts test/tui/terminal-session.test.ts test/tui/write-and-diff.test.ts", + "test:tui:e2e": "bun test test/tui/allowlisted-shell.test.ts test/tui/http-mock.test.ts test/tui/input-actions.test.ts test/tui/model-resume.test.ts test/tui/permission-request-queue.test.ts test/tui/run-scenario.test.ts test/tui/session-rename.test.ts test/tui/terminal-session.test.ts test/tui/write-and-diff.test.ts", "test:tui:host": "bun test test/tui/scenario-mountx.test.ts", "test:tui:manual": "bun scripts/tui-scenario.ts --manual", "test:tui-scenario": "bun scripts/tui-scenario.ts", diff --git a/packages/zcode-tui/src/index.ts b/packages/zcode-tui/src/index.ts index 7c0c5f8..7125137 100644 --- a/packages/zcode-tui/src/index.ts +++ b/packages/zcode-tui/src/index.ts @@ -1375,6 +1375,9 @@ class ZCodeTui { { name: "paste-image", description: "Attach an image from the system clipboard" }, { name: "attachments", description: "Manage or clear pending attachments", argumentHint: "[clear]" }, { name: "activity", description: "Inspect every active tool and open task" }, + { name: "queue", description: "Manage queued drafts", argumentHint: "[pause|resume]" }, + { name: "edit-message", description: "Restore an earlier question for editing" }, + { name: "retry", description: "Retry an earlier question from a conversation rewind" }, { name: "tasks", description: "Inspect, message or recover background tasks", @@ -1491,7 +1494,7 @@ class ZCodeTui { return { consume: true }; } if (matchesKey(data, "tab") - && this.turnAbortController + && (this.turnAbortController || this.inputQueue.paused) && Boolean(this.editor.getText().trim()) && !this.editor.isShowingAutocomplete()) { this.queueCurrentEditorInput(); @@ -1630,6 +1633,21 @@ class ZCodeTui { await this.showActivityDetails(); return; } + if (input === "/queue" || input === "/queue list") { + await this.manageInputQueue(); + return; + } + if (input === "/queue pause" || input === "/queue resume") { + this.inputQueue.paused = input === "/queue pause"; + if (!this.inputQueue.paused) this.inputQueue.resetAutoSend(); + this.addNotice(this.inputQueue.paused ? "Queue paused. Use /queue resume to continue." : "Queue resumed.", "muted"); + this.drainInputAfterBackgroundHandoff(); + return; + } + if (input === "/edit-message" || input === "/retry") { + await this.editOrRetryMessage(input === "/retry"); + return; + } if (input === "/tasks" || input === "/tasks list") { await this.showBackgroundTasks(); return; @@ -3251,11 +3269,108 @@ class ZCodeTui { private queueCurrentEditorInput(): void { const input = this.editor.getText().trim(); if (!input) return; + if (this.pendingAttachments.length > 0) { + this.addNotice("Send or remove pending images before queueing a text draft.", "warning"); + return; + } this.editor.setText(""); this.inputQueue.queueFollowUp(protectSubmission(input)); } + private async manageInputQueue(): Promise<void> { + const wasPaused = this.inputQueue.paused; + this.inputQueue.paused = true; + try { + while (true) { + const entries = this.inputQueue.entries(); + if (!entries.length) { this.addNotice("No queued inputs.", "muted"); return; } + const selected = await this.showChoice({ + title: "Queued inputs", prompt: "Select a draft. Sending pauses while this panel is open.", + items: entries.map((entry) => ({ value: String(entry.id), label: entry.displayInput, + description: entry.editable ? "Editable draft" : "Accepted by runtime · cannot edit here" })) + }); + if (!selected) return; + const entry = entries.find((entry) => String(entry.id) === selected.value); + if (!entry?.editable) { this.addNotice("This input is already owned by the runtime.", "warning"); continue; } + const action = await this.showChoice({ + title: "Queued draft", prompt: "Choose an action.", items: [ + { value: "edit", label: "Move to editor" }, + { value: "up", label: "Move earlier" }, + { value: "down", label: "Move later" }, + { value: "delete", label: "Delete draft" }, + { value: "back", label: "Back" } + ] + }); + if (!action || action.value === "back") continue; + if (action.value === "edit" && (this.editor.getText().trim() || this.pendingAttachments.length > 0)) { + this.addNotice("Save or clear the current draft before editing a queued input.", "warning"); + return; + } + if (action.value === "up" || action.value === "down") { + if (!this.inputQueue.move(entry.id, action.value === "up" ? -1 : 1)) this.addNotice("The draft cannot move further in that direction.", "muted"); + } else { + const removed = this.inputQueue.remove(entry.id); + if (!removed) { this.addNotice("The queued input changed. Select it again.", "warning"); continue; } + if (action.value === "edit") { + this.editor.setText(removed.input); + this.focusEditor(); + return; + } + } + } + } finally { + this.inputQueue.paused = wasPaused; + this.drainInputAfterBackgroundHandoff(); + } + } + + private async editOrRetryMessage(retry: boolean): Promise<void> { + if (this.activeSubmissions > 0 || this.backgroundTaskEvents.hasActiveHandoffs()) { + this.addNotice("Wait for the active turn before changing conversation history.", "warning"); + return; + } + if (this.editor.getText().trim() || this.pendingAttachments.length > 0) { + this.addNotice("Save or clear the current draft before changing conversation history.", "warning"); + return; + } + if (!this.options.loadSessionTranscript) { + this.addNotice("Message history is unavailable in this runtime.", "warning"); + return; + } + try { + const targets = rewindTargets(await this.options.loadSessionTranscript()); + if (!targets.length) { this.addNotice("No earlier questions to select.", "muted"); return; } + const selected = await this.showChoice({ + title: retry ? "Retry a question" : "Edit an earlier question", + prompt: "Select the question to return to.", + items: targets.map((target) => ({ value: target.messageId, label: rewindTargetLabel(target.text) })) + }); + const target = targets.find((target) => target.messageId === selected?.value); + if (!target) return; + const confirmation = await this.showChoice({ + title: retry ? "Retry this question?" : "Edit this question?", + prompt: "Later conversation turns will be removed. Workspace files stay as they are. " + + (retry ? "Only question text will be sent again; original images are not included." + : "Only question text is restored; reattach any images before sending."), + items: [{ value: "cancel", label: "Cancel" }, { value: "confirm", label: retry ? "Rewind and send again" : "Rewind and move to editor" }] + }); + if (confirmation?.value !== "confirm") return; + if (this.activeSubmissions > 0 || this.backgroundTaskEvents.hasActiveHandoffs()) { + this.addNotice("The conversation became active. Wait for it to finish and select the question again.", "warning"); + return; + } + await this.applyConversationRewind(target, "conversation"); + if (retry) { this.editor.setText(""); await this.submit(target.text); } + } catch (error) { + this.addNotice(error instanceof Error ? error.message : String(error), "error"); + } + } + private editLatestQueuedFollowUp(): void { + if (this.pendingAttachments.length > 0) { + this.addNotice("Send or remove pending images before restoring a queued draft.", "warning"); + return; + } const submission = this.inputQueue.editLatestFollowUp(); if (!submission) return; this.editor.setText(submission.input); diff --git a/packages/zcode-tui/src/input-queue.ts b/packages/zcode-tui/src/input-queue.ts index 7fd4610..a1e55b9 100644 --- a/packages/zcode-tui/src/input-queue.ts +++ b/packages/zcode-tui/src/input-queue.ts @@ -20,6 +20,7 @@ export interface PendingSteerSubmission { export interface InputQueueState { pendingSteers: string[]; queuedInputs: string[]; + paused?: boolean; } export interface CommittedSteer { @@ -46,6 +47,9 @@ export class InputQueue { private readonly pendingResolutions = new Map<string, PendingSteerResolution>(); private readonly completedTurnIds = new Set<string>(); private autoSendEnabled = true; + private pausedByUser = false; + private nextId = 1; + private readonly ids = new WeakMap<QueuedSubmission, number>(); constructor(private readonly callbacks: InputQueueCallbacks) {} @@ -78,6 +82,39 @@ export class InputQueue { return this.queuedFollowUps.length > 0; } + entries(): Array<{ id: number; displayInput: string; editable: boolean }> { + return this.queuedFollowUps.map((submission) => { + let id = this.ids.get(submission); + if (id === undefined) { id = this.nextId++; this.ids.set(submission, id); } + return { id, displayInput: submission.displayInput, editable: !submission.pendingInputIds?.length }; + }); + } + + remove(id: number): QueuedSubmission | undefined { + const index = this.queuedFollowUps.findIndex((submission) => this.ids.get(submission) === id); + if (index < 0 || this.queuedFollowUps[index]?.pendingInputIds?.length) return undefined; + const [submission] = this.queuedFollowUps.splice(index, 1); + this.syncView(); + return submission; + } + + move(id: number, offset: -1 | 1): boolean { + const index = this.queuedFollowUps.findIndex((submission) => this.ids.get(submission) === id); + const next = index + offset; + if (index < 0 || next < 0 || next >= this.queuedFollowUps.length) return false; + if (this.queuedFollowUps[index]?.pendingInputIds?.length || this.queuedFollowUps[next]?.pendingInputIds?.length) return false; + [this.queuedFollowUps[index], this.queuedFollowUps[next]] = [this.queuedFollowUps[next]!, this.queuedFollowUps[index]!]; + this.syncView(); + return true; + } + + get paused(): boolean { return this.pausedByUser; } + + set paused(value: boolean) { + this.pausedByUser = value; + this.syncView(); + } + hasPendingSteers(): boolean { return this.pendingSteers.length > 0; } @@ -91,7 +128,7 @@ export class InputQueue { // --- Auto-send flag --- get autoSend(): boolean { - return this.autoSendEnabled; + return this.autoSendEnabled && !this.pausedByUser; } set autoSend(value: boolean) { @@ -314,7 +351,8 @@ export class InputQueue { private syncView(): void { this.callbacks.onStateChanged({ pendingSteers: this.pendingSteers.map(({ submission }) => submission.displayInput), - queuedInputs: this.queuedFollowUps.map((submission) => submission.displayInput) + queuedInputs: this.queuedFollowUps.map((submission) => submission.displayInput), + ...(this.pausedByUser ? { paused: true } : {}) }); } } diff --git a/packages/zcode-tui/src/queued-input-view.ts b/packages/zcode-tui/src/queued-input-view.ts index c811c17..da1d209 100644 --- a/packages/zcode-tui/src/queued-input-view.ts +++ b/packages/zcode-tui/src/queued-input-view.ts @@ -24,12 +24,14 @@ function firstFitting(candidates: string[], width: number): string { export class QueuedInputView implements Component { private pendingSteers: string[] = []; private queuedInputs: string[] = []; + private paused = false; constructor(private readonly theme: ZCodeTheme) {} setState(state: QueuedInputViewState): void { this.pendingSteers = state.pendingSteers.map(oneLine).filter(Boolean); this.queuedInputs = state.queuedInputs.map(oneLine).filter(Boolean); + this.paused = state.paused === true; } render(width: number): string[] { @@ -59,7 +61,7 @@ export class QueuedInputView implements Component { const visible = this.queuedInputs.slice(-maxVisibleInputs); const summary = `${this.queuedInputs.length} ${this.queuedInputs.length === 1 ? "input" : "inputs"}`; lines.push( - ` ${this.theme.bold("Queued next turn")} ${this.theme.muted(`· ${summary}`)}`, + ` ${this.theme.bold(this.paused ? "Queue paused" : "Queued next turn")} ${this.theme.muted(`· ${summary}`)}`, ...visible.map((input) => ` ${this.theme.muted("↳")} ${this.theme.bold(input)}`) ); if (hidden > 0) lines.splice(sectionStart + 1, 0, this.theme.muted(` … ${hidden} earlier`)); diff --git a/test/input-queue.test.ts b/test/input-queue.test.ts index d0f007d..6891a30 100644 --- a/test/input-queue.test.ts +++ b/test/input-queue.test.ts @@ -34,6 +34,33 @@ function queueHarness() { } describe("TUI input queue", () => { + test("stable draft ids support reorder and removal without touching admitted input", () => { + const { queue } = queueHarness(); + queue.queueFollowUp(submission("first")); + queue.queueFollowUp(submission("second")); + queue.queueFollowUp({ ...submission("accepted"), pendingInputIds: ["runtime-owned"] }); + const [first, second, accepted] = queue.entries(); + expect(queue.move(second!.id, -1)).toBeTrue(); + expect(queue.entries().map((entry) => entry.id)).toEqual([second!.id, first!.id, accepted!.id]); + expect(queue.move(first!.id, 1)).toBeFalse(); + expect(queue.remove(accepted!.id)).toBeUndefined(); + expect(queue.remove(first!.id)?.input).toBe("first"); + expect(queue.remove(first!.id)).toBeUndefined(); + expect(queue.takeNextFollowUp()?.input).toBe("second"); + }); + + test("explicit pause survives turn resets and resume preserves recovery hold", () => { + const { queue, states } = queueHarness(); + queue.paused = true; + queue.resetAutoSend(); + expect(queue.autoSend).toBeFalse(); + expect(states.at(-1)?.paused).toBeTrue(); + queue.autoSend = false; + queue.paused = false; + expect(queue.autoSend).toBeFalse(); + queue.resetAutoSend(); + expect(queue.autoSend).toBeTrue(); + }); test("publishes every editable follow-up transition", () => { const { queue, states } = queueHarness(); diff --git a/test/tui/fixtures/input-actions.ts b/test/tui/fixtures/input-actions.ts new file mode 100644 index 0000000..262b2a9 --- /dev/null +++ b/test/tui/fixtures/input-actions.ts @@ -0,0 +1,17 @@ +import { runTui } from "../../../packages/zcode-tui/src/index.ts"; +let history = [ + { role: "user", messageId: "question-1", content: "Original question" }, + { role: "agent", messageId: "answer-1", content: "Original answer" } +]; +await runTui({ + initialModel: "scenario/model", workspaceDirectory: process.cwd(), + loadSessionTranscript: async () => history, + readClipboardImage: async () => ({ dataUrl: "data:image/png;base64,aW1hZ2U=", mediaType: "image/png" }), + submitPrompt: async (input) => { + if (String(input) === "/rewind cascade conversation question-1") { + history = []; + return { response: "Rewound", resetSessionProjection: true, restoredMessages: [] }; + } + return { response: `Sent: ${String(input)}` }; + } +}); diff --git a/test/tui/input-actions.test.ts b/test/tui/input-actions.test.ts new file mode 100644 index 0000000..f12f032 --- /dev/null +++ b/test/tui/input-actions.test.ts @@ -0,0 +1,69 @@ +import { test } from "bun:test"; +import { join } from "node:path"; +import { ScenarioWorkspace } from "./harness/scenario-workspace.ts"; +import { TerminalSession } from "./harness/terminal-session.ts"; + +test("queue supports pausing, reordering and editing drafts through the keyboard", async () => { + await using workspace = await ScenarioWorkspace.create({ prefix: "zcode-input-actions-" }); + await using session = TerminalSession.start({ command: [process.execPath, join(import.meta.dir, "fixtures/input-actions.ts")], workspace }); + await session.waitForScreen("ready", /Original answer/u); + await session.sendAndWait("/queue pause\r", "paused", /Queue paused/u); + session.send("First draft"); + await session.settle(); + session.send("\t"); + await session.waitForScreen("first queued", /1 input/u); + session.send("Second draft"); + await session.settle(); + session.send("\t"); + await session.waitForScreen("second queued", /2 inputs/u); + await session.sendAndWait("/queue\r", "queue picker", /Select a draft/u); + session.send("\x1b[B\r"); + await session.waitForScreen("draft actions", /Move earlier/u); + session.send("\x1b[B\r"); + await session.waitForScreen("reordered picker", /Select a draft/u); + session.send("\r"); + await session.waitForScreen("draft actions again", /Move to editor/u); + session.send("\r"); + await session.waitForScreen("draft restored", /Second draft/u); + await session.assertScreenExcludes("nothing submitted", /Sent: Second draft/u); + session.send("\x03"); + await session.settle(); + await session.sendAndWait("/queue resume\r", "remaining input sent", /Sent: First draft/u); + await session.exit(); +}, 20_000); + +test.each(["/edit-message", "/retry"])("%s confirms conversation rewind before editing or resending", async (command) => { + await using workspace = await ScenarioWorkspace.create({ prefix: "zcode-message-action-" }); + await using session = TerminalSession.start({ command: [process.execPath, join(import.meta.dir, "fixtures/input-actions.ts")], workspace }); + await session.waitForScreen("ready", /Original answer/u); + await session.sendAndWait(`${command}\r`, "question picker", /Select the question/u); + session.send("\r"); + await session.waitForScreen("explicit history scope", /Later conversation turns will be removed/u); + session.send("\x1b[B\r"); + if (command === "/retry") await session.waitForHistory("question retried", /Sent: Original question/u); + else { + await session.waitForHistory("rewind completed", /Conversation rewound\. The selected input was restored to the editor/u); + await session.waitForScreen("question restored", /Original question/u); + await session.assertScreenExcludes("edit does not submit", /Sent: Original question/u); + session.send("\x03"); + await session.settle(); + } + await session.exit(); +}, 20_000); + +test("pending images cannot silently move into another queued or retried question", async () => { + await using workspace = await ScenarioWorkspace.create({ prefix: "zcode-input-images-" }); + await using session = TerminalSession.start({ command: [process.execPath, join(import.meta.dir, "fixtures/input-actions.ts")], workspace }); + await session.waitForScreen("ready", /Original answer/u); + await session.sendAndWait("/queue pause\r", "paused", /Queue paused/u); + await session.sendAndWait("/paste-image\r", "attached", /1 image attached/u); + session.send("Image question"); + await session.settle(); + session.send("\t"); + await session.waitForScreen("image cannot queue", /Send or remove pending images before queueing/u); + session.send("\x03"); + await session.settle(); + await session.sendAndWait("/retry\r", "retry guarded", /Save or clear the current draft/u); + await session.assertScreenExcludes("no retry sent", /Sent: Original question/u); + await session.exit(); +}, 20_000); From 1b46e3de1fe5425c373b9f591c816ff7dff3d9e5 Mon Sep 17 00:00:00 2001 From: Kingsword <kingsword09@gmail.com> Date: Thu, 24 Sep 2026 11:45:56 +0800 Subject: [PATCH 2/2] fix(tui): keep editor images separate from queued drafts --- packages/zcode-tui/src/index.ts | 3 ++- test/tui/fixtures/input-actions.ts | 7 +++++++ test/tui/input-actions.test.ts | 17 +++++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/zcode-tui/src/index.ts b/packages/zcode-tui/src/index.ts index 7125137..312e10b 100644 --- a/packages/zcode-tui/src/index.ts +++ b/packages/zcode-tui/src/index.ts @@ -1779,7 +1779,8 @@ class ZCodeTui { this.inputQueue.queueFollowUp({ ...submission, recordHistory: false }); return true; } - const attachments = !steering && !input.startsWith("/") ? [...this.pendingAttachments] : []; + // Pending images belong to the editor, never to a previously queued draft. + const attachments = !queuedSubmission && !steering && !input.startsWith("/") ? [...this.pendingAttachments] : []; if (steering && !this.options.sendInput) { this.inputQueue.queueFollowUp({ ...submission, recordHistory: false }); return true; diff --git a/test/tui/fixtures/input-actions.ts b/test/tui/fixtures/input-actions.ts index 262b2a9..d3c66c3 100644 --- a/test/tui/fixtures/input-actions.ts +++ b/test/tui/fixtures/input-actions.ts @@ -8,6 +8,13 @@ await runTui({ loadSessionTranscript: async () => history, readClipboardImage: async () => ({ dataUrl: "data:image/png;base64,aW1hZ2U=", mediaType: "image/png" }), submitPrompt: async (input) => { + if (typeof input === "object" && input !== null && "attachments" in input) { + if (!("text" in input) || input.text !== "Image question") throw new Error("Unexpected editor attachment sent with queued text"); + if (JSON.stringify(input.attachments) !== JSON.stringify([{ type: "image", content: "data:image/png;base64,aW1hZ2U=", mediaType: "image/png" }])) { + throw new Error("Editor attachment was not preserved"); + } + return { response: "Sent: Image question with original image" }; + } if (String(input) === "/rewind cascade conversation question-1") { history = []; return { response: "Rewound", resetSessionProjection: true, restoredMessages: [] }; diff --git a/test/tui/input-actions.test.ts b/test/tui/input-actions.test.ts index f12f032..3ecbb07 100644 --- a/test/tui/input-actions.test.ts +++ b/test/tui/input-actions.test.ts @@ -67,3 +67,20 @@ test("pending images cannot silently move into another queued or retried questio await session.assertScreenExcludes("no retry sent", /Sent: Original question/u); await session.exit(); }, 20_000); + +test("resuming queued text preserves an image attached afterwards for the editor", async () => { + await using workspace = await ScenarioWorkspace.create({ prefix: "zcode-queued-images-" }); + await using session = TerminalSession.start({ command: [process.execPath, join(import.meta.dir, "fixtures/input-actions.ts")], workspace }); + await session.waitForScreen("ready", /Original answer/u); + await session.sendAndWait("/queue pause\r", "paused", /Queue paused/u); + session.send("Queued text"); + await session.settle(); + session.send("\t"); + await session.waitForScreen("queued", /1 input/u); + await session.sendAndWait("/paste-image\r", "attached after queue", /1 image attached/u); + await session.sendAndWait("/queue resume\r", "text sent without image", /Sent: Queued text/u); + await session.waitForScreen("image remains", /\[Image #1\]/u); + await session.sendAndWait("Image question\r", "editor image sent", /Sent: Image question with original image/u); + await session.assertScreenExcludes("sent image leaves editor", /\[Image #1\]/u); + await session.exit(); +}, 20_000);