Skip to content

Commit 8e4c425

Browse files
authored
🤖 Support multiline continue messages in /compact (#255)
## Overview Refactor `/compact` command to support multiline continue messages. Everything after the first line becomes the continue message, making it much more ergonomic than the old `-c "message"` syntax. **Old syntax (still works for backwards compat):** ``` /compact -c "Continue implementing auth" ``` **New syntax:** ``` /compact Continue implementing the auth system. Make sure to add tests for edge cases. ``` ## Changes ### Parser Infrastructure - Added `rawInput` field to `SlashCommandHandlerArgs` to pass raw input with newlines preserved - Updated parser to extract raw input after command name, preserving newlines - Only trim leading spaces on first line, not newlines ### Compact Command Handler - Parse multiline continue messages from lines after the first line - Prioritize `-c` flag if present (backwards compatibility) - Otherwise use multiline content as continue message ### Tests - Added 9 new test cases for multiline parsing: - Basic multiline continue message - Multiline with `-t` flag - Multiple lines preservation - Empty lines handling - Whitespace preservation - `-c` flag takes precedence (backwards compat) - Trailing newline handling - All 23 tests passing ### Documentation - Updated `docs/context-management.md` with multiline syntax examples - Updated `docs/prompting-tips.md` to show multiline usage - Removed `-c` flag from documented syntax (but it still works) ## Benefits 1. **Ergonomics**: Much easier to type natural language without quotes/escaping 2. **Readability**: Multiline messages are easier to read and edit 3. **Consistency**: Follows natural "command on first line, args below" pattern 4. **Backwards compatible**: `-c` flag still works (just undocumented) ## Testing - ✅ All unit tests passing (511 pass) - ✅ TypeScript compilation passes - ✅ Linting passes - ✅ 9 new test cases for multiline parsing ## Files Changed - `src/utils/slashCommands/types.ts` - Add rawInput to handler args - `src/utils/slashCommands/parser.ts` - Pass rawInput to handlers - `src/utils/slashCommands/registry.ts` - Update compact handler - `src/utils/slashCommands/compact.test.ts` - Add multiline tests - `docs/context-management.md` - Update syntax and examples - `docs/prompting-tips.md` - Update examples **Net change:** +132 lines / -21 lines = +111 lines _Generated with `cmux`_
1 parent 1bd73d6 commit 8e4c425

File tree

7 files changed

+196
-22
lines changed

7 files changed

+196
-22
lines changed

docs/context-management.md

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,46 +47,60 @@ Compress conversation history using AI summarization. Replaces the conversation
4747
### Syntax
4848

4949
```
50-
/compact [-t <tokens>] [-c <message>]
50+
/compact [-t <tokens>]
51+
[continue message on subsequent lines]
5152
```
5253

5354
### Options
5455

5556
- `-t <tokens>` - Maximum output tokens for the summary (default: ~2000 words)
56-
- `-c <message>` - Continue after compaction with this user message
5757

5858
### Examples
5959

60+
**Basic compaction:**
61+
6062
```
6163
/compact
6264
```
6365

64-
Basic compaction with defaults.
66+
**Limit summary size:**
6567

6668
```
6769
/compact -t 5000
6870
```
6971

70-
Limit summary to ~5000 tokens.
72+
**Auto-continue with custom message:**
7173

7274
```
73-
/compact -c "Continue implementing the auth system"
75+
/compact
76+
Continue implementing the auth system
7477
```
7578

76-
Compact and automatically send a follow-up message after compaction completes.
79+
After compaction completes, automatically sends "Continue implementing the auth system" as a follow-up message.
80+
81+
**Multiline continue message:**
7782

7883
```
79-
/compact -t 3000 -c "Keep going"
84+
/compact
85+
Now let's refactor the middleware to use the new auth context.
86+
Make sure to add tests for the error cases.
8087
```
8188

82-
Combine token limit and auto-continue.
89+
Continue messages can span multiple lines for more detailed instructions.
90+
91+
**Combine token limit and auto-continue:**
92+
93+
```
94+
/compact -t 3000
95+
Keep working on the feature
96+
```
8397

8498
### Notes
8599

86100
- Uses the selected LLM to summarize conversation history
87101
- Preserves actionable context and specific details
88102
- **Irreversible** - original messages are replaced
89-
- Continue message is used once per compaction (not persisted)
103+
- Continue message is sent once after compaction completes (not persisted)
90104

91105
---
92106

docs/prompting-tips.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,15 @@ passes.
3535
## Aggressively prune context
3636

3737
Even though Sonnet 4.5 has up to 1M in potential context, we experience a noticeable improvement in
38-
quality when kept <100k tokens. We suggest running `/compact -c "<what you want next>"`
39-
often to keep context small. The `-c` flag will automatically send a follow up message post-compaction
40-
to keep the session flowing.
38+
quality when kept <100k tokens. We suggest running `/compact` with a continue message
39+
often to keep context small. For example:
40+
41+
```
42+
/compact
43+
<what you want next>
44+
```
45+
46+
This will automatically send a follow-up message after compaction to keep the session flowing.
4147

4248
## Keeping code clean
4349

src/utils/slashCommands/compact.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,3 +132,86 @@ it("rejects positional arguments with flags", () => {
132132
subcommand: "Unexpected argument: extra",
133133
});
134134
});
135+
136+
describe("multiline continue messages", () => {
137+
it("parses basic multiline continue message", () => {
138+
const result = parseCommand("/compact\nContinue implementing the auth system");
139+
expect(result).toEqual({
140+
type: "compact",
141+
maxOutputTokens: undefined,
142+
continueMessage: "Continue implementing the auth system",
143+
});
144+
});
145+
146+
it("parses multiline with -t flag", () => {
147+
const result = parseCommand("/compact -t 5000\nKeep working on the feature");
148+
expect(result).toEqual({
149+
type: "compact",
150+
maxOutputTokens: 5000,
151+
continueMessage: "Keep working on the feature",
152+
});
153+
});
154+
155+
it("parses multiline message with multiple lines", () => {
156+
const result = parseCommand("/compact\nLine 1\nLine 2\nLine 3");
157+
expect(result).toEqual({
158+
type: "compact",
159+
maxOutputTokens: undefined,
160+
continueMessage: "Line 1\nLine 2\nLine 3",
161+
});
162+
});
163+
164+
it("handles empty lines in multiline message", () => {
165+
const result = parseCommand("/compact\n\nContinue after empty line");
166+
expect(result).toEqual({
167+
type: "compact",
168+
maxOutputTokens: undefined,
169+
continueMessage: "Continue after empty line",
170+
});
171+
});
172+
173+
it("preserves whitespace in multiline content", () => {
174+
const result = parseCommand("/compact\n Indented message\n More indented");
175+
expect(result).toEqual({
176+
type: "compact",
177+
maxOutputTokens: undefined,
178+
continueMessage: "Indented message\n More indented",
179+
});
180+
});
181+
182+
it("prioritizes -c flag over multiline content (backwards compat)", () => {
183+
const result = parseCommand('/compact -c "Flag message"\nMultiline message');
184+
expect(result).toEqual({
185+
type: "compact",
186+
maxOutputTokens: undefined,
187+
continueMessage: "Flag message",
188+
});
189+
});
190+
191+
it("handles -c flag with multiline (flag wins)", () => {
192+
const result = parseCommand('/compact -t 3000 -c "Keep going"\nThis should be ignored');
193+
expect(result).toEqual({
194+
type: "compact",
195+
maxOutputTokens: 3000,
196+
continueMessage: "Keep going",
197+
});
198+
});
199+
200+
it("ignores trailing newlines", () => {
201+
const result = parseCommand("/compact\nContinue here\n\n\n");
202+
expect(result).toEqual({
203+
type: "compact",
204+
maxOutputTokens: undefined,
205+
continueMessage: "Continue here",
206+
});
207+
});
208+
209+
it("returns undefined continueMessage when only whitespace after command", () => {
210+
const result = parseCommand("/compact\n \n \n");
211+
expect(result).toEqual({
212+
type: "compact",
213+
maxOutputTokens: undefined,
214+
continueMessage: undefined,
215+
});
216+
});
217+
});

src/utils/slashCommands/parser.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export function parseCommand(input: string): ParsedCommand {
1919
}
2020

2121
// Remove leading slash and split by spaces (respecting quotes)
22+
// Parse tokens from full input to support multi-line commands like /providers
2223
const parts = (trimmed.substring(1).match(/(?:[^\s"]+|"[^"]*")+/g) ?? []) as string[];
2324
if (parts.length === 0) {
2425
return null;
@@ -64,11 +65,23 @@ export function parseCommand(input: string): ParsedCommand {
6465

6566
const cleanRemainingTokens = remainingTokens.map((token) => token.replace(/^"(.*)"$/, "$1"));
6667

68+
// Calculate rawInput: everything after the command key, preserving newlines
69+
// For "/compact -t 5000\nContinue here", rawInput should be "-t 5000\nContinue here"
70+
// For "/compact\nContinue here", rawInput should be "\nContinue here"
71+
// We trim leading spaces on the first line only, not newlines
72+
const commandKeyWithSlash = `/${commandKey}`;
73+
let rawInput = trimmed.substring(commandKeyWithSlash.length);
74+
// Only trim spaces at the start, not newlines
75+
while (rawInput.startsWith(" ")) {
76+
rawInput = rawInput.substring(1);
77+
}
78+
6779
return targetDefinition.handler({
6880
definition: targetDefinition,
6981
path,
7082
remainingTokens,
7183
cleanRemainingTokens,
84+
rawInput,
7285
});
7386
}
7487

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/**
2+
* Tests to ensure multiline support doesn't break other commands
3+
*/
4+
import { parseCommand } from "./parser";
5+
6+
describe("parser multiline compatibility", () => {
7+
it("allows /providers with newlines in value", () => {
8+
const result = parseCommand("/providers set anthropic apiKey\nsk-123");
9+
expect(result).toEqual({
10+
type: "providers-set",
11+
provider: "anthropic",
12+
keyPath: ["apiKey"],
13+
value: "sk-123",
14+
});
15+
});
16+
17+
it("allows /providers with newlines between args", () => {
18+
const result = parseCommand("/providers\nset\nanthropic\napiKey\nsk-456");
19+
expect(result).toEqual({
20+
type: "providers-set",
21+
provider: "anthropic",
22+
keyPath: ["apiKey"],
23+
value: "sk-456",
24+
});
25+
});
26+
27+
it("allows /model with newlines", () => {
28+
const result = parseCommand("/model\nopus");
29+
expect(result).toEqual({
30+
type: "model-set",
31+
modelString: "anthropic:claude-opus-4-1",
32+
});
33+
});
34+
35+
it("allows /truncate with newlines", () => {
36+
const result = parseCommand("/truncate\n50");
37+
expect(result).toEqual({
38+
type: "truncate",
39+
percentage: 0.5,
40+
});
41+
});
42+
});

src/utils/slashCommands/registry.ts

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -170,9 +170,16 @@ const truncateCommandDefinition: SlashCommandDefinition = {
170170
const compactCommandDefinition: SlashCommandDefinition = {
171171
key: "compact",
172172
description:
173-
"Compact conversation history using AI summarization. Use -t <tokens> to set max output tokens, -c <message> to continue with custom prompt after compaction",
174-
handler: ({ cleanRemainingTokens }): ParsedCommand => {
175-
// Parse flags using minimist
173+
"Compact conversation history using AI summarization. Use -t <tokens> to set max output tokens. Add continue message on lines after the command.",
174+
handler: ({ cleanRemainingTokens, rawInput }): ParsedCommand => {
175+
// Split rawInput into first line (for flags) and remaining lines (for multiline continue)
176+
// rawInput format: "-t 5000\nContinue here" or "\nContinue here" (starts with newline if no flags)
177+
const hasMultilineContent = rawInput.includes("\n");
178+
const lines = rawInput.split("\n");
179+
// Note: firstLine could be empty string if rawInput starts with \n (which is fine)
180+
const remainingLines = lines.slice(1).join("\n").trim();
181+
182+
// Parse flags from first line using minimist
176183
const parsed = minimist(cleanRemainingTokens, {
177184
string: ["t", "c"],
178185
unknown: (arg: string) => {
@@ -210,20 +217,28 @@ const compactCommandDefinition: SlashCommandDefinition = {
210217
maxOutputTokens = tokens;
211218
}
212219

213-
// Reject extra positional arguments
214-
if (parsed._.length > 0) {
220+
// Reject extra positional arguments UNLESS they're from multiline content
221+
// (multiline content gets parsed as positional args by minimist since newlines become spaces)
222+
if (parsed._.length > 0 && !hasMultilineContent) {
215223
return {
216224
type: "unknown-command",
217225
command: "compact",
218226
subcommand: `Unexpected argument: ${parsed._[0]}`,
219227
};
220228
}
221229

222-
// Get continue message if -c flag present
223-
const continueMessage =
224-
parsed.c !== undefined && typeof parsed.c === "string" && parsed.c.trim().length > 0
225-
? parsed.c.trim()
226-
: undefined;
230+
// Determine continue message:
231+
// 1. If -c flag present (backwards compat), use it
232+
// 2. Otherwise, use multiline content (new behavior)
233+
let continueMessage: string | undefined;
234+
235+
if (parsed.c !== undefined && typeof parsed.c === "string" && parsed.c.trim().length > 0) {
236+
// -c flag takes precedence (backwards compatibility)
237+
continueMessage = parsed.c.trim();
238+
} else if (remainingLines.length > 0) {
239+
// Use multiline content
240+
continueMessage = remainingLines;
241+
}
227242

228243
return { type: "compact", maxOutputTokens, continueMessage };
229244
},

src/utils/slashCommands/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ interface SlashCommandHandlerArgs {
3939
path: readonly SlashCommandDefinition[];
4040
remainingTokens: string[];
4141
cleanRemainingTokens: string[];
42+
rawInput: string; // Raw input after command name, preserving newlines
4243
}
4344

4445
export type SlashCommandHandler = (input: SlashCommandHandlerArgs) => ParsedCommand;

0 commit comments

Comments
 (0)