Skip to content

Commit f9ff3ae

Browse files
feat: inject user guidance into the running turn
While a turn was running, Enter was rejected with "wait for the current response or press esc to interrupt", so sending a follow-up instruction meant waiting for the whole turn or interrupting the agent. A message queued until the end of the turn would still arrive too late to override instructions the agent had already executed. SessionManager now keeps a small per-session queue of supplemental prompts. Before every LLM call of the running turn the queue is drained: each entry becomes a user message (including images and selected skills) appended to the conversation, so the model reads it together with the work it already did and can revise or supersede the earlier instructions. When a turn would end while guidance is still queued, the loop stays alive for one more iteration instead of deferring the guidance to a later turn. - session: addSupplementaryPrompt / cancelSupplementaryPrompt / listPendingSupplementaryPrompts / countPendingSupplementaryPrompts, the onSupplementaryQueueChanged and onSupplementaryPromptInjected callbacks, and MessageMeta.isSupplementary - session: drain the queue before each request, keep the loop alive while guidance is pending, and drop the queue when a session is cleaned up - cli: prompts typed while busy are handed to the session, the prompt area lists the guidance that is still waiting, Backspace on an empty prompt removes the last entry, and injected guidance is rendered as a labelled block (also in raw mode) - tests: 7 core tests covering the queue semantics and the request payloads of a turn that receives guidance mid-flight, plus 4 Ink tests for the input wiring - docs: README and quickstart key tables Refs lessweb#113, lessweb#117
1 parent c42e68e commit f9ff3ae

15 files changed

Lines changed: 874 additions & 24 deletions

File tree

‎README-en.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,11 +88,20 @@ Skills are discovered from these locations, in priority order:
8888
| Key | Action |
8989
|------------------|----------------------------------------------------------|
9090
| `Enter` | Send the prompt |
91+
| `Enter` (busy) | Send extra guidance to the turn that is running |
9192
| `Shift+Enter` | Insert a newline (also `Ctrl+J`) |
9293
| `Ctrl+V` | Paste an image from the clipboard |
9394
| `Esc` | Interrupt the current model turn |
95+
| `Backspace` | On an empty prompt: remove the last queued guidance |
9496
| `Ctrl+D` twice | Quit Deep Code |
9597

98+
While a turn is running, `Enter` no longer blocks and does not have to wait: the prompt becomes
99+
supplemental guidance, appended to the conversation right before the model's next step of that
100+
turn. The model therefore reads it together with the work it already did and can revise or
101+
supersede the earlier instructions. Up to 10 messages can wait; press `Backspace` on an empty
102+
prompt to drop the last one. `Esc` still interrupts the turn immediately, and slash commands
103+
still wait for the turn to finish.
104+
96105
## Supported Models
97106

98107
- `deepseek-flash` (Recommended)

‎README.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,11 +87,18 @@ Skills 会按以下优先级扫描:
8787
| 按键 | 操作 |
8888
|---------------|--------------------|
8989
| `Enter` | 发送消息 |
90+
| `Enter`(忙碌时) | 作为补充指引发给正在执行的这一轮 |
9091
| `Shift+Enter` | 插入换行(也可用 `Ctrl+J`) |
9192
| `Ctrl+V` | 从剪贴板粘贴图片 |
9293
| `Esc` | 中断当前模型回复 |
94+
| `Backspace` | 输入框为空时,移除最后一条待注入的指引 |
9395
| 连续 `Ctrl+D` | 退出 |
9496

97+
AI 正在回复时,`Enter` 不再被拒绝,也不必等到本轮结束:消息会作为补充指引,在本轮下一次
98+
LLM 调用前作为 user 消息追加到对话中。模型因此能读到它,并结合已完成的工作修改甚至推翻之前的
99+
指令。最多可排队 10 条;输入框为空时按 `Backspace` 可移除最后一条。按 `Esc` 仍会立即中断本轮,
100+
斜杠命令仍需等待本轮结束。
101+
95102
## 支持的模型
96103

97104
- `deepseek-flash`(推荐使用)

‎docs/quickstart.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,10 @@ deepcode -p "总结这个项目"
107107
| 操作 | 用法 |
108108
| ---- | ---- |
109109
| 发送消息 | `Enter` |
110+
| AI 回复中补充指令 | 输入后按 `Enter`,在下一次 LLM 调用前注入本轮 |
110111
| 输入多行 | `Shift+Enter` 或 `Ctrl+J` |
111112
| 中断当前回复 | `Esc` |
113+
| 移除最后一条待注入指引 | 输入框为空时按 `Backspace` |
112114
| 粘贴图片 | `Ctrl+V` |
113115
| 退出 | 连续按两次 `Ctrl+D`,或使用 `/exit` |
114116

‎docs/quickstart_en.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,10 @@ Before editing files, propose a plan for adding pagination to the user list.
107107
| Action | Key |
108108
| ------ | --- |
109109
| Send message | `Enter` |
110+
| Send guidance while the AI is responding | Type it and press `Enter`; it is injected before the model's next step |
110111
| Insert a newline | `Shift+Enter` or `Ctrl+J` |
111112
| Interrupt the current response | `Esc` |
113+
| Remove the last queued guidance | Press `Backspace` on an empty prompt |
112114
| Paste an image | `Ctrl+V` |
113115
| Quit | Press `Ctrl+D` twice, or use `/exit` |
114116

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import assert from "node:assert/strict";
2+
import { Readable, Writable } from "node:stream";
3+
import { test } from "node:test";
4+
import { setTimeout as delay } from "node:timers/promises";
5+
import { stripVTControlCharacters } from "node:util";
6+
import React from "react";
7+
import { render } from "ink";
8+
import { PromptInput } from "../ui";
9+
import type { PromptSubmission } from "../ui";
10+
11+
type Harness = ReturnType<typeof createHarness>;
12+
13+
function createHarness() {
14+
const frames: string[] = [];
15+
const stdout = Object.assign(
16+
new Writable({
17+
write(chunk, _encoding, callback) {
18+
const frame = stripVTControlCharacters(chunk.toString());
19+
if (frame.trim()) {
20+
frames.push(frame);
21+
}
22+
callback();
23+
},
24+
}),
25+
{ columns: 100, rows: 24, isTTY: true }
26+
);
27+
const stdin = Object.assign(new Readable({ read() {} }), {
28+
isTTY: true,
29+
setRawMode() {},
30+
ref() {},
31+
unref() {},
32+
});
33+
return { frames, stdout, stdin };
34+
}
35+
36+
function renderPromptInput(
37+
harness: Harness,
38+
props: {
39+
busy: boolean;
40+
queuedPrompts?: string[];
41+
onSubmit: (submission: PromptSubmission) => void;
42+
onRemoveQueuedPrompt?: () => void;
43+
}
44+
) {
45+
const element = React.createElement(PromptInput, {
46+
projectRoot: process.cwd(),
47+
skills: [],
48+
modelConfig: { model: "deepseek-v4-flash", thinkingEnabled: true, reasoningEffort: "max" },
49+
screenWidth: 100,
50+
promptHistory: [],
51+
busy: props.busy,
52+
queuedPrompts: props.queuedPrompts,
53+
planMode: false,
54+
onSubmit: props.onSubmit,
55+
onModelConfigChange: () => "",
56+
onPlanModeChange: () => {},
57+
onInterrupt: () => {},
58+
onRemoveQueuedPrompt: props.onRemoveQueuedPrompt,
59+
});
60+
61+
return render(element, {
62+
stdout: harness.stdout as unknown as NodeJS.WriteStream,
63+
stdin: harness.stdin as unknown as NodeJS.ReadStream,
64+
debug: true,
65+
patchConsole: false,
66+
exitOnCtrlC: false,
67+
});
68+
}
69+
70+
async function press(harness: Harness, app: { waitUntilRenderFlush: () => Promise<void> }, data: string) {
71+
harness.stdin.push(data);
72+
await delay(0);
73+
await app.waitUntilRenderFlush();
74+
}
75+
76+
test("PromptInput submits a plain prompt on enter while idle", async () => {
77+
const harness = createHarness();
78+
const submissions: PromptSubmission[] = [];
79+
const app = renderPromptInput(harness, { busy: false, onSubmit: (submission) => submissions.push(submission) });
80+
try {
81+
await press(harness, app, "first prompt");
82+
await press(harness, app, "\r");
83+
assert.deepEqual(
84+
submissions.map((submission) => submission.text),
85+
["first prompt"]
86+
);
87+
} finally {
88+
app.unmount();
89+
}
90+
});
91+
92+
test("PromptInput still submits a plain prompt while busy so App can queue it", async () => {
93+
const harness = createHarness();
94+
const submissions: PromptSubmission[] = [];
95+
const app = renderPromptInput(harness, { busy: true, onSubmit: (submission) => submissions.push(submission) });
96+
try {
97+
await press(harness, app, "queued prompt");
98+
await press(harness, app, "\r");
99+
assert.deepEqual(
100+
submissions.map((submission) => submission.text),
101+
["queued prompt"]
102+
);
103+
} finally {
104+
app.unmount();
105+
}
106+
});
107+
108+
test("PromptInput keeps blocking slash commands while busy", async () => {
109+
const harness = createHarness();
110+
const submissions: PromptSubmission[] = [];
111+
const app = renderPromptInput(harness, { busy: true, onSubmit: (submission) => submissions.push(submission) });
112+
try {
113+
await press(harness, app, "/model");
114+
await press(harness, app, "\r");
115+
assert.deepEqual(submissions, []);
116+
// The buffer is kept so the user can run the command once the turn finishes.
117+
assert.match(harness.frames.at(-1) ?? "", /\/model/);
118+
} finally {
119+
app.unmount();
120+
}
121+
});
122+
123+
test("PromptInput renders pending guidance and removes the last one on backspace", async () => {
124+
const harness = createHarness();
125+
let removed = 0;
126+
const app = renderPromptInput(harness, {
127+
busy: true,
128+
queuedPrompts: ["older prompt", "newer prompt"],
129+
onSubmit: () => {},
130+
onRemoveQueuedPrompt: () => {
131+
removed += 1;
132+
},
133+
});
134+
try {
135+
await delay(0);
136+
await app.waitUntilRenderFlush();
137+
const frame = harness.frames.at(-1) ?? "";
138+
assert.match(frame, /guidance 1\. older prompt/);
139+
assert.match(frame, /guidance 2\. newer prompt/);
140+
assert.match(frame, /2 guidance queued/);
141+
142+
await press(harness, app, "\u007F");
143+
assert.equal(removed, 1);
144+
} finally {
145+
app.unmount();
146+
}
147+
});
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { test } from "node:test";
2+
import assert from "node:assert/strict";
3+
4+
import { formatQueueHint, formatQueuedPromptPreview } from "../ui";
5+
6+
test("formatQueuedPromptPreview collapses whitespace and truncates long prompts", () => {
7+
assert.equal(formatQueuedPromptPreview(" fix the\n\nfailing test "), "fix the failing test");
8+
assert.equal(formatQueuedPromptPreview("a".repeat(80), 0, 20), `${"a".repeat(19)}…`);
9+
assert.equal(formatQueuedPromptPreview("", 0), "");
10+
});
11+
12+
test("formatQueuedPromptPreview falls back to the image count for image-only prompts", () => {
13+
assert.equal(formatQueuedPromptPreview("", 1), "[1 image]");
14+
assert.equal(formatQueuedPromptPreview(" ", 2), "[2 images]");
15+
});
16+
17+
test("formatQueueHint describes how many prompts wait for the next step", () => {
18+
assert.equal(formatQueueHint(0), "");
19+
assert.equal(formatQueueHint(1), "1 guidance queued · backspace to remove");
20+
assert.equal(formatQueueHint(3), "3 guidance queued · backspace to remove");
21+
});

‎packages/cli/src/ui/components/MessageView/index.tsx‎

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,20 @@ export function MessageView({ message, collapsed, width = 80 }: MessageViewProps
2424
if (message.role === "user") {
2525
const content = message.content || "(no content)";
2626
const text = message.meta?.isAnswers ? renderMarkdown(content) : content;
27-
return (
28-
<PromptEchoLine
29-
text={text}
30-
width={width}
31-
attachmentCount={Array.isArray(message.contentParams) ? message.contentParams.length : 0}
32-
/>
33-
);
27+
const attachmentCount = Array.isArray(message.contentParams) ? message.contentParams.length : 0;
28+
29+
if (message.meta?.isSupplementary) {
30+
return (
31+
<Box flexDirection="column" marginY={0}>
32+
<Box marginLeft={1}>
33+
<StatusLine bulletColor="yellow" name="Guidance" params="sent while the turn was running" width={width} />
34+
</Box>
35+
<PromptEchoLine text={text} width={width} attachmentCount={attachmentCount} />
36+
</Box>
37+
);
38+
}
39+
40+
return <PromptEchoLine text={text} width={width} attachmentCount={attachmentCount} />;
3441
}
3542

3643
if (message.role === "assistant") {
@@ -169,7 +176,7 @@ function StatusLine({
169176
params,
170177
width,
171178
}: {
172-
bulletColor: "gray" | "green" | "red";
179+
bulletColor: "gray" | "green" | "red" | "yellow";
173180
name: string;
174181
params: string;
175182
width: number;

‎packages/cli/src/ui/components/MessageView/utils.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,9 @@ export function renderMessageToStdout(message: SessionMessage, mode: RawMode): s
230230
if (message.role === "user") {
231231
const content = message.content || "(no content)";
232232
const text = message.meta?.isAnswers ? renderMarkdown(content) : content;
233+
if (message.meta?.isSupplementary) {
234+
return `${chalk("✧")} ${chalk("Guidance")} ${chalk("sent while the turn was running")}\n${chalk(`> ${text}`)}`;
235+
}
233236
return chalk(`> ${text}`);
234237
}
235238

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* Presentation helpers for the supplemental prompt queue.
3+
*
4+
* The queue itself lives in `SessionManager` (`addSupplementaryPrompt`,
5+
* `cancelSupplementaryPrompt`, `listPendingSupplementaryPrompts`): each queued
6+
* prompt is appended to the conversation as a user message right before the next
7+
* LLM call of the running turn, so the model can revise its plan instead of
8+
* waiting for the turn to end.
9+
*
10+
* This module only formats what the CLI shows for the prompts that are still
11+
* waiting for that injection point.
12+
*/
13+
14+
/**
15+
* Build the one-line label for a queued prompt. Whitespace is collapsed so
16+
* multi-line prompts stay on a single row, and prompts made of image
17+
* attachments only fall back to an image count.
18+
*/
19+
export function formatQueuedPromptPreview(text: string, imageCount = 0, maxLength = 60): string {
20+
const collapsed = text.replace(/\s+/g, " ").trim();
21+
const label = collapsed || (imageCount > 0 ? `[${imageCount} image${imageCount === 1 ? "" : "s"}]` : "");
22+
if (label.length <= maxLength) {
23+
return label;
24+
}
25+
return `${label.slice(0, Math.max(1, maxLength - 1)).trimEnd()}…`;
26+
}
27+
28+
/** Footer hint describing how many prompts are still waiting for the next step. */
29+
export function formatQueueHint(queuedCount: number): string {
30+
if (queuedCount <= 0) {
31+
return "";
32+
}
33+
return `${queuedCount} guidance queued · backspace to remove`;
34+
}

‎packages/cli/src/ui/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,4 +98,5 @@ export {
9898
type FileMentionToken,
9999
} from "./core/file-mentions";
100100
export { findExpandedThinkingId, isCollapsedThinking } from "./core/thinking-state";
101+
export { formatQueuedPromptPreview, formatQueueHint } from "./core/prompt-queue";
101102
export { buildExitSummaryText, buildPluginRateLimitHintText, buildResumeHintText } from "./exit-summary";

0 commit comments

Comments
 (0)