Skip to content

Commit 96f71c2

Browse files
feat: steer a streaming answer on request (opt-in, Ctrl+Enter)
Guidance injected before the next LLM call still had to wait for the answer that was already streaming, so a long text answer could not be redirected until it finished on its own. No API can rewrite output that has already been generated, so the only way to have the guidance read sooner is to abort that one request and ask again. The session now tracks the streaming request separately from the turn. When the user asks to steer, that request is aborted, whatever the model already wrote is kept as an assistant message marked meta.interrupted, and the loop immediately issues a new request that carries the guidance. Tools are never interrupted: steering only targets the model stream, so a running command finishes first and the guidance is injected at the next request boundary. Steering is opt-in, so an existing setup keeps behaving exactly as before: - Ctrl+Enter steers that one message. - steerMode: "interrupt" makes plain Enter steer as well; the default is "queue". - session: steerActiveSession(), a per-request steer signal released once the request settles, and LlmSteeredError carrying the partial answer - session: a steered answer is not an interruption - the turn continues, the status stays completed, and the aborted request is not retried - settings: steerMode ("queue" by default) - cli: Ctrl+Enter is parsed from its CSI-u sequence, forwarded as PromptSubmission.steer, and the cut-short answer is labelled in the transcript; the footer mentions the shortcut - tests: core covers the aborted stream, the follow-up request payload, the kept partial answer and the no-op case; the cli covers the key parsing, the steering submit, and steerMode resolution - docs: configuration table and section, README and quickstart key tables Refs lessweb#113, lessweb#117
1 parent f9ff3ae commit 96f71c2

19 files changed

Lines changed: 452 additions & 23 deletions

‎README-en.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ Skills are discovered from these locations, in priority order:
8989
|------------------|----------------------------------------------------------|
9090
| `Enter` | Send the prompt |
9191
| `Enter` (busy) | Send extra guidance to the turn that is running |
92+
| `Ctrl+Enter` | Send now: cut the streaming answer short and read the prompt immediately |
9293
| `Shift+Enter` | Insert a newline (also `Ctrl+J`) |
9394
| `Ctrl+V` | Paste an image from the clipboard |
9495
| `Esc` | Interrupt the current model turn |
@@ -98,8 +99,11 @@ Skills are discovered from these locations, in priority order:
9899
While a turn is running, `Enter` no longer blocks and does not have to wait: the prompt becomes
99100
supplemental guidance, appended to the conversation right before the model's next step of that
100101
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
102+
supersede the earlier instructions. If the model is in the middle of writing an answer, press
103+
`Ctrl+Enter` to cut that answer short so the prompt is read immediately - its text stays in the
104+
conversation. Set `steerMode: "interrupt"` to make `Ctrl+Enter` the default for `Enter` as well.
105+
Up to 10 messages can wait; press `Backspace` on an empty prompt to drop the last one. Running
106+
commands are never interrupted, `Esc` still interrupts the turn immediately, and slash commands
103107
still wait for the turn to finish.
104108

105109
## Supported Models

‎README.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ Skills 会按以下优先级扫描:
8888
|---------------|--------------------|
8989
| `Enter` | 发送消息 |
9090
| `Enter`(忙碌时) | 作为补充指引发给正在执行的这一轮 |
91+
| `Ctrl+Enter` | 立即发送:截断正在输出的回答并马上读取该指令 |
9192
| `Shift+Enter` | 插入换行(也可用 `Ctrl+J`) |
9293
| `Ctrl+V` | 从剪贴板粘贴图片 |
9394
| `Esc` | 中断当前模型回复 |
@@ -96,7 +97,9 @@ Skills 会按以下优先级扫描:
9697

9798
AI 正在回复时,`Enter` 不再被拒绝,也不必等到本轮结束:消息会作为补充指引,在本轮下一次
9899
LLM 调用前作为 user 消息追加到对话中。模型因此能读到它,并结合已完成的工作修改甚至推翻之前的
99-
指令。最多可排队 10 条;输入框为空时按 `Backspace` 可移除最后一条。按 `Esc` 仍会立即中断本轮,
100+
指令。如果模型正在输出回答,按 `Ctrl+Enter` 可以截断该回答、立即读取新指令(已生成内容仍保留在
101+
对话里);把 `steerMode` 设为 `"interrupt"` 则让普通 `Enter` 也具备同样行为。最多可排队 10 条;
102+
输入框为空时按 `Backspace` 可移除最后一条。正在执行的命令不会被中断,按 `Esc` 仍会立即中断本轮,
100103
斜杠命令仍需等待本轮结束。
101104

102105
## 支持的模型

‎docs/configuration.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两
3333
| `thinkingEnabled` | boolean | 是否启用思考模式(DeepSeek V4 系列默认启用) |
3434
| `reasoningEffort` | string | 推理强度,可选 `"low"`、`"high"` 或 `"max"`(默认 `"max"`) |
3535
| `multimodal` | string | 多模态(图片)能力开关,可选 `"default"`、`"on"` 或 `"off"`(默认 `"default"`) |
36+
| `steerMode` | string | 模型正在输出时发送新指令是否同时截断该回答:`"queue"`(等到下次请求边界再注入,默认)或 `"interrupt"`(截断当前回复、立即读取新指令)。无论该配置如何,`Ctrl+Enter` 都会截断当前输出 |
3637
| `filesApiEnabled` | boolean | 是否通过 DeepSeek Files API 发送图片(默认 `false`) |
3738
| `filesApiTimeoutMs` | number | 单张图片 Files API 处理超时,默认 `60000`,最大 `600000` 毫秒 |
3839
| `fileExpiresAfterSeconds` | number | 远端文件有效期,默认 `604800` 秒 |
@@ -106,6 +107,23 @@ Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两
106107

107108
当使用的模型未内置在已知模型列表中、或其实际能力与默认判定不符时,可通过该配置覆盖。
108109

110+
#### `steerMode` — 模型输出中追加指令
111+
112+
控制模型还在输出回答时你发送新指令,是否同时截断该回答:
113+
114+
| 值 | 说明 |
115+
| ----------- | ------------------------------------------------------------ |
116+
| `queue`(默认) | 新指令进入队列,在本轮下一次 LLM 调用前注入 |
117+
| `interrupt` | 截断正在流式输出的回答,已生成的内容保留在对话中,新指令立即被读取 |
118+
119+
无论该配置如何,`Ctrl+Enter` 都会为这一条消息截断当前输出,因此无需为了偶尔的转向而改变所有指令的
120+
默认行为。如果终端不支持上报 `Ctrl+Enter`(需要 modifyOtherKeys 模式),可以把 `steerMode` 设为
121+
`"interrupt"`。
122+
123+
两种方式都会把新指令作为本轮对话中的 user 消息,模型因此可以修改甚至推翻它原本正在执行的指令。
124+
正在执行的命令不会被中断:steering 只影响模型输出流,因此工具会先执行完,新指令在下一个请求边界注入。
125+
按 `Esc` 仍会中断整个轮次。
126+
109127
#### DeepSeek Files API
110128

111129
当 `BASE_URL` 为 `https://api.deepseek.com` 时,设置 `filesApiEnabled: true` 后,Deep Code 会将图片上传到固定的 `https://api.deepseek.com/files`,并在聊天请求中使用 `file_id`。其他 API 地址不会启用该功能。上传或缓存刷新失败时,本次请求直接失败;关闭开关时图片处理逻辑保持不变。

‎docs/configuration_en.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ The following are all the top-level fields supported in `settings.json`, along w
3333
| `thinkingEnabled` | boolean | Whether to enable thinking mode (enabled by default for DeepSeek V4 series)|
3434
| `reasoningEffort` | string | Reasoning intensity: `"low"`, `"high"`, or `"max"` (default `"max"`) |
3535
| `multimodal` | string | Multimodal (image) capability override: `"default"`, `"on"`, or `"off"` (default `"default"`) |
36+
| `steerMode` | string | How a prompt sent while the model is writing is handled: `"interrupt"` (cut the answer short so the prompt is read immediately, default) or `"queue"` (wait for the next request boundary) |
3637
| `filesApiEnabled` | boolean | Send images through the DeepSeek Files API (default `false`) |
3738
| `filesApiTimeoutMs` | number | Per-image Files API timeout; defaults to `60000`, maximum `600000` ms |
3839
| `fileExpiresAfterSeconds` | number | Remote file lifetime, default `604800` seconds |
@@ -106,6 +107,25 @@ Controls whether the current model is treated as a multimodal model that accepts
106107

107108
Use this to override the default detection when your model is not in the known-model list, or when its actual capability differs from the default.
108109

110+
#### `steerMode` — Steering While the Model Is Writing
111+
112+
Controls whether a prompt sent while the model is still producing an answer also cuts that answer
113+
short:
114+
115+
| Value | Description |
116+
| ------------------- | --------------------------------------------------------------------------- |
117+
| `queue` (default) | The prompt waits and is injected before the next LLM call of the running turn |
118+
| `interrupt` | The answer that is streaming is cut short; its text is kept in the conversation and the prompt is read immediately |
119+
120+
`Ctrl+Enter` cuts the streaming answer short for that one message regardless of this setting, so
121+
steering stays available without changing the default for every prompt. On terminals that do not
122+
report `Ctrl+Enter` (it needs modifyOtherKeys mode), set `steerMode: "interrupt"` instead.
123+
124+
Either way the prompt becomes a user message of the running turn, so the model can revise or
125+
supersede the instructions it was already following. Running commands are never interrupted:
126+
steering only affects the model stream, so a tool that is executing finishes first and the
127+
prompt is injected at the next request boundary. Pressing `Esc` still interrupts the whole turn.
128+
109129
#### DeepSeek Files API
110130

111131
When `BASE_URL` is `https://api.deepseek.com`, enabling `filesApiEnabled` uploads images to the fixed `https://api.deepseek.com/files` endpoint and sends `file_id` references in chat requests. Other API endpoints do not enable this feature. An upload or cache-refresh failure fails the request; disabling the setting preserves the existing image path.

‎docs/quickstart.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ deepcode -p "总结这个项目"
108108
| ---- | ---- |
109109
| 发送消息 | `Enter` |
110110
| AI 回复中补充指令 | 输入后按 `Enter`,在下一次 LLM 调用前注入本轮 |
111+
| 立即转向(截断当前输出) | 输入后按 `Ctrl+Enter` |
111112
| 输入多行 | `Shift+Enter` 或 `Ctrl+J` |
112113
| 中断当前回复 | `Esc` |
113114
| 移除最后一条待注入指引 | 输入框为空时按 `Backspace` |

‎docs/quickstart_en.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ Before editing files, propose a plan for adding pagination to the user list.
108108
| ------ | --- |
109109
| Send message | `Enter` |
110110
| Send guidance while the AI is responding | Type it and press `Enter`; it is injected before the model's next step |
111+
| Steer immediately (cut the current answer) | Type it and press `Ctrl+Enter` |
111112
| Insert a newline | `Shift+Enter` or `Ctrl+J` |
112113
| Interrupt the current response | `Esc` |
113114
| Remove the last queued guidance | Press `Backspace` on an empty prompt |

‎packages/cli/src/tests/exec-runner.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ function createSettings(
3333
debugLogEnabled: false,
3434
telemetryEnabled: false,
3535
multimodal: "default",
36+
steerMode: "interrupt",
3637
filesApiEnabled: false,
3738
filesApiTimeoutMs: 60_000,
3839
fileExpiresAfterSeconds: 604_800,

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,17 @@ test("parseTerminalInput recognizes alternate shifted return sequences", () => {
228228
}
229229
});
230230

231+
test("parseTerminalInput recognizes ctrl+enter as a steering submit", () => {
232+
for (const sequence of ["\u001B[13;5u", "\u001B[13;5~", "\u001B[27;5;13~"]) {
233+
const { key } = parseTerminalInput(sequence);
234+
assert.equal(key.return, true);
235+
assert.equal(key.ctrl, true);
236+
assert.equal(key.shift, false);
237+
assert.equal(key.meta, false);
238+
assert.equal(getPromptReturnKeyAction(key), "steer");
239+
}
240+
});
241+
231242
test("terminal extended key helpers request and restore modifyOtherKeys mode", () => {
232243
assert.equal(enableTerminalExtendedKeys(), "\u001B[>4;1m");
233244
assert.equal(disableTerminalExtendedKeys(), "\u001B[>4;0m");

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,25 @@ test("PromptInput still submits a plain prompt while busy so App can queue it",
100100
submissions.map((submission) => submission.text),
101101
["queued prompt"]
102102
);
103+
// Plain Enter only queues: App decides whether to steer based on steerMode.
104+
assert.equal(submissions[0]?.steer, undefined);
105+
} finally {
106+
app.unmount();
107+
}
108+
});
109+
110+
test("PromptInput marks ctrl+enter as a steering submit", async () => {
111+
const harness = createHarness();
112+
const submissions: PromptSubmission[] = [];
113+
const app = renderPromptInput(harness, { busy: true, onSubmit: (submission) => submissions.push(submission) });
114+
try {
115+
await press(harness, app, "stop doing that");
116+
await press(harness, app, "\u001B[13;5u");
117+
assert.deepEqual(
118+
submissions.map((submission) => submission.text),
119+
["stop doing that"]
120+
);
121+
assert.equal(submissions[0]?.steer, true);
103122
} finally {
104123
app.unmount();
105124
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ export function MessageView({ message, collapsed, width = 80 }: MessageViewProps
8888
return <Text key={i}>{seg.body}</Text>;
8989
})
9090
: null}
91+
{message.meta?.interrupted ? <Text dimColor>— superseded by your guidance</Text> : null}
9192
</Box>
9293
</Box>
9394
);

0 commit comments

Comments
 (0)