diff --git a/README.md b/README.md
index 12535ff..dcb15b0 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
/workflows inspect workflow progress, artifacts and recovery
/tasks inspect and manage background tasks
/tasks message send guidance to a running background agent
@@ -321,6 +325,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 4983a17..16e0f00 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/dynamic-workflows.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/runtime-refresh.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/dynamic-workflows.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/runtime-refresh.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 01e7fc5..502574e 100644
--- a/packages/zcode-tui/src/index.ts
+++ b/packages/zcode-tui/src/index.ts
@@ -1385,6 +1385,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: "workflows", description: "Inspect workflow progress, results and recovery" },
{
name: "tasks",
@@ -1502,7 +1505,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();
@@ -1642,6 +1645,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 === "/workflows" || input === "/workflows list") && this.options.listWorkflowRuns) {
await this.showDynamicWorkflows();
return;
@@ -1777,7 +1795,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;
@@ -3344,11 +3363,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 {
+ 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 {
+ 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();
private readonly completedTurnIds = new Set();
private autoSendEnabled = true;
+ private pausedByUser = false;
+ private nextId = 1;
+ private readonly ids = new WeakMap();
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..d3c66c3
--- /dev/null
+++ b/test/tui/fixtures/input-actions.ts
@@ -0,0 +1,24 @@
+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 (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: [] };
+ }
+ 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..3ecbb07
--- /dev/null
+++ b/test/tui/input-actions.test.ts
@@ -0,0 +1,86 @@
+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);
+
+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);