Skip to content
Open
1 change: 1 addition & 0 deletions README-en.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ Skills are discovered from these locations, in priority order:
| `/fork` | Fork the current conversation |
| `/continue` | Continue the active conversation or pick one to resume |
| `/model` | Switch model, thinking mode, and reasoning effort |
| `/plan` | Switch the input to Plan Mode |
| `/raw` | Toggle display mode (Normal / Lite / Raw scrollback) |
| `/init` | Initialize an AGENTS.md file (LLM project instructions) |
| `/skills` | List available skills |
Expand Down
1 change: 1 addition & 0 deletions README-zh_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ Skills 会按以下优先级扫描:
| `/fork` | 从当前对话创建独立的新会话 |
| `/continue` | 继续当前对话,或选择历史对话恢复 |
| `/model` | 切换模型、思考模式和推理强度 |
| `/plan` | 切换到规划模式(Plan Mode) |
| `/raw` | 切换显示模式(Normal / Lite / Raw 滚动回溯) |
| `/init` | 初始化 AGENTS.md 文件 |
| `/skills` | 列出可用 skills |
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ Skills 会按以下优先级扫描:
| `/fork` | 从当前对话创建独立的新会话 |
| `/continue` | 继续当前对话,或选择历史对话恢复 |
| `/model` | 切换模型、思考模式和推理强度 |
| `/plan` | 切换到规划模式(Plan Mode) |
| `/raw` | 切换显示模式(Normal / Lite / Raw 滚动回溯) |
| `/init` | 初始化 AGENTS.md 文件 |
| `/skills` | 列出可用 skills |
Expand Down
5 changes: 4 additions & 1 deletion docs/plan-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,11 @@ Plan Mode 的核心规则是**只规划,不动手**。例如以下操作是**
| **1. implement this plan** | 退出 Plan Mode,自动发送实现指令,让 AI 开始按方案写代码 |
| **2. stay in Plan mode** | 保持在 Plan Mode,继续修改或完善方案 |
| **3. switch to Default mode** | 退出 Plan Mode,回到默认模式(不自动开始实现) |
| **4. clear context and implement this plan** | 以已批准方案启动干净的新会话 |

你可以用数字键 `1-3` 直接选择,也可以用 `↑/↓` 移动光标后按 `Enter` 确认。按 `Esc` 等同于选择 "stay in Plan mode"。
已批准方案会成为干净会话的第一条用户消息。新会话重新加载当前配置与 AGENTS.md,并通过精简目录重新发现可用 skills,不复制规划对话或完整 skill 内容。工作区不变,源会话可恢复,文件历史可用时继承;两个会话分别标记为 `planned` 和 `implementation`。

你可以用数字键 `1-4` 直接选择,也可以用 `↑/↓` 移动光标后按 `Enter` 确认。按 `Esc` 等同于选择 "stay in Plan mode"。

## Plan Mode 与 UpdatePlan 工具的区别

Expand Down
5 changes: 4 additions & 1 deletion docs/plan-mode_en.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,11 @@ After the plan is output, Deep Code automatically shows a choice dialog—no ext
| **1. implement this plan** | Leave Plan Mode and automatically send an implementation prompt so the AI starts coding |
| **2. stay in Plan mode** | Stay in Plan Mode to continue refining the plan |
| **3. switch to Default mode** | Leave Plan Mode and return to Default mode without starting implementation |
| **4. clear context and implement this plan** | Start a fresh session with the approved plan |

You can press `1-3` to select directly, or use `↑/↓` to move the cursor and `Enter` to confirm. Pressing `Esc` is equivalent to choosing "stay in Plan mode."
The approved plan becomes the fresh session’s first user message. It reloads current configuration and AGENTS.md and rediscovers available skills from a compact catalog, without copying planning dialogue or skill bodies. The workspace stays unchanged, the source remains resumable, file history is inherited when available, and the sessions are marked `planned` and `implementation`.

You can press `1-4` to select directly, or use `↑/↓` to move the cursor and `Enter` to confirm. Pressing `Esc` is equivalent to choosing "stay in Plan mode."

## Plan Mode vs. UpdatePlan Tool

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/tests/exec-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ function createHarness(scenario: ManagerScenario = {}) {
},
true
);
options.onProcessStdout?.(123, "process output\n");
options.onProcessStdout?.(123, "process output\n", activeId);
scenario.duringPrompt?.();
entry = createEntry(activeId, scenario.finalStatus ?? "completed", {
assistantReply: scenario.finalReply === undefined ? "final answer" : scenario.finalReply,
Expand Down
12 changes: 12 additions & 0 deletions packages/cli/src/tests/prompt-input-keys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,18 @@ test("getPlanImplementationChoice treats escape as staying in Plan Mode", () =>
assert.equal(getPlanImplementationChoice("", { escape: true, return: false }, 0), "stay");
});

test("getPlanImplementationChoice maps digit keys 1-4 to the four choices", () => {
assert.equal(getPlanImplementationChoice("1", { escape: false, return: false }, 0), "implement");
assert.equal(getPlanImplementationChoice("2", { escape: false, return: false }, 0), "stay");
assert.equal(getPlanImplementationChoice("3", { escape: false, return: false }, 0), "default");
assert.equal(getPlanImplementationChoice("4", { escape: false, return: false }, 0), "clearContext");
});

test("getPlanImplementationChoice selects the choice at the cursor on return", () => {
assert.equal(getPlanImplementationChoice("", { escape: false, return: true }, 0), "implement");
assert.equal(getPlanImplementationChoice("", { escape: false, return: true }, 3), "clearContext");
});

test("prompt return key action submits on plain enter", () => {
const { key } = parseTerminalInput("\r");
assert.equal(getPromptReturnKeyAction(key), "submit");
Expand Down
73 changes: 71 additions & 2 deletions packages/cli/src/tests/session-list.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { formatSessionTitle, filterSessions, formatSessionStatus } from "../ui";
import React from "react";
import { renderToString } from "ink";
import { formatSessionTitle, filterSessions, formatSessionStatus, getSessionBadges } from "../ui/views/SessionList";
import { SessionList } from "../ui/views/SessionList";
import { claimPlanImplementation, isActiveSessionEvent } from "../ui/core/session-events";
import type { SessionEntry } from "@vegamo/deepcode-core";

test("formatSessionTitle replaces newlines with spaces", () => {
Expand All @@ -11,6 +15,69 @@ test("formatSessionTitle truncates after normalizing whitespace", () => {
assert.equal(formatSessionTitle("one\n two three", 10), "one two th…");
});

test("plan derivation badges remain separate from long truncated titles", () => {
const [source, implementation, fork] = buildSessions([
{ id: "source", summary: "A very long plan title that must be independently truncated" },
{
id: "implementation",
summary: "A very long plan title that must be independently truncated",
derivedFrom: { kind: "plan-implementation", sessionId: "source", messageId: "plan-message" },
},
{ id: "fork", forkedFrom: { sessionId: "source", messageId: "plan-message" } },
]);

assert.equal(formatSessionTitle(source!.summary!, 20), "A very long plan tit…");
assert.deepEqual(getSessionBadges(source!, [source!, implementation!, fork!]), ["planned"]);
assert.deepEqual(getSessionBadges(implementation!, [source!, implementation!, fork!]), ["implementation"]);
assert.deepEqual(getSessionBadges(fork!, [source!, implementation!, fork!]), []);
});

test("session callbacks ignore events from inactive sessions", () => {
assert.equal(isActiveSessionEvent("implementation", "source"), false);
assert.equal(isActiveSessionEvent("implementation", "implementation"), true);
assert.equal(isActiveSessionEvent("implementation", undefined), false);
assert.equal(isActiveSessionEvent(null, "source"), false);
assert.equal(isActiveSessionEvent(null, undefined), false);
});

test("plan implementation can only be claimed once while preparation is in flight", () => {
const inFlight = { current: false };

assert.equal(claimPlanImplementation(inFlight), true);
assert.equal(claimPlanImplementation(inFlight), false);
});

test("long session titles keep lineage badges and status on the first line at 80 columns", () => {
const sessions = buildSessions([
{ id: "source", summary: "A very long plan title ".repeat(8) },
{
id: "implementation",
summary: "A very long implementation title ".repeat(8),
derivedFrom: { kind: "plan-implementation", sessionId: "source", messageId: "plan-message" },
},
]);
const output = renderToString(
React.createElement(SessionList, {
sessions,
onSelect: () => {},
onCancel: () => {},
}),
{ columns: 80 }
);
const lines = output.split("\n");
const titleLine = lines.find((line) => line.includes("[planned]"));
const implementationLine = lines.find((line) => line.includes("[implementation]"));

assert.match(titleLine ?? "", /… +\[planned\] \(done\) │$/);
assert.match(implementationLine ?? "", /… +\[implementation\] \(done\) │$/);
const plannedTimeLine = lines[lines.indexOf(titleLine ?? "") + 1] ?? "";
const implementationTimeLine = lines[lines.indexOf(implementationLine ?? "") + 1] ?? "";
assert.match(plannedTimeLine, /2026/);
assert.doesNotMatch(plannedTimeLine, /\[planned\]|\(done\)/);
assert.match(implementationTimeLine, /2026/);
assert.doesNotMatch(implementationTimeLine, /\[implementation\]|\(done\)/);
});

test("formatSessionStatus maps status values to display labels", () => {
assert.equal(formatSessionStatus("completed"), "done");
assert.equal(formatSessionStatus("processing"), "running");
Expand Down Expand Up @@ -101,7 +168,7 @@ test("filterSessions handles sessions with null fields", () => {

function buildSessions(overrides: Array<Partial<SessionEntry>>): SessionEntry[] {
return overrides.map((override, i) => ({
id: `session-${i}`,
id: override.id ?? `session-${i}`,
summary: override.summary ?? null,
assistantReply: override.assistantReply ?? null,
assistantThinking: null,
Expand All @@ -115,5 +182,7 @@ function buildSessions(overrides: Array<Partial<SessionEntry>>): SessionEntry[]
createTime: new Date().toISOString(),
updateTime: new Date().toISOString(),
processes: null,
forkedFrom: override.forkedFrom,
derivedFrom: override.derivedFrom,
}));
}
11 changes: 11 additions & 0 deletions packages/cli/src/ui/core/session-events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export function isActiveSessionEvent(activeSessionId: string | null, eventSessionId?: string): boolean {
return activeSessionId !== null && eventSessionId === activeSessionId;
}

export function claimPlanImplementation(inFlight: { current: boolean }): boolean {
if (inFlight.current) {
return false;
}
inFlight.current = true;
return true;
}
Loading
Loading