Skip to content

Commit 49d2782

Browse files
authored
Merge pull request #386 from lessweb/add-compact-slash-command
feat(cli): add /compact command
2 parents 84f6b35 + 762758d commit 49d2782

11 files changed

Lines changed: 389 additions & 14 deletions

File tree

‎README-en.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ Skills are discovered from these locations, in priority order:
7777
| `/resume` | Choose a previous conversation to continue |
7878
| `/fork` | Fork the current conversation |
7979
| `/continue` | Continue the active conversation or pick one to resume |
80+
| `/compact` | Manually compact the current conversation context |
8081
| `/model` | Switch model, thinking mode, and reasoning effort |
8182
| `/raw` | Toggle display mode (Normal / Lite / Raw scrollback) |
8283
| `/init` | Initialize an AGENTS.md file (LLM project instructions) |

‎README-zh_CN.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ Skills 会按以下优先级扫描:
7676
| `/resume` | 选择历史对话继续 |
7777
| `/fork` | 从当前对话创建独立的新会话 |
7878
| `/continue` | 继续当前对话,或选择历史对话恢复 |
79+
| `/compact` | 手动压缩当前对话上下文 |
7980
| `/model` | 切换模型、思考模式和推理强度 |
8081
| `/raw` | 切换显示模式(Normal / Lite / Raw 滚动回溯) |
8182
| `/init` | 初始化 AGENTS.md 文件 |

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ Skills 会按以下优先级扫描:
7676
| `/resume` | 选择历史对话继续 |
7777
| `/fork` | 从当前对话创建独立的新会话 |
7878
| `/continue` | 继续当前对话,或选择历史对话恢复 |
79+
| `/compact` | 手动压缩当前对话上下文 |
7980
| `/model` | 切换模型、思考模式和推理强度 |
8081
| `/raw` | 切换显示模式(Normal / Lite / Raw 滚动回溯) |
8182
| `/init` | 初始化 AGENTS.md 文件 |
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
import assert from "node:assert/strict";
2+
import fs from "node:fs";
3+
import os from "node:os";
4+
import path from "node:path";
5+
import { syncBuiltinESMExports } from "node:module";
6+
import { PassThrough, Writable } from "node:stream";
7+
import { test, type TestContext } from "node:test";
8+
import { setTimeout } from "node:timers/promises";
9+
import { stripVTControlCharacters } from "node:util";
10+
import React from "react";
11+
import { render } from "ink";
12+
import { SessionManager, type SessionEntry, type SessionMessage } from "@vegamo/deepcode-core";
13+
import App from "../ui/views/App";
14+
15+
async function harness(t: TestContext, compact: (signal: AbortSignal) => Promise<boolean>, active = true) {
16+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-compact-command-"));
17+
const originalEnv = process.env;
18+
process.env = Object.fromEntries(Object.entries(originalEnv).filter(([key]) => !key.startsWith("DEEPCODE_")));
19+
t.mock.method(os, "homedir", () => root);
20+
syncBuiltinESMExports();
21+
fs.mkdirSync(path.join(root, ".deepcode"));
22+
fs.writeFileSync(path.join(root, ".deepcode", "settings.json"), JSON.stringify({ statusline: { enabled: false } }));
23+
const entry: SessionEntry = {
24+
id: "compact-test",
25+
summary: "Previous conversation",
26+
assistantReply: null,
27+
assistantThinking: null,
28+
assistantRefusal: null,
29+
toolCalls: null,
30+
status: "completed",
31+
failReason: null,
32+
usage: null,
33+
usagePerModel: null,
34+
activeTokens: 20000,
35+
processes: null,
36+
createTime: "2026-01-01T00:00:00Z",
37+
updateTime: "2026-01-01T00:00:00Z",
38+
};
39+
const history: SessionMessage[] = [
40+
{
41+
id: "old-prompt",
42+
sessionId: entry.id,
43+
role: "user",
44+
content: "Keep this conversation visible",
45+
contentParams: null,
46+
messageParams: null,
47+
compacted: false,
48+
visible: true,
49+
createTime: entry.createTime,
50+
updateTime: entry.updateTime,
51+
},
52+
];
53+
t.mock.method(SessionManager.prototype, "initMcpServers", async () => {});
54+
t.mock.method(SessionManager.prototype, "listSkills", async () => []);
55+
t.mock.method(SessionManager.prototype, "getActiveSessionId", () => (active ? entry.id : null));
56+
t.mock.method(SessionManager.prototype, "getSession", () => (active ? entry : null));
57+
const list = t.mock.method(SessionManager.prototype, "listSessions", () => (active ? [entry] : []));
58+
t.mock.method(SessionManager.prototype, "listSessionMessages", () => history);
59+
const prompt = t.mock.method(SessionManager.prototype, "handleUserPrompt", async () => {});
60+
const interrupt = t.mock.method(SessionManager.prototype, "interruptActiveSession", () => {});
61+
const compaction = t.mock.method(
62+
SessionManager.prototype as unknown as {
63+
compactSessionOnce(sessionId: string, signal: AbortSignal, retain: number): Promise<boolean>;
64+
},
65+
"compactSessionOnce",
66+
async (id: string, signal?: AbortSignal) => {
67+
assert.equal(id, entry.id);
68+
assert.ok(signal);
69+
const changed = await compact(signal);
70+
if (changed) entry.activeTokens = 1000;
71+
return changed;
72+
}
73+
);
74+
const frames: string[] = [];
75+
const output = Object.assign(
76+
new Writable({
77+
write(chunk, _encoding, callback) {
78+
const frame = stripVTControlCharacters(chunk.toString());
79+
if (frame.trim()) frames.push(frame);
80+
callback();
81+
},
82+
}),
83+
{ columns: 120, rows: 40, isTTY: true }
84+
);
85+
const input = Object.assign(new PassThrough(), { isTTY: true, setRawMode() {}, ref() {}, unref() {} });
86+
const app = render(
87+
React.createElement(App, {
88+
projectRoot: root,
89+
resumeSessionId: active ? entry.id : undefined,
90+
}),
91+
{
92+
stdin: input as unknown as NodeJS.ReadStream,
93+
stdout: output as unknown as NodeJS.WriteStream,
94+
stderr: output as unknown as NodeJS.WriteStream,
95+
debug: true,
96+
patchConsole: false,
97+
exitOnCtrlC: false,
98+
}
99+
);
100+
t.after(() => {
101+
app.unmount();
102+
app.cleanup();
103+
t.mock.restoreAll();
104+
syncBuiltinESMExports();
105+
process.env = originalEnv;
106+
fs.rmSync(root, { recursive: true, force: true });
107+
});
108+
async function flush() {
109+
await setTimeout(30);
110+
await app.waitUntilRenderFlush();
111+
}
112+
async function send(value: string) {
113+
input.emit("data", value);
114+
await flush();
115+
}
116+
await flush();
117+
return {
118+
app,
119+
frames,
120+
entry,
121+
list,
122+
prompt,
123+
interrupt,
124+
compaction,
125+
send,
126+
flush,
127+
async submit(value = "/compact") {
128+
await send(value);
129+
await send("\r");
130+
},
131+
latest: () => frames.at(-1) ?? "",
132+
};
133+
}
134+
135+
test("/compact preserves displayed history, refreshes usage and waits for another prompt", async (t) => {
136+
const h = await harness(t, async () => true);
137+
const refreshes = h.list.mock.callCount();
138+
await h.submit();
139+
assert.equal(h.compaction.mock.callCount(), 1);
140+
assert.equal(h.prompt.mock.callCount(), 0);
141+
assert.match(h.latest(), /> Conversation compacted\./);
142+
assert.equal(h.latest().match(/Conversation compacted\./g)?.length, 1);
143+
assert.ok(h.frames.some((frame) => frame.includes("Compacting conversation...")));
144+
assert.match(h.latest(), /1000\//);
145+
assert.ok(h.frames.some((frame) => frame.includes("Keep this conversation visible")));
146+
assert.ok(h.list.mock.callCount() > refreshes);
147+
await h.submit("Continue working");
148+
assert.equal(h.prompt.mock.callCount(), 1);
149+
assert.match(h.latest(), /> Conversation compacted\./);
150+
});
151+
152+
test("/compact reports no active conversation without starting a request", async (t) => {
153+
const h = await harness(t, async () => true, false);
154+
await h.submit();
155+
assert.match(h.latest(), /No active conversation to compact\./);
156+
assert.equal(h.compaction.mock.callCount(), 0);
157+
assert.equal(h.prompt.mock.callCount(), 0);
158+
});
159+
160+
test("/compact can be selected from the completion menu", async (t) => {
161+
const h = await harness(t, async () => true);
162+
await h.submit("/comp");
163+
assert.match(h.latest(), /Conversation compacted\./);
164+
assert.equal(h.compaction.mock.callCount(), 1);
165+
assert.equal(h.prompt.mock.callCount(), 0);
166+
});
167+
168+
test("/compact reports a no-op without claiming success", async (t) => {
169+
const h = await harness(t, async () => false);
170+
// Trailing text follows the existing behavior of parameterless commands.
171+
await h.submit("/compact extra text");
172+
assert.match(h.latest(), /Conversation was not compacted\./);
173+
assert.equal(h.compaction.mock.callCount(), 1);
174+
assert.equal(h.prompt.mock.callCount(), 0);
175+
});
176+
177+
test("/compact failures restore input and allow retry", async (t) => {
178+
let attempts = 0;
179+
const h = await harness(t, async () => {
180+
if (++attempts === 1) throw new Error("Summary request failed");
181+
return true;
182+
});
183+
await h.submit();
184+
assert.match(h.latest(), /Error: Summary request failed/);
185+
await h.submit();
186+
assert.match(h.latest(), /Conversation compacted\./);
187+
assert.doesNotMatch(h.latest(), /Summary request failed/);
188+
assert.equal(h.prompt.mock.callCount(), 0);
189+
});
190+
191+
test("Esc cancels compaction without interrupting the session; duplicate submissions are blocked", async (t) => {
192+
let signal: AbortSignal | undefined;
193+
const h = await harness(t, (current) => {
194+
signal = current;
195+
return new Promise((_resolve, reject) => {
196+
current.addEventListener("abort", () => reject(current.reason), { once: true });
197+
});
198+
});
199+
await h.submit();
200+
assert.match(h.latest(), /Compacting conversation\.\.\./);
201+
await h.submit();
202+
assert.equal(h.compaction.mock.callCount(), 1);
203+
assert.equal(h.prompt.mock.callCount(), 0);
204+
await h.send("\u001b");
205+
assert.equal(signal?.aborted, true);
206+
assert.equal(h.interrupt.mock.callCount(), 0);
207+
assert.match(h.latest(), /Compaction cancelled\./);
208+
assert.doesNotMatch(h.latest(), /Error:/);
209+
// Clear the duplicate command left in the input before submitting a normal prompt.
210+
await h.send("\u0015");
211+
await h.submit("Continue working");
212+
assert.equal(h.prompt.mock.callCount(), 1);
213+
});
214+
215+
test("unmount cancels an in-flight compaction", async (t) => {
216+
let signal: AbortSignal | undefined;
217+
const h = await harness(t, (current) => {
218+
signal = current;
219+
return new Promise((_resolve, reject) => {
220+
current.addEventListener("abort", () => reject(current.reason), { once: true });
221+
});
222+
});
223+
await h.submit();
224+
h.app.unmount();
225+
await h.flush();
226+
assert.equal(signal?.aborted, true);
227+
assert.equal(h.interrupt.mock.callCount(), 0);
228+
});
229+
230+
test("exiting cancels an in-flight compaction", async (t) => {
231+
let signal: AbortSignal | undefined;
232+
const h = await harness(t, (current) => {
233+
signal = current;
234+
return new Promise((_resolve, reject) => {
235+
current.addEventListener("abort", () => reject(current.reason), { once: true });
236+
});
237+
});
238+
await h.submit();
239+
await h.send("\u0004");
240+
await h.send("\u0004");
241+
assert.equal(signal?.aborted, true);
242+
assert.equal(h.interrupt.mock.callCount(), 0);
243+
assert.doesNotMatch(h.latest(), /Compaction cancelled\.|Error:/);
244+
});

‎packages/cli/src/tests/slash-commands.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ test("buildSlashCommands prefixes skills before built-ins", () => {
2828
"resume",
2929
"fork",
3030
"continue",
31+
"compact",
3132
"undo",
3233
"mcp",
3334
"raw",
@@ -130,6 +131,14 @@ test("findExactSlashCommand returns built-in /undo", () => {
130131
assert.equal(item?.kind, "undo");
131132
});
132133

134+
test("/compact is suggested and the built-in takes precedence over a skill with the same name", () => {
135+
const items = buildSlashCommands([{ name: "compact", path: "/skills/compact/SKILL.md", description: "A skill" }]);
136+
assert.ok(filterSlashCommands(items, "/comp").some((item) => item.kind === "compact"));
137+
const command = findExactSlashCommand(items, "/compact");
138+
assert.equal(command?.kind, "compact");
139+
assert.equal(command?.description, "Compact the current conversation");
140+
});
141+
133142
test("findExactSlashCommand returns built-in /skills", () => {
134143
const items = buildSlashCommands(skills);
135144
const item = findExactSlashCommand(items, "/skills");

‎packages/cli/src/ui/core/slash-commands.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export type SlashCommandKind =
1010
| "resume"
1111
| "fork"
1212
| "continue"
13+
| "compact"
1314
| "undo"
1415
| "mcp"
1516
| "raw"
@@ -73,6 +74,12 @@ export const BUILTIN_SLASH_COMMANDS: SlashCommandItem[] = [
7374
label: "/continue",
7475
description: "Continue the active conversation or pick one to resume",
7576
},
77+
{
78+
kind: "compact",
79+
name: "compact",
80+
label: "/compact",
81+
description: "Compact the current conversation",
82+
},
7683
{
7784
kind: "undo",
7885
name: "undo",

0 commit comments

Comments
 (0)