Skip to content

Commit b6d4b2b

Browse files
committed
feat(cli): queue follow-up prompts with previews
1 parent f1d9909 commit b6d4b2b

10 files changed

Lines changed: 673 additions & 23 deletions

File tree

‎README-en.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,14 @@ Skills are discovered from these locations, in priority order:
9393
| `Esc` | Interrupt the current model turn |
9494
| `Ctrl+D` twice | Quit Deep Code |
9595

96+
### Queued prompts
97+
98+
While the assistant is working, press `Enter` to queue a follow-up prompt. Prompts run one at a time in submission order, after the active turn finishes. The input stays editable with a visible cursor, and the queue shows its count plus up to three numbered, one-line previews. Image-only and skill-only prompts also have descriptive previews.
99+
100+
Press `Esc` to interrupt the active turn and discard every waiting prompt. A new prompt intentionally submitted after `Esc` waits for the interrupted run to settle and then executes. Slash commands remain blocked while busy, except `/exit`, which exits immediately and discards the queue. Pending prompts are kept in memory for the current CLI instance.
101+
102+
![Queued prompt previews in the CLI, rendered with sample prompts](resources/prompt-queue.png)
103+
96104
## Supported Models
97105

98106
- `deepseek-flash` (Recommended)

‎README.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,14 @@ Skills 会按以下优先级扫描:
9292
| `Esc` | 中断当前模型回复 |
9393
| 连续 `Ctrl+D` | 退出 |
9494

95+
### 提示词队列
96+
97+
助手工作时,按 `Enter` 可将后续提示词加入队列。当前轮次结束后,队列中的提示词会按提交顺序逐一执行。输入框仍可编辑,光标保持可见;队列会显示待处理数量,以及最多三条带编号的单行预览。仅包含图片或 skills 的提示词也会显示对应说明。
98+
99+
按 `Esc` 会中断当前轮次并清空所有尚未执行的提示词。在 `Esc` 后主动提交的新提示词会等待被中断的任务结束,然后执行。忙碌时斜杠命令仍不可用,但 `/exit` 会立即退出并清空队列。待处理提示词仅保存在当前 CLI 进程的内存中。
100+
101+
![使用示例提示词渲染的 CLI 队列预览](resources/prompt-queue.png)
102+
95103
## 支持的模型
96104

97105
- `deepseek-flash`(推荐使用)
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import assert from "node:assert/strict";
2+
import { mkdtempSync, rmSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { PassThrough, Writable } from "node:stream";
6+
import { test } from "node:test";
7+
import { setImmediate } from "node:timers/promises";
8+
import { stripVTControlCharacters } from "node:util";
9+
import React from "react";
10+
import { render } from "ink";
11+
import stringWidth from "string-width";
12+
import { PromptInput } from "../ui/views/PromptInput";
13+
import type { PromptSubmission } from "../ui/views/PromptInput";
14+
15+
async function renderBusyInput(
16+
queuedPrompts: readonly PromptSubmission[] = [
17+
{ text: "Review the changes", imageUrls: [] },
18+
{ text: "Run the tests", imageUrls: [] },
19+
]
20+
) {
21+
const projectRoot = mkdtempSync(join(tmpdir(), "deepcode-prompt-input-"));
22+
const frames: string[] = [];
23+
const submissions: PromptSubmission[] = [];
24+
let interrupts = 0;
25+
const stdout = Object.assign(
26+
new Writable({
27+
write(chunk, _encoding, callback) {
28+
frames.push(chunk.toString());
29+
callback();
30+
},
31+
}),
32+
{ columns: 80, rows: 24, isTTY: false }
33+
);
34+
const stdin = Object.assign(new PassThrough(), {
35+
isTTY: true,
36+
setRawMode: () => stdin,
37+
ref: () => stdin,
38+
unref: () => stdin,
39+
});
40+
const props = {
41+
projectRoot,
42+
screenWidth: 80,
43+
skills: [],
44+
modelConfig: { model: "deepseek-flash", thinkingEnabled: false, reasoningEffort: "high" as const },
45+
promptHistory: [],
46+
busy: true,
47+
queuedPrompts,
48+
planMode: false,
49+
onSubmit: (submission: PromptSubmission) => submissions.push(submission),
50+
onInterrupt: () => interrupts++,
51+
onPlanModeChange: () => {},
52+
onModelConfigChange: () => "",
53+
};
54+
const app = render(React.createElement(PromptInput, props), {
55+
stdin: stdin as unknown as NodeJS.ReadStream,
56+
stdout: stdout as unknown as NodeJS.WriteStream,
57+
debug: true,
58+
patchConsole: false,
59+
exitOnCtrlC: false,
60+
});
61+
async function flush() {
62+
await setImmediate();
63+
await app.waitUntilRenderFlush();
64+
}
65+
await flush();
66+
return {
67+
get frame() {
68+
return [...frames].reverse().find((frame) => stripVTControlCharacters(frame).trim().length > 0) ?? "";
69+
},
70+
submissions,
71+
get interrupts() {
72+
return interrupts;
73+
},
74+
async input(value: string) {
75+
stdin.emit("data", value);
76+
await flush();
77+
},
78+
async updateQueue(queuedPrompts: readonly PromptSubmission[]) {
79+
app.rerender(React.createElement(PromptInput, { ...props, queuedPrompts }));
80+
await flush();
81+
},
82+
cleanup() {
83+
app.unmount();
84+
app.cleanup();
85+
stdin.destroy();
86+
stdout.destroy();
87+
rmSync(projectRoot, { recursive: true, force: true });
88+
},
89+
};
90+
}
91+
92+
test("busy prompt input accepts plain prompts and shows the queue count and composing cursor", async () => {
93+
const h = await renderBusyInput();
94+
try {
95+
await h.input("next prompt");
96+
const frame = h.frame;
97+
assert.match(stripVTControlCharacters(frame), /next prompt/);
98+
assert.match(stripVTControlCharacters(frame), /2 prompts queued/);
99+
assert.match(stripVTControlCharacters(frame), /1\. Review the changes/);
100+
assert.match(stripVTControlCharacters(frame), /2\. Run the tests/);
101+
assert.ok(frame.includes("\u001B[7m \u001B[27m"), "composing cursor remains visible while busy");
102+
await h.input("\r");
103+
assert.equal(h.submissions.length, 1);
104+
assert.equal(h.submissions[0]?.text, "next prompt");
105+
} finally {
106+
h.cleanup();
107+
}
108+
});
109+
110+
test("queued previews stay on one line, show at most three items, and update as the queue drains", async () => {
111+
const h = await renderBusyInput([
112+
{ text: `Review\nthe changes ${"long prompt ".repeat(20)}`, imageUrls: [] },
113+
{ text: "Run the tests", imageUrls: [] },
114+
{ text: "Check the build", imageUrls: [] },
115+
{ text: "Fourth prompt", imageUrls: [] },
116+
{ text: "Fifth prompt", imageUrls: [] },
117+
]);
118+
try {
119+
let frame = stripVTControlCharacters(h.frame);
120+
assert.match(frame, /5 prompts queued/);
121+
assert.match(frame, /1\. Review the changes/);
122+
assert.match(frame, /2\. Run the tests/);
123+
assert.match(frame, /3\. Check the build/);
124+
assert.match(frame, /2 more queued/);
125+
assert.doesNotMatch(frame, /Fourth prompt|Fifth prompt/);
126+
const previewLines = frame.split("\n").filter((line) => /^\d+\. /.test(line));
127+
assert.equal(previewLines.length, 3);
128+
assert.ok(previewLines.every((line) => stringWidth(line) <= 80));
129+
130+
await h.updateQueue([{ text: "Run the tests", imageUrls: [] }]);
131+
frame = stripVTControlCharacters(h.frame);
132+
assert.match(frame, /1 prompt queued/);
133+
assert.match(frame, /1\. Run the tests/);
134+
assert.doesNotMatch(frame, /Review the changes|Check the build|more queued/);
135+
136+
await h.updateQueue([]);
137+
frame = stripVTControlCharacters(h.frame);
138+
assert.doesNotMatch(frame, /queued|Run the tests/);
139+
} finally {
140+
h.cleanup();
141+
}
142+
});
143+
144+
test("busy prompt input blocks slash commands but lets /exit submit immediately", async () => {
145+
const h = await renderBusyInput();
146+
try {
147+
for (const command of ["/new", "/continue", "/init", "/model", "/plan"]) {
148+
await h.input(command);
149+
await h.input("\r");
150+
assert.deepEqual(h.submissions, [], command);
151+
assert.ok(stripVTControlCharacters(h.frame).includes(`> ${command}`), "blocked command stays in the input");
152+
await h.input("\u0015"); // Ctrl+U clears the blocked command.
153+
}
154+
await h.input("/exit");
155+
await h.input("\r");
156+
assert.deepEqual(h.submissions, [{ text: "/exit", imageUrls: [], command: "exit" }]);
157+
} finally {
158+
h.cleanup();
159+
}
160+
});
161+
162+
test("Esc interrupts a busy run and a new intentional submission is still accepted", async () => {
163+
const h = await renderBusyInput();
164+
try {
165+
await h.input("\u001B");
166+
assert.equal(h.interrupts, 1);
167+
await h.input("after interrupt");
168+
await h.input("\r");
169+
assert.equal(h.submissions[0]?.text, "after interrupt");
170+
} finally {
171+
h.cleanup();
172+
}
173+
});

‎packages/cli/src/tests/prompt-input-keys.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import {
1010
IMAGE_ATTACHMENT_CLEAR_HINT,
1111
addUniqueSkill,
1212
formatImageAttachmentStatus,
13+
formatQueuedPromptStatus,
14+
formatQueuedPromptPreview,
1315
formatSelectedSkillsStatus,
1416
getPromptCursorPlacement,
1517
getPromptReturnKeyAction,
@@ -353,6 +355,30 @@ test("formatImageAttachmentStatus formats the image count label", () => {
353355
assert.equal(IMAGE_ATTACHMENT_CLEAR_HINT, "ctrl+x clear images");
354356
});
355357

358+
test("formatQueuedPromptStatus formats the queued prompt count label", () => {
359+
assert.equal(formatQueuedPromptStatus(0), "");
360+
assert.equal(formatQueuedPromptStatus(-1), "");
361+
assert.equal(formatQueuedPromptStatus(1), "⏳ 1 prompt queued");
362+
assert.equal(formatQueuedPromptStatus(3), "⏳ 3 prompts queued");
363+
});
364+
365+
test("formatQueuedPromptPreview collapses whitespace and removes terminal control sequences", () => {
366+
assert.equal(
367+
formatQueuedPromptPreview({ text: " Review\n\t\u001B[31mthe changes\u001B[0m ", imageUrls: [] }),
368+
"Review the changes"
369+
);
370+
});
371+
372+
test("formatQueuedPromptPreview describes prompts containing only images or skills", () => {
373+
const skill: SkillInfo = { name: "skill-writer", path: "/skills/skill-writer/SKILL.md", description: "Write skills" };
374+
assert.equal(formatQueuedPromptPreview({ text: "", imageUrls: ["image"] }), "📎 1 image attached");
375+
assert.equal(formatQueuedPromptPreview({ text: "", imageUrls: [], selectedSkills: [skill] }), "⚡ skill-writer");
376+
assert.equal(
377+
formatQueuedPromptPreview({ text: "", imageUrls: ["image"], selectedSkills: [skill] }),
378+
"📎 1 image attached · ⚡ skill-writer"
379+
);
380+
});
381+
356382
test("buildInitPromptSubmission preserves manually selected skills", () => {
357383
const skill: SkillInfo = { name: "skill-writer", path: "/skills/skill-writer/SKILL.md", description: "Write skills" };
358384

0 commit comments

Comments
 (0)