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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,10 @@ picker to return to input selection, then `Esc` again to close rewind.
/status inspect detailed runtime and session status
/rename <title> 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 <id> <text> send guidance to a running background agent
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
120 changes: 118 additions & 2 deletions packages/zcode-tui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<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);
Expand Down
42 changes: 40 additions & 2 deletions packages/zcode-tui/src/input-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface PendingSteerSubmission {
export interface InputQueueState {
pendingSteers: string[];
queuedInputs: string[];
paused?: boolean;
}

export interface CommittedSteer {
Expand All @@ -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) {}

Expand Down Expand Up @@ -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;
}
Expand All @@ -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) {
Expand Down Expand Up @@ -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 } : {})
});
}
}
4 changes: 3 additions & 1 deletion packages/zcode-tui/src/queued-input-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] {
Expand Down Expand Up @@ -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`));
Expand Down
27 changes: 27 additions & 0 deletions test/input-queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
24 changes: 24 additions & 0 deletions test/tui/fixtures/input-actions.ts
Original file line number Diff line number Diff line change
@@ -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)}` };
}
});
Loading
Loading