From 1e247fd6fe554df9c066693926b2f769d00a8f6f Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 14 Oct 2025 18:49:54 -0500 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=A4=96=20Support=20multiline=20contin?= =?UTF-8?q?ue=20messages=20in=20/compact=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add rawInput to parser infrastructure to preserve newlines - Update /compact handler to parse multiline continue messages - Lines after first line become the continue message - -c flag still works for backwards compatibility (undocumented) - Add 9 new test cases for multiline parsing - Update docs to show multiline syntax (removed -c from examples) Benefits: - More ergonomic: no need for quotes or escaping - More readable: natural multiline format - Fully backwards compatible: -c flag still works Example: /compact -t 5000 Continue implementing the auth system. Make sure to add tests for edge cases. --- docs/context-management.md | 32 +++++++--- docs/prompting-tips.md | 12 +++- src/utils/slashCommands/compact.test.ts | 83 +++++++++++++++++++++++++ src/utils/slashCommands/parser.ts | 17 ++++- src/utils/slashCommands/registry.ts | 29 ++++++--- src/utils/slashCommands/types.ts | 1 + 6 files changed, 153 insertions(+), 21 deletions(-) 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..6ec4b76f1 100644 --- a/src/utils/slashCommands/parser.ts +++ b/src/utils/slashCommands/parser.ts @@ -18,8 +18,11 @@ export function parseCommand(input: string): ParsedCommand { return null; } + // Extract command from first line for parsing, but preserve full input for rawInput + const firstLine = trimmed.split("\n")[0]; + // Remove leading slash and split by spaces (respecting quotes) - const parts = (trimmed.substring(1).match(/(?:[^\s"]+|"[^"]*")+/g) ?? []) as string[]; + const parts = (firstLine.substring(1).match(/(?:[^\s"]+|"[^"]*")+/g) ?? []) as string[]; if (parts.length === 0) { return null; } @@ -64,11 +67,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/registry.ts b/src/utils/slashCommands/registry.ts index 759a3d4e7..4bace4a4f 100644 --- a/src/utils/slashCommands/registry.ts +++ b/src/utils/slashCommands/registry.ts @@ -170,9 +170,15 @@ 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 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) => { @@ -219,11 +225,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; From 09990d9f0e517aad69f47cd4819d8af1ce116e3b Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 14 Oct 2025 18:55:05 -0500 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=A4=96=20Fix=20parser=20regression:?= =?UTF-8?q?=20preserve=20multiline=20support=20for=20all=20commands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit broke multiline support for commands like /providers by only parsing tokens from the first line. This commit fixes the issue: - Parse tokens from full input (as before) to support all commands - /compact handler checks for newlines in rawInput to distinguish between single-line positional args (error) vs multiline content (valid) - Add 4 new tests to ensure /providers, /model, /truncate work with newlines The fix: - Parser: parse tokens from full string (newlines treated as whitespace) - /compact: detect multiline via rawInput.includes('\n'), skip positional arg check if multiline (since multiline content appears as positional args) This preserves backwards compatibility while enabling multiline /compact. --- src/utils/slashCommands/parser.ts | 6 +-- .../slashCommands/parser_multiline.test.ts | 42 +++++++++++++++++++ src/utils/slashCommands/registry.ts | 6 ++- 3 files changed, 48 insertions(+), 6 deletions(-) create mode 100644 src/utils/slashCommands/parser_multiline.test.ts diff --git a/src/utils/slashCommands/parser.ts b/src/utils/slashCommands/parser.ts index 6ec4b76f1..8cb5a3cab 100644 --- a/src/utils/slashCommands/parser.ts +++ b/src/utils/slashCommands/parser.ts @@ -18,11 +18,9 @@ export function parseCommand(input: string): ParsedCommand { return null; } - // Extract command from first line for parsing, but preserve full input for rawInput - const firstLine = trimmed.split("\n")[0]; - // Remove leading slash and split by spaces (respecting quotes) - const parts = (firstLine.substring(1).match(/(?:[^\s"]+|"[^"]*")+/g) ?? []) as string[]; + // 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; } 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 4bace4a4f..a2a95bfae 100644 --- a/src/utils/slashCommands/registry.ts +++ b/src/utils/slashCommands/registry.ts @@ -174,6 +174,7 @@ const compactCommandDefinition: SlashCommandDefinition = { 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(); @@ -216,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",