diff --git a/docs/context-management.md b/docs/context-management.md index a817faf49..dfa08bef4 100644 --- a/docs/context-management.md +++ b/docs/context-management.md @@ -47,46 +47,60 @@ Compress conversation history using AI summarization. Replaces the conversation ### Syntax ``` -/compact [-t ] [-c ] +/compact [-t ] +[continue message on subsequent lines] ``` ### Options - `-t ` - Maximum output tokens for the summary (default: ~2000 words) -- `-c ` - Continue after compaction with this user message ### Examples +**Basic compaction:** + ``` /compact ``` -Basic compaction with defaults. +**Limit summary size:** ``` /compact -t 5000 ``` -Limit summary to ~5000 tokens. +**Auto-continue with custom message:** ``` -/compact -c "Continue implementing the auth system" +/compact +Continue implementing the auth system ``` -Compact and automatically send a follow-up message after compaction completes. +After compaction completes, automatically sends "Continue implementing the auth system" as a follow-up message. + +**Multiline continue message:** ``` -/compact -t 3000 -c "Keep going" +/compact +Now let's refactor the middleware to use the new auth context. +Make sure to add tests for the error cases. ``` -Combine token limit and auto-continue. +Continue messages can span multiple lines for more detailed instructions. + +**Combine token limit and auto-continue:** + +``` +/compact -t 3000 +Keep working on the feature +``` ### Notes - Uses the selected LLM to summarize conversation history - Preserves actionable context and specific details - **Irreversible** - original messages are replaced -- Continue message is used once per compaction (not persisted) +- Continue message is sent once after compaction completes (not persisted) --- diff --git a/docs/prompting-tips.md b/docs/prompting-tips.md index 7bdc49242..f7e7e7ec9 100644 --- a/docs/prompting-tips.md +++ b/docs/prompting-tips.md @@ -35,9 +35,15 @@ passes. ## Aggressively prune context Even though Sonnet 4.5 has up to 1M in potential context, we experience a noticeable improvement in -quality when kept <100k tokens. We suggest running `/compact -c ""` -often to keep context small. The `-c` flag will automatically send a follow up message post-compaction -to keep the session flowing. +quality when kept <100k tokens. We suggest running `/compact` with a continue message +often to keep context small. For example: + +``` +/compact + +``` + +This will automatically send a follow-up message after compaction to keep the session flowing. ## Keeping code clean diff --git a/src/utils/slashCommands/compact.test.ts b/src/utils/slashCommands/compact.test.ts index 59fccb0c5..387b61d9f 100644 --- a/src/utils/slashCommands/compact.test.ts +++ b/src/utils/slashCommands/compact.test.ts @@ -132,3 +132,86 @@ it("rejects positional arguments with flags", () => { subcommand: "Unexpected argument: extra", }); }); + +describe("multiline continue messages", () => { + it("parses basic multiline continue message", () => { + const result = parseCommand("/compact\nContinue implementing the auth system"); + expect(result).toEqual({ + type: "compact", + maxOutputTokens: undefined, + continueMessage: "Continue implementing the auth system", + }); + }); + + it("parses multiline with -t flag", () => { + const result = parseCommand("/compact -t 5000\nKeep working on the feature"); + expect(result).toEqual({ + type: "compact", + maxOutputTokens: 5000, + continueMessage: "Keep working on the feature", + }); + }); + + it("parses multiline message with multiple lines", () => { + const result = parseCommand("/compact\nLine 1\nLine 2\nLine 3"); + expect(result).toEqual({ + type: "compact", + maxOutputTokens: undefined, + continueMessage: "Line 1\nLine 2\nLine 3", + }); + }); + + it("handles empty lines in multiline message", () => { + const result = parseCommand("/compact\n\nContinue after empty line"); + expect(result).toEqual({ + type: "compact", + maxOutputTokens: undefined, + continueMessage: "Continue after empty line", + }); + }); + + it("preserves whitespace in multiline content", () => { + const result = parseCommand("/compact\n Indented message\n More indented"); + expect(result).toEqual({ + type: "compact", + maxOutputTokens: undefined, + continueMessage: "Indented message\n More indented", + }); + }); + + it("prioritizes -c flag over multiline content (backwards compat)", () => { + const result = parseCommand('/compact -c "Flag message"\nMultiline message'); + expect(result).toEqual({ + type: "compact", + maxOutputTokens: undefined, + continueMessage: "Flag message", + }); + }); + + it("handles -c flag with multiline (flag wins)", () => { + const result = parseCommand('/compact -t 3000 -c "Keep going"\nThis should be ignored'); + expect(result).toEqual({ + type: "compact", + maxOutputTokens: 3000, + continueMessage: "Keep going", + }); + }); + + it("ignores trailing newlines", () => { + const result = parseCommand("/compact\nContinue here\n\n\n"); + expect(result).toEqual({ + type: "compact", + maxOutputTokens: undefined, + continueMessage: "Continue here", + }); + }); + + it("returns undefined continueMessage when only whitespace after command", () => { + const result = parseCommand("/compact\n \n \n"); + expect(result).toEqual({ + type: "compact", + maxOutputTokens: undefined, + continueMessage: undefined, + }); + }); +}); diff --git a/src/utils/slashCommands/parser.ts b/src/utils/slashCommands/parser.ts index 4e50fbe9f..8cb5a3cab 100644 --- a/src/utils/slashCommands/parser.ts +++ b/src/utils/slashCommands/parser.ts @@ -19,6 +19,7 @@ export function parseCommand(input: string): ParsedCommand { } // Remove leading slash and split by spaces (respecting quotes) + // Parse tokens from full input to support multi-line commands like /providers const parts = (trimmed.substring(1).match(/(?:[^\s"]+|"[^"]*")+/g) ?? []) as string[]; if (parts.length === 0) { return null; @@ -64,11 +65,23 @@ export function parseCommand(input: string): ParsedCommand { const cleanRemainingTokens = remainingTokens.map((token) => token.replace(/^"(.*)"$/, "$1")); + // Calculate rawInput: everything after the command key, preserving newlines + // For "/compact -t 5000\nContinue here", rawInput should be "-t 5000\nContinue here" + // For "/compact\nContinue here", rawInput should be "\nContinue here" + // We trim leading spaces on the first line only, not newlines + const commandKeyWithSlash = `/${commandKey}`; + let rawInput = trimmed.substring(commandKeyWithSlash.length); + // Only trim spaces at the start, not newlines + while (rawInput.startsWith(" ")) { + rawInput = rawInput.substring(1); + } + return targetDefinition.handler({ definition: targetDefinition, path, remainingTokens, cleanRemainingTokens, + rawInput, }); } diff --git a/src/utils/slashCommands/parser_multiline.test.ts b/src/utils/slashCommands/parser_multiline.test.ts new file mode 100644 index 000000000..3c36c2bfe --- /dev/null +++ b/src/utils/slashCommands/parser_multiline.test.ts @@ -0,0 +1,42 @@ +/** + * Tests to ensure multiline support doesn't break other commands + */ +import { parseCommand } from "./parser"; + +describe("parser multiline compatibility", () => { + it("allows /providers with newlines in value", () => { + const result = parseCommand("/providers set anthropic apiKey\nsk-123"); + expect(result).toEqual({ + type: "providers-set", + provider: "anthropic", + keyPath: ["apiKey"], + value: "sk-123", + }); + }); + + it("allows /providers with newlines between args", () => { + const result = parseCommand("/providers\nset\nanthropic\napiKey\nsk-456"); + expect(result).toEqual({ + type: "providers-set", + provider: "anthropic", + keyPath: ["apiKey"], + value: "sk-456", + }); + }); + + it("allows /model with newlines", () => { + const result = parseCommand("/model\nopus"); + expect(result).toEqual({ + type: "model-set", + modelString: "anthropic:claude-opus-4-1", + }); + }); + + it("allows /truncate with newlines", () => { + const result = parseCommand("/truncate\n50"); + expect(result).toEqual({ + type: "truncate", + percentage: 0.5, + }); + }); +}); diff --git a/src/utils/slashCommands/registry.ts b/src/utils/slashCommands/registry.ts index 759a3d4e7..a2a95bfae 100644 --- a/src/utils/slashCommands/registry.ts +++ b/src/utils/slashCommands/registry.ts @@ -170,9 +170,16 @@ const truncateCommandDefinition: SlashCommandDefinition = { const compactCommandDefinition: SlashCommandDefinition = { key: "compact", description: - "Compact conversation history using AI summarization. Use -t to set max output tokens, -c to continue with custom prompt after compaction", - handler: ({ cleanRemainingTokens }): ParsedCommand => { - // Parse flags using minimist + "Compact conversation history using AI summarization. Use -t to set max output tokens. Add continue message on lines after the command.", + handler: ({ cleanRemainingTokens, rawInput }): ParsedCommand => { + // Split rawInput into first line (for flags) and remaining lines (for multiline continue) + // rawInput format: "-t 5000\nContinue here" or "\nContinue here" (starts with newline if no flags) + const hasMultilineContent = rawInput.includes("\n"); + const lines = rawInput.split("\n"); + // Note: firstLine could be empty string if rawInput starts with \n (which is fine) + const remainingLines = lines.slice(1).join("\n").trim(); + + // Parse flags from first line using minimist const parsed = minimist(cleanRemainingTokens, { string: ["t", "c"], unknown: (arg: string) => { @@ -210,8 +217,9 @@ const compactCommandDefinition: SlashCommandDefinition = { maxOutputTokens = tokens; } - // Reject extra positional arguments - if (parsed._.length > 0) { + // Reject extra positional arguments UNLESS they're from multiline content + // (multiline content gets parsed as positional args by minimist since newlines become spaces) + if (parsed._.length > 0 && !hasMultilineContent) { return { type: "unknown-command", command: "compact", @@ -219,11 +227,18 @@ const compactCommandDefinition: SlashCommandDefinition = { }; } - // Get continue message if -c flag present - const continueMessage = - parsed.c !== undefined && typeof parsed.c === "string" && parsed.c.trim().length > 0 - ? parsed.c.trim() - : undefined; + // Determine continue message: + // 1. If -c flag present (backwards compat), use it + // 2. Otherwise, use multiline content (new behavior) + let continueMessage: string | undefined; + + if (parsed.c !== undefined && typeof parsed.c === "string" && parsed.c.trim().length > 0) { + // -c flag takes precedence (backwards compatibility) + continueMessage = parsed.c.trim(); + } else if (remainingLines.length > 0) { + // Use multiline content + continueMessage = remainingLines; + } return { type: "compact", maxOutputTokens, continueMessage }; }, diff --git a/src/utils/slashCommands/types.ts b/src/utils/slashCommands/types.ts index bfe18160f..1f0b58670 100644 --- a/src/utils/slashCommands/types.ts +++ b/src/utils/slashCommands/types.ts @@ -39,6 +39,7 @@ interface SlashCommandHandlerArgs { path: readonly SlashCommandDefinition[]; remainingTokens: string[]; cleanRemainingTokens: string[]; + rawInput: string; // Raw input after command name, preserving newlines } export type SlashCommandHandler = (input: SlashCommandHandlerArgs) => ParsedCommand;