Skip to content

Commit 5e23d3c

Browse files
2 parents 1dfe446 + fd92e6d commit 5e23d3c

12 files changed

Lines changed: 724 additions & 70 deletions

src/prompt.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -243,9 +243,9 @@ Here's an example of how your output should be structured:
243243
244244
</summary>`;
245245

246-
const SYSTEM_PROMPT_BASE = `You are an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
246+
const SYSTEM_PROMPT_BASE = `你是名叫Deep Code的交互式CLI工具,帮助用户完成软件工程任务。 Use the instructions below and the tools available to you to assist the user.
247247
248-
IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.`;
248+
重要:严禁编造任何非编程相关的 URL。对于编程链接,仅限使用:1) 用户提供的上下文;2) 你确定的官方文档主域名。在输出前,必须自查该链接是否存在于你的上下文记忆中;若不存在,请明确说明无法提供。`;
249249

250250
type PromptToolOptions = {
251251
webSearchEnabled?: boolean;

src/session.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ export type MessageMeta = {
121121
resultMd?: string;
122122
asThinking?: boolean;
123123
isSummary?: boolean;
124+
isModelChange?: boolean;
124125
skill?: SkillInfo;
125126
};
126127

@@ -598,7 +599,7 @@ The candidate skills are as follows:\n\n`;
598599
if (!fs.existsSync(root)) {
599600
return [];
600601
}
601-
let entries: fs.Dirent[] = [];
602+
let entries: fs.Dirent[];
602603
try {
603604
entries = fs.readdirSync(root, { withFileTypes: true });
604605
} catch {
@@ -772,6 +773,12 @@ The candidate skills are as follows:\n\n`;
772773
this.activeSessionId = sessionId;
773774
}
774775

776+
addSessionSystemMessage(sessionId: string, content: string, meta?: MessageMeta): void {
777+
const message = this.buildSystemMessage(sessionId, content, meta);
778+
this.appendSessionMessage(sessionId, message);
779+
this.onAssistantMessage(message, false);
780+
}
781+
775782
async handleUserPrompt(userPrompt: UserPromptContent): Promise<void> {
776783
const controller = new AbortController();
777784
this.activePromptController = controller;
@@ -1535,7 +1542,12 @@ ${skillMd}
15351542
return this.readNonEmptyFile(path.join(os.homedir(), ".deepcode", "AGENTS.md"));
15361543
}
15371544

1538-
private buildSystemMessage(sessionId: string, content: string, contentParams: unknown | null = null): SessionMessage {
1545+
private buildSystemMessage(
1546+
sessionId: string,
1547+
content: string,
1548+
contentParams: unknown | null = null,
1549+
meta?: MessageMeta
1550+
): SessionMessage {
15391551
const now = new Date().toISOString();
15401552
return {
15411553
id: crypto.randomUUID(),
@@ -1548,6 +1560,7 @@ ${skillMd}
15481560
visible: false,
15491561
createTime: now,
15501562
updateTime: now,
1563+
meta,
15511564
};
15521565
}
15531566

src/tests/dropdownMenu.test.ts

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { test } from "node:test";
2+
import assert from "node:assert/strict";
3+
import { calculateVisibleStart } from "../ui/DropdownMenu";
4+
5+
test("calculateVisibleStart centers active item when possible", () => {
6+
// 10 items, max 5 visible, active index 4 (middle)
7+
// Should show items 2-6 (start at 2)
8+
const start = calculateVisibleStart(4, 10, 5);
9+
assert.equal(start, 2);
10+
});
11+
12+
test("calculateVisibleStart handles active item at the beginning", () => {
13+
// 10 items, max 5 visible, active index 0
14+
// Should show items 0-4 (start at 0)
15+
const start = calculateVisibleStart(0, 10, 5);
16+
assert.equal(start, 0);
17+
});
18+
19+
test("calculateVisibleStart handles active item at the end", () => {
20+
// 10 items, max 5 visible, active index 9 (last)
21+
// Should show items 5-9 (start at 5)
22+
const start = calculateVisibleStart(9, 10, 5);
23+
assert.equal(start, 5);
24+
});
25+
26+
test("calculateVisibleStart handles fewer items than maxVisible", () => {
27+
// 3 items, max 5 visible, active index 1
28+
// Should show all items (start at 0)
29+
const start = calculateVisibleStart(1, 3, 5);
30+
assert.equal(start, 0);
31+
});
32+
33+
test("calculateVisibleStart handles single item", () => {
34+
// 1 item, max 5 visible, active index 0
35+
// Should start at 0
36+
const start = calculateVisibleStart(0, 1, 5);
37+
assert.equal(start, 0);
38+
});
39+
40+
test("calculateVisibleStart handles empty list", () => {
41+
// 0 items, max 5 visible, active index 0
42+
// Should start at 0
43+
const start = calculateVisibleStart(0, 0, 5);
44+
assert.equal(start, 0);
45+
});
46+
47+
test("calculateVisibleStart handles activeIndex near start with odd maxVisible", () => {
48+
// 10 items, max 7 visible (odd), active index 2
49+
// floor((7-1)/2) = 3, so 2-3 = -1, clamped to 0
50+
const start = calculateVisibleStart(2, 10, 7);
51+
assert.equal(start, 0);
52+
});
53+
54+
test("calculateVisibleStart handles activeIndex near start with even maxVisible", () => {
55+
// 10 items, max 6 visible (even), active index 2
56+
// floor((6-1)/2) = 2, so 2-2 = 0
57+
const start = calculateVisibleStart(2, 10, 6);
58+
assert.equal(start, 0);
59+
});
60+
61+
test("calculateVisibleStart keeps active item centered in middle range", () => {
62+
// 20 items, max 5 visible, active index 10
63+
// floor((5-1)/2) = 2, so 10-2 = 8
64+
const start = calculateVisibleStart(10, 20, 5);
65+
assert.equal(start, 8);
66+
});
67+
68+
test("calculateVisibleStart handles activeIndex at exact boundary", () => {
69+
// 10 items, max 5 visible, active index 2 (boundary where centering starts)
70+
// floor((5-1)/2) = 2, so 2-2 = 0
71+
const start = calculateVisibleStart(2, 10, 5);
72+
assert.equal(start, 0);
73+
});
74+
75+
test("calculateVisibleStart handles activeIndex just after boundary", () => {
76+
// 10 items, max 5 visible, active index 3
77+
// floor((5-1)/2) = 2, so 3-2 = 1
78+
const start = calculateVisibleStart(3, 10, 5);
79+
assert.equal(start, 1);
80+
});
81+
82+
test("calculateVisibleStart handles large maxVisible", () => {
83+
// 10 items, max 100 visible, active index 5
84+
// Should show all items (start at 0)
85+
const start = calculateVisibleStart(5, 10, 100);
86+
assert.equal(start, 0);
87+
});
88+
89+
test("calculateVisibleStart handles activeIndex equal to totalItems", () => {
90+
// 10 items, max 5 visible, active index 10 (out of bounds)
91+
// floor((5-1)/2) = 2, so 10-2 = 8, clamped to 5 (10-5)
92+
const start = calculateVisibleStart(10, 10, 5);
93+
assert.equal(start, 5);
94+
});
95+
96+
test("calculateVisibleStart with maxVisible of 1", () => {
97+
// 5 items, max 1 visible, active index 2
98+
// floor((1-1)/2) = 0, so 2-0 = 2, clamped to 4 (5-1)
99+
const start = calculateVisibleStart(2, 5, 1);
100+
assert.equal(start, 2);
101+
});
102+
103+
test("calculateVisibleStart with maxVisible of 1 at end", () => {
104+
// 5 items, max 1 visible, active index 4 (last)
105+
// floor((1-1)/2) = 0, so 4-0 = 4, clamped to 4 (5-1)
106+
const start = calculateVisibleStart(4, 5, 1);
107+
assert.equal(start, 4);
108+
});
109+
110+
test("calculateVisibleStart scrolling behavior - moving down", () => {
111+
// Simulate scrolling through a list
112+
// 10 items, max 5 visible
113+
114+
// Start at index 0
115+
assert.equal(calculateVisibleStart(0, 10, 5), 0);
116+
117+
// Move to index 2 (still centered)
118+
assert.equal(calculateVisibleStart(2, 10, 5), 0);
119+
120+
// Move to index 5 (window should scroll)
121+
assert.equal(calculateVisibleStart(5, 10, 5), 3);
122+
123+
// Move to index 8 (near end)
124+
assert.equal(calculateVisibleStart(8, 10, 5), 5);
125+
126+
// Move to index 9 (at end)
127+
assert.equal(calculateVisibleStart(9, 10, 5), 5);
128+
});
129+
130+
test("calculateVisibleStart scrolling behavior - moving up", () => {
131+
// Simulate scrolling up through a list
132+
// 10 items, max 5 visible
133+
134+
// Start at index 9 (end)
135+
assert.equal(calculateVisibleStart(9, 10, 5), 5);
136+
137+
// Move to index 6
138+
assert.equal(calculateVisibleStart(6, 10, 5), 4);
139+
140+
// Move to index 4 (window should scroll up)
141+
assert.equal(calculateVisibleStart(4, 10, 5), 2);
142+
143+
// Move to index 1 (near start)
144+
assert.equal(calculateVisibleStart(1, 10, 5), 0);
145+
146+
// Move to index 0 (at start)
147+
assert.equal(calculateVisibleStart(0, 10, 5), 0);
148+
});

src/tests/promptInputKeys.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,44 @@ test("parseTerminalInput recognizes ctrl+x as the image attachment clear shortcu
128128
assert.equal(isClearImageAttachmentsShortcut(input, key), true);
129129
});
130130

131+
test("parseTerminalInput recognizes ctrl+- modifyOtherKeys sequence (standard)", () => {
132+
const { input, key } = parseTerminalInput("\u001B[45;5u");
133+
assert.equal(input, "-");
134+
assert.equal(key.ctrl, true);
135+
assert.equal(key.meta, false);
136+
});
137+
138+
test("parseTerminalInput recognizes ctrl+- modifyOtherKeys sequence (extended)", () => {
139+
const { input, key } = parseTerminalInput("\u001B[27;5;45~");
140+
assert.equal(input, "-");
141+
assert.equal(key.ctrl, true);
142+
assert.equal(key.meta, false);
143+
});
144+
145+
test("parseTerminalInput recognizes raw 0x1F as ctrl+shift+- (redo)", () => {
146+
const { input, key } = parseTerminalInput("\u001F");
147+
assert.equal(input, "-");
148+
assert.equal(key.ctrl, true);
149+
assert.equal(key.shift, true);
150+
assert.equal(key.meta, false);
151+
});
152+
153+
test("parseTerminalInput recognizes ctrl+shift+- modifyOtherKeys sequence (standard)", () => {
154+
const { input, key } = parseTerminalInput("\u001B[45;6u");
155+
assert.equal(input, "-");
156+
assert.equal(key.ctrl, true);
157+
assert.equal(key.shift, true);
158+
assert.equal(key.meta, false);
159+
});
160+
161+
test("parseTerminalInput recognizes ctrl+shift+- modifyOtherKeys sequence (extended)", () => {
162+
const { input, key } = parseTerminalInput("\u001B[27;6;45~");
163+
assert.equal(input, "-");
164+
assert.equal(key.ctrl, true);
165+
assert.equal(key.shift, true);
166+
assert.equal(key.meta, false);
167+
});
168+
131169
test("formatImageAttachmentStatus formats the image count label", () => {
132170
assert.equal(formatImageAttachmentStatus(0), "");
133171
assert.equal(formatImageAttachmentStatus(1), "📎 1 image attached");

src/tests/promptUndoRedo.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { test } from "node:test";
2+
import assert from "node:assert/strict";
3+
4+
import { removeCurrentSlashToken } from "../ui";
5+
import {
6+
clearPromptUndoRedoState,
7+
createPromptUndoRedoState,
8+
recordPromptEdit,
9+
redoPromptEdit,
10+
undoPromptEdit,
11+
} from "../ui/promptUndoRedo";
12+
13+
test("prompt undo and redo restore edited buffer states", () => {
14+
const history = createPromptUndoRedoState();
15+
const empty = { text: "", cursor: 0 };
16+
const hello = { text: "hello", cursor: 5 };
17+
18+
recordPromptEdit(history, empty, hello);
19+
20+
assert.deepEqual(undoPromptEdit(history, hello), empty);
21+
assert.deepEqual(redoPromptEdit(history, empty), hello);
22+
});
23+
24+
test("prompt redo history is cleared after a new edit", () => {
25+
const history = createPromptUndoRedoState();
26+
const empty = { text: "", cursor: 0 };
27+
const first = { text: "first", cursor: 5 };
28+
const second = { text: "second", cursor: 6 };
29+
30+
recordPromptEdit(history, empty, first);
31+
assert.deepEqual(undoPromptEdit(history, first), empty);
32+
33+
recordPromptEdit(history, empty, second);
34+
35+
assert.equal(redoPromptEdit(history, second), null);
36+
});
37+
38+
test("prompt undo ignores cursor-only movement", () => {
39+
const history = createPromptUndoRedoState();
40+
const before = { text: "hello", cursor: 5 };
41+
const after = { text: "hello", cursor: 0 };
42+
43+
recordPromptEdit(history, before, after);
44+
45+
assert.equal(undoPromptEdit(history, after), null);
46+
});
47+
48+
test("clearing consumed slash token drops undo and redo history", () => {
49+
const history = createPromptUndoRedoState();
50+
const empty = { text: "", cursor: 0 };
51+
const slashCommand = { text: "/model", cursor: 6 };
52+
53+
recordPromptEdit(history, empty, slashCommand);
54+
const cleared = removeCurrentSlashToken(slashCommand);
55+
clearPromptUndoRedoState(history);
56+
57+
assert.deepEqual(cleared, { text: "", cursor: 0 });
58+
assert.equal(undoPromptEdit(history, cleared), null);
59+
assert.equal(redoPromptEdit(history, cleared), null);
60+
});

src/ui/App.tsx

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import OpenAI from "openai";
88
import {
99
SessionManager,
1010
type LlmStreamProgress,
11+
type MessageMeta,
1112
type SessionEntry,
1213
type SessionMessage,
1314
type SessionStatus,
@@ -268,12 +269,42 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R
268269
const { changed } = writeModelConfigSelection(selection, current, projectRoot);
269270
const next = resolveCurrentSettings(projectRoot);
270271
setResolvedSettings(next);
272+
271273
if (!changed) {
272274
return "Model settings unchanged";
273275
}
276+
277+
const activeSessionId = sessionManager.getActiveSessionId();
278+
const meta: MessageMeta = {
279+
isModelChange: true,
280+
};
281+
const content = `/model\n└ Set model to ${selection.model} (${selection?.thinkingEnabled ? selection?.reasoningEffort : "no thinking"})`;
282+
283+
if (activeSessionId) {
284+
sessionManager.addSessionSystemMessage(activeSessionId, content, meta);
285+
} else {
286+
const now = new Date().toISOString();
287+
setMessages((prev) => [
288+
...prev,
289+
{
290+
id: crypto.randomUUID(),
291+
sessionId: "local",
292+
role: "system" as const,
293+
content,
294+
contentParams: null,
295+
messageParams: null,
296+
compacted: false,
297+
visible: true,
298+
createTime: now,
299+
updateTime: now,
300+
meta,
301+
},
302+
]);
303+
}
304+
274305
return `Model settings updated: ${formatModelConfig(current)}${formatModelConfig(next)}`;
275306
},
276-
[projectRoot]
307+
[projectRoot, sessionManager]
277308
);
278309

279310
const handleSubmit = useCallback(

0 commit comments

Comments
 (0)