Skip to content
Closed
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
12 changes: 12 additions & 0 deletions README-en.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,23 @@ 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 |
| `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. When the model is in the middle of writing an answer, that
answer is cut short and whatever it already wrote stays in the conversation; set
`steerMode: "queue"` to wait for the next request boundary instead. Up to 10 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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,19 @@ Skills 会按以下优先级扫描:
| 按键 | 操作 |
|---------------|--------------------|
| `Enter` | 发送消息 |
| `Enter`(忙碌时) | 作为补充指引发给正在执行的这一轮 |
| `Shift+Enter` | 插入换行(也可用 `Ctrl+J`) |
| `Ctrl+V` | 从剪贴板粘贴图片 |
| `Esc` | 中断当前模型回复 |
| `Backspace` | 输入框为空时,移除最后一条待注入的指引 |
| 连续 `Ctrl+D` | 退出 |

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

## 支持的模型

- `deepseek-flash`(推荐使用)
Expand Down
14 changes: 14 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 | 模型正在输出时发送新指令的处理方式:`"interrupt"`(截断当前回复、立即读取新指令,默认)或 `"queue"`(等到下次请求边界再注入) |
| `filesApiEnabled` | boolean | 是否通过 DeepSeek Files API 发送图片(默认 `false`) |
| `filesApiTimeoutMs` | number | 单张图片 Files API 处理超时,默认 `60000`,最大 `600000` 毫秒 |
| `fileExpiresAfterSeconds` | number | 远端文件有效期,默认 `604800` 秒 |
Expand Down Expand Up @@ -106,6 +107,19 @@ Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两

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

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

控制模型还在输出回答时你发送新指令的处理方式:

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

两种方式都会把新指令作为本轮对话中的 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
15 changes: 15 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,20 @@ 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 what happens when you send a prompt while the model is still producing an answer:

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

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

Expand Down
2 changes: 2 additions & 0 deletions docs/quickstart_en.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,10 @@ 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 |
| 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
147 changes: 147 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,147 @@
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"]
);
} 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 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");
});
24 changes: 16 additions & 8 deletions packages/cli/src/ui/components/MessageView/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,20 @@ export function MessageView({ message, collapsed, width = 80 }: MessageViewProps
if (message.role === "user") {
const content = message.content || "(no content)";
const text = message.meta?.isAnswers ? renderMarkdown(content) : content;
return (
<PromptEchoLine
text={text}
width={width}
attachmentCount={Array.isArray(message.contentParams) ? message.contentParams.length : 0}
/>
);
const attachmentCount = Array.isArray(message.contentParams) ? message.contentParams.length : 0;

if (message.meta?.isSupplementary) {
return (
<Box flexDirection="column" marginY={0}>
<Box marginLeft={1}>
<StatusLine bulletColor="yellow" name="Guidance" params="sent while the turn was running" width={width} />
</Box>
<PromptEchoLine text={text} width={width} attachmentCount={attachmentCount} />
</Box>
);
}

return <PromptEchoLine text={text} width={width} attachmentCount={attachmentCount} />;
}

if (message.role === "assistant") {
Expand Down Expand Up @@ -81,6 +88,7 @@ export function MessageView({ message, collapsed, width = 80 }: MessageViewProps
return <Text key={i}>{seg.body}</Text>;
})
: null}
{message.meta?.interrupted ? <Text dimColor>— superseded by your guidance</Text> : null}
</Box>
</Box>
);
Expand Down Expand Up @@ -169,7 +177,7 @@ function StatusLine({
params,
width,
}: {
bulletColor: "gray" | "green" | "red";
bulletColor: "gray" | "green" | "red" | "yellow";
name: string;
params: string;
width: number;
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/ui/components/MessageView/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,9 @@ export function renderMessageToStdout(message: SessionMessage, mode: RawMode): s
if (message.role === "user") {
const content = message.content || "(no content)";
const text = message.meta?.isAnswers ? renderMarkdown(content) : content;
if (message.meta?.isSupplementary) {
return `${chalk("✧")} ${chalk("Guidance")} ${chalk("sent while the turn was running")}\n${chalk(`> ${text}`)}`;
}
return chalk(`> ${text}`);
}

Expand Down
34 changes: 34 additions & 0 deletions packages/cli/src/ui/core/prompt-queue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* Presentation helpers for the supplemental prompt queue.
*
* The queue itself lives in `SessionManager` (`addSupplementaryPrompt`,
* `cancelSupplementaryPrompt`, `listPendingSupplementaryPrompts`): each queued
* prompt is appended to the conversation as a user message right before the next
* LLM call of the running turn, so the model can revise its plan instead of
* waiting for the turn to end.
*
* This module only formats what the CLI shows for the prompts that are still
* waiting for that injection point.
*/

/**
* Build the one-line label for a queued prompt. Whitespace is collapsed so
* multi-line prompts stay on a single row, and prompts made of image
* attachments only fall back to an image count.
*/
export function formatQueuedPromptPreview(text: string, imageCount = 0, maxLength = 60): string {
const collapsed = text.replace(/\s+/g, " ").trim();
const label = collapsed || (imageCount > 0 ? `[${imageCount} image${imageCount === 1 ? "" : "s"}]` : "");
if (label.length <= maxLength) {
return label;
}
return `${label.slice(0, Math.max(1, maxLength - 1)).trimEnd()}…`;
}

/** Footer hint describing how many prompts are still waiting for the next step. */
export function formatQueueHint(queuedCount: number): string {
if (queuedCount <= 0) {
return "";
}
return `${queuedCount} guidance queued · backspace to remove`;
}
1 change: 1 addition & 0 deletions packages/cli/src/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,5 @@ export {
type FileMentionToken,
} from "./core/file-mentions";
export { findExpandedThinkingId, isCollapsedThinking } from "./core/thinking-state";
export { formatQueuedPromptPreview, formatQueueHint } from "./core/prompt-queue";
export { buildExitSummaryText, buildPluginRateLimitHintText, buildResumeHintText } from "./exit-summary";
Loading