Skip to content
Open
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
13 changes: 13 additions & 0 deletions README-en.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,24 @@ Skills are discovered from these locations, in priority order:
| Key | Action |
|------------------|----------------------------------------------------------|
| `Enter` | Send the prompt |
| `Enter` (busy) | Send extra guidance to the turn that is running |
| `Ctrl+Enter` | Send now: cut the streaming answer short and read the prompt immediately |
| `Shift+Enter` | Insert a newline (also `Ctrl+J`) |
| `Ctrl+V` | Paste an image from the clipboard |
| `Esc` | Interrupt the current model turn |
| `Backspace` | On an empty prompt: remove the last queued guidance |
| `Ctrl+D` twice | Quit Deep Code |

While a turn is running, `Enter` no longer blocks and does not have to wait: the prompt becomes
supplemental guidance, appended to the conversation right before the model's next step of that
turn. The model therefore reads it together with the work it already did and can revise or
supersede the earlier instructions. If the model is in the middle of writing an answer, press
`Ctrl+Enter` to cut that answer short so the prompt is read immediately - its text stays in the
conversation. Set `steerMode: "interrupt"` to make `Ctrl+Enter` the default for `Enter` as well.
Up to 20 messages can wait; press `Backspace` on an empty prompt to drop the last one. Running
commands are never interrupted, `Esc` still interrupts the turn immediately, and slash commands
still wait for the turn to finish.

## Supported Models

- `deepseek-flash` (Recommended)
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,21 @@ Skills 会按以下优先级扫描:
| 按键 | 操作 |
|---------------|--------------------|
| `Enter` | 发送消息 |
| `Enter`(忙碌时) | 作为补充指引发给正在执行的这一轮 |
| `Ctrl+Enter` | 立即发送:截断正在输出的回答并马上读取该指令 |
| `Shift+Enter` | 插入换行(也可用 `Ctrl+J`) |
| `Ctrl+V` | 从剪贴板粘贴图片 |
| `Esc` | 中断当前模型回复 |
| `Backspace` | 输入框为空时,移除最后一条待注入的指引 |
| 连续 `Ctrl+D` | 退出 |

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

## 支持的模型

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

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

#### `steerMode` — 模型输出中追加指令

控制模型还在输出回答时你发送新指令,是否同时截断该回答:

| 值 | 说明 |
| ----------- | ------------------------------------------------------------ |
| `queue`(默认) | 新指令进入队列,在本轮下一次 LLM 调用前注入 |
| `interrupt` | 截断正在流式输出的回答,已生成的内容保留在对话中,新指令立即被读取 |

无论该配置如何,`Ctrl+Enter` 都会为这一条消息截断当前输出,因此无需为了偶尔的转向而改变所有指令的
默认行为。如果终端不支持上报 `Ctrl+Enter`(需要 modifyOtherKeys 模式),可以把 `steerMode` 设为
`"interrupt"`。

两种方式都会把新指令作为本轮对话中的 user 消息,模型因此可以修改甚至推翻它原本正在执行的指令。
正在执行的命令不会被中断:steering 只影响模型输出流,因此工具会先执行完,新指令在下一个请求边界注入。
按 `Esc` 仍会中断整个轮次。

#### DeepSeek Files API

当 `BASE_URL` 为 `https://api.deepseek.com` 时,设置 `filesApiEnabled: true` 后,Deep Code 会将图片上传到固定的 `https://api.deepseek.com/files`,并在聊天请求中使用 `file_id`。其他 API 地址不会启用该功能。上传或缓存刷新失败时,本次请求直接失败;关闭开关时图片处理逻辑保持不变。
Expand Down
20 changes: 20 additions & 0 deletions docs/configuration_en.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ The following are all the top-level fields supported in `settings.json`, along w
| `thinkingEnabled` | boolean | Whether to enable thinking mode (enabled by default for DeepSeek V4 series)|
| `reasoningEffort` | string | Reasoning intensity: `"low"`, `"high"`, or `"max"` (default `"max"`) |
| `multimodal` | string | Multimodal (image) capability override: `"default"`, `"on"`, or `"off"` (default `"default"`) |
| `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) |
| `filesApiEnabled` | boolean | Send images through the DeepSeek Files API (default `false`) |
| `filesApiTimeoutMs` | number | Per-image Files API timeout; defaults to `60000`, maximum `600000` ms |
| `fileExpiresAfterSeconds` | number | Remote file lifetime, default `604800` seconds |
Expand Down Expand Up @@ -106,6 +107,25 @@ Controls whether the current model is treated as a multimodal model that accepts

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.

#### `steerMode` — Steering While the Model Is Writing

Controls whether a prompt sent while the model is still producing an answer also cuts that answer
short:

| Value | Description |
| ------------------- | --------------------------------------------------------------------------- |
| `queue` (default) | The prompt waits and is injected before the next LLM call of the running turn |
| `interrupt` | The answer that is streaming is cut short; its text is kept in the conversation and the prompt is read immediately |

`Ctrl+Enter` cuts the streaming answer short for that one message regardless of this setting, so
steering stays available without changing the default for every prompt. On terminals that do not
report `Ctrl+Enter` (it needs modifyOtherKeys mode), set `steerMode: "interrupt"` instead.

Either way the prompt becomes a user message of the running turn, so the model can revise or
supersede the instructions it was already following. Running commands are never interrupted:
steering only affects the model stream, so a tool that is executing finishes first and the
prompt is injected at the next request boundary. Pressing `Esc` still interrupts the whole turn.

#### DeepSeek Files API

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.
Expand Down
3 changes: 3 additions & 0 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,11 @@ deepcode -p "总结这个项目"
| 操作 | 用法 |
| ---- | ---- |
| 发送消息 | `Enter` |
| AI 回复中补充指令 | 输入后按 `Enter`,在下一次 LLM 调用前注入本轮 |
| 立即转向(截断当前输出) | 输入后按 `Ctrl+Enter` |
| 输入多行 | `Shift+Enter` 或 `Ctrl+J` |
| 中断当前回复 | `Esc` |
| 移除最后一条待注入指引 | 输入框为空时按 `Backspace` |
| 粘贴图片 | `Ctrl+V` |
| 退出 | 连续按两次 `Ctrl+D`,或使用 `/exit` |

Expand Down
3 changes: 3 additions & 0 deletions docs/quickstart_en.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,11 @@ Before editing files, propose a plan for adding pagination to the user list.
| Action | Key |
| ------ | --- |
| Send message | `Enter` |
| Send guidance while the AI is responding | Type it and press `Enter`; it is injected before the model's next step |
| Steer immediately (cut the current answer) | Type it and press `Ctrl+Enter` |
| Insert a newline | `Shift+Enter` or `Ctrl+J` |
| Interrupt the current response | `Esc` |
| Remove the last queued guidance | Press `Backspace` on an empty prompt |
| Paste an image | `Ctrl+V` |
| Quit | Press `Ctrl+D` twice, or use `/exit` |

Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/tests/exec-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ function createSettings(
debugLogEnabled: false,
telemetryEnabled: false,
multimodal: "default",
steerMode: "interrupt",
filesApiEnabled: false,
filesApiTimeoutMs: 60_000,
fileExpiresAfterSeconds: 604_800,
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/src/tests/prompt-input-keys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,17 @@ test("parseTerminalInput recognizes alternate shifted return sequences", () => {
}
});

test("parseTerminalInput recognizes ctrl+enter as a steering submit", () => {
for (const sequence of ["\u001B[13;5u", "\u001B[13;5~", "\u001B[27;5;13~"]) {
const { key } = parseTerminalInput(sequence);
assert.equal(key.return, true);
assert.equal(key.ctrl, true);
assert.equal(key.shift, false);
assert.equal(key.meta, false);
assert.equal(getPromptReturnKeyAction(key), "steer");
}
});

test("terminal extended key helpers request and restore modifyOtherKeys mode", () => {
assert.equal(enableTerminalExtendedKeys(), "\u001B[>4;1m");
assert.equal(disableTerminalExtendedKeys(), "\u001B[>4;0m");
Expand Down
185 changes: 185 additions & 0 deletions packages/cli/src/tests/prompt-input-queue.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import assert from "node:assert/strict";
import { Readable, Writable } from "node:stream";
import { test } from "node:test";
import { setTimeout as delay } from "node:timers/promises";
import { stripVTControlCharacters } from "node:util";
import React from "react";
import { render } from "ink";
import { PromptInput } from "../ui";
import type { PromptSubmission } from "../ui";

type Harness = ReturnType<typeof createHarness>;

function createHarness() {
const frames: string[] = [];
const stdout = Object.assign(
new Writable({
write(chunk, _encoding, callback) {
const frame = stripVTControlCharacters(chunk.toString());
if (frame.trim()) {
frames.push(frame);
}
callback();
},
}),
{ columns: 100, rows: 24, isTTY: true }
);
const stdin = Object.assign(new Readable({ read() {} }), {
isTTY: true,
setRawMode() {},
ref() {},
unref() {},
});
return { frames, stdout, stdin };
}

function renderPromptInput(
harness: Harness,
props: {
busy: boolean;
queuedPrompts?: string[];
onSubmit: (submission: PromptSubmission) => void;
onRemoveQueuedPrompt?: () => void;
}
) {
const element = React.createElement(PromptInput, {
projectRoot: process.cwd(),
skills: [],
modelConfig: { model: "deepseek-v4-flash", thinkingEnabled: true, reasoningEffort: "max" },
screenWidth: 100,
promptHistory: [],
busy: props.busy,
queuedPrompts: props.queuedPrompts,
planMode: false,
onSubmit: props.onSubmit,
onModelConfigChange: () => "",
onPlanModeChange: () => {},
onInterrupt: () => {},
onRemoveQueuedPrompt: props.onRemoveQueuedPrompt,
});

return render(element, {
stdout: harness.stdout as unknown as NodeJS.WriteStream,
stdin: harness.stdin as unknown as NodeJS.ReadStream,
debug: true,
patchConsole: false,
exitOnCtrlC: false,
});
}

async function press(harness: Harness, app: { waitUntilRenderFlush: () => Promise<void> }, data: string) {
harness.stdin.push(data);
await delay(0);
await app.waitUntilRenderFlush();
}

test("PromptInput submits a plain prompt on enter while idle", async () => {
const harness = createHarness();
const submissions: PromptSubmission[] = [];
const app = renderPromptInput(harness, { busy: false, onSubmit: (submission) => submissions.push(submission) });
try {
await press(harness, app, "first prompt");
await press(harness, app, "\r");
assert.deepEqual(
submissions.map((submission) => submission.text),
["first prompt"]
);
} finally {
app.unmount();
}
});

test("PromptInput still submits a plain prompt while busy so App can queue it", async () => {
const harness = createHarness();
const submissions: PromptSubmission[] = [];
const app = renderPromptInput(harness, { busy: true, onSubmit: (submission) => submissions.push(submission) });
try {
await press(harness, app, "queued prompt");
await press(harness, app, "\r");
assert.deepEqual(
submissions.map((submission) => submission.text),
["queued prompt"]
);
// Plain Enter only queues: App decides whether to steer based on steerMode.
assert.equal(submissions[0]?.steer, undefined);
} finally {
app.unmount();
}
});

test("PromptInput marks ctrl+enter as a steering submit", async () => {
const harness = createHarness();
const submissions: PromptSubmission[] = [];
const app = renderPromptInput(harness, { busy: true, onSubmit: (submission) => submissions.push(submission) });
try {
await press(harness, app, "stop doing that");
await press(harness, app, "\u001B[13;5u");
assert.deepEqual(
submissions.map((submission) => submission.text),
["stop doing that"]
);
assert.equal(submissions[0]?.steer, true);
} finally {
app.unmount();
}
});

test("PromptInput keeps blocking slash commands while busy", async () => {
const harness = createHarness();
const submissions: PromptSubmission[] = [];
const app = renderPromptInput(harness, { busy: true, onSubmit: (submission) => submissions.push(submission) });
try {
await press(harness, app, "/model");
await press(harness, app, "\r");
assert.deepEqual(submissions, []);
// The buffer is kept so the user can run the command once the turn finishes.
assert.match(harness.frames.at(-1) ?? "", /\/model/);
} finally {
app.unmount();
}
});

test("PromptInput still allows /exit while busy so the CLI can quit", async () => {
const harness = createHarness();
const submissions: PromptSubmission[] = [];
const app = renderPromptInput(harness, { busy: true, onSubmit: (submission) => submissions.push(submission) });
try {
await press(harness, app, "/exit");
await press(harness, app, "\r");
// App receives the command (not a prompt), so `/exit` keeps quitting the CLI
// instead of being queued as guidance for the running turn.
assert.deepEqual(
submissions.map((submission) => submission.command),
["exit"]
);
assert.equal(submissions[0]?.steer, undefined);
} finally {
app.unmount();
}
});

test("PromptInput renders pending guidance and removes the last one on backspace", async () => {
const harness = createHarness();
let removed = 0;
const app = renderPromptInput(harness, {
busy: true,
queuedPrompts: ["older prompt", "newer prompt"],
onSubmit: () => {},
onRemoveQueuedPrompt: () => {
removed += 1;
},
});
try {
await delay(0);
await app.waitUntilRenderFlush();
const frame = harness.frames.at(-1) ?? "";
assert.match(frame, /guidance 1\. older prompt/);
assert.match(frame, /guidance 2\. newer prompt/);
assert.match(frame, /2 guidance queued/);

await press(harness, app, "\u007F");
assert.equal(removed, 1);
} finally {
app.unmount();
}
});
21 changes: 21 additions & 0 deletions packages/cli/src/tests/prompt-queue.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { test } from "node:test";
import assert from "node:assert/strict";

import { formatQueueHint, formatQueuedPromptPreview } from "../ui";

test("formatQueuedPromptPreview collapses whitespace and truncates long prompts", () => {
assert.equal(formatQueuedPromptPreview(" fix the\n\nfailing test "), "fix the failing test");
assert.equal(formatQueuedPromptPreview("a".repeat(80), 0, 20), `${"a".repeat(19)}…`);
assert.equal(formatQueuedPromptPreview("", 0), "");
});

test("formatQueuedPromptPreview falls back to the image count for image-only prompts", () => {
assert.equal(formatQueuedPromptPreview("", 1), "[1 image]");
assert.equal(formatQueuedPromptPreview(" ", 2), "[2 images]");
});

test("formatQueueHint describes how many prompts wait for the next step", () => {
assert.equal(formatQueueHint(0), "");
assert.equal(formatQueueHint(1), "1 guidance queued · backspace to remove");
assert.equal(formatQueueHint(3), "3 guidance queued · backspace to remove");
});
Loading