From 449cf6af0a43abe67c6a6d9b684092399ac08a92 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Mon, 1 Jun 2026 05:07:57 +0100 Subject: [PATCH] feat(cli): wire Cursor /summarize and /clear slash builtins (#747) * feat(cursor): wire /summarize and /clear slash builtins for remote sessions Seed cursor builtins for web autocomplete, parse summarize/clear in cursorRemoteLauncher (pass-through to agent -p; reject /clear with args). Fixes tiann/hapi#738 Co-authored-by: Cursor * fix(cursor): isolate slash commands before message queue batching Parse summarize/clear at enqueue time (runCursor) with pushIsolateAndClear so waitForMessagesAndGetAsString never merges a slash with the next prompt. Adds queue policy tests for invalid /clear + following message. Addresses PR #747 review. Co-authored-by: Cursor * fix(cursor): preserve pending messages when isolating slash commands pushIsolateAndClear() wipes the entire queue, so a normal prompt queued before /summarize or /clear would be silently dropped. Add pushIsolated() - isolation without clearing - and route Cursor slash commands through it instead. Adds queue tests covering the preserve-then-isolate path. Addresses PR #747 review. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- cli/src/cursor/cursorRemoteLauncher.ts | 20 +++++++ cli/src/cursor/cursorSpecialCommands.test.ts | 33 ++++++++++++ cli/src/cursor/cursorSpecialCommands.ts | 32 +++++++++++ cli/src/cursor/cursorUserMessageQueue.test.ts | 53 +++++++++++++++++++ cli/src/cursor/cursorUserMessageQueue.ts | 21 ++++++++ cli/src/cursor/runCursor.ts | 3 +- cli/src/utils/MessageQueue2.test.ts | 22 ++++++++ cli/src/utils/MessageQueue2.ts | 37 +++++++++++++ shared/src/slashCommands.ts | 5 +- web/src/lib/codexSlashCommands.test.ts | 6 +++ 10 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 cli/src/cursor/cursorSpecialCommands.test.ts create mode 100644 cli/src/cursor/cursorSpecialCommands.ts create mode 100644 cli/src/cursor/cursorUserMessageQueue.test.ts create mode 100644 cli/src/cursor/cursorUserMessageQueue.ts diff --git a/cli/src/cursor/cursorRemoteLauncher.ts b/cli/src/cursor/cursorRemoteLauncher.ts index 2d1836a0..8debffe3 100644 --- a/cli/src/cursor/cursorRemoteLauncher.ts +++ b/cli/src/cursor/cursorRemoteLauncher.ts @@ -13,6 +13,7 @@ import { import type { CursorSession } from './session'; import type { CursorStreamEvent } from './utils/cursorEventConverter'; import { parseCursorEvent, convertCursorEventToAgentMessage } from './utils/cursorEventConverter'; +import { parseCursorSpecialCommand } from './cursorSpecialCommands'; function buildAgentArgs(opts: { message: string; @@ -97,10 +98,29 @@ class CursorRemoteLauncher extends RemoteLauncherBase { } const { message, mode } = batch; + const specialCommand = parseCursorSpecialCommand(message); + + if (specialCommand.type === 'invalid') { + session.sendSessionEvent({ type: 'message', message: specialCommand.message }); + messageBuffer.addMessage(specialCommand.message, 'status'); + if (session.queue.size() === 0 && !this.shouldExit) { + sendReady(); + } + continue; + } + const { mode: agentMode, yolo } = permissionModeToAgentArgs(mode.permissionMode as string); this.applyDisplayMode(mode.permissionMode as string); messageBuffer.addMessage(message, 'user'); + if (specialCommand.type === 'summarize') { + logger.debug('[cursor-remote] /summarize — pass-through to agent -p'); + messageBuffer.addMessage('Context summarization requested', 'status'); + } else if (specialCommand.type === 'clear') { + logger.debug('[cursor-remote] /clear — pass-through to agent -p'); + messageBuffer.addMessage('Context clear requested', 'status'); + } + const args = buildAgentArgs({ message, cwd: session.path, diff --git a/cli/src/cursor/cursorSpecialCommands.test.ts b/cli/src/cursor/cursorSpecialCommands.test.ts new file mode 100644 index 00000000..920035b7 --- /dev/null +++ b/cli/src/cursor/cursorSpecialCommands.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { parseCursorSpecialCommand } from './cursorSpecialCommands'; + +describe('parseCursorSpecialCommand', () => { + it('accepts /summarize with optional instructions', () => { + expect(parseCursorSpecialCommand('/summarize')).toEqual({ + type: 'summarize', + message: '/summarize' + }); + expect(parseCursorSpecialCommand(' /summarize keep peer relocate recap ')).toEqual({ + type: 'summarize', + message: '/summarize keep peer relocate recap' + }); + }); + + it('accepts exact /clear', () => { + expect(parseCursorSpecialCommand(' /clear ')).toEqual({ type: 'clear' }); + }); + + it('rejects /clear with arguments', () => { + expect(parseCursorSpecialCommand('/clear now')).toEqual({ + type: 'invalid', + command: 'clear', + message: '/clear does not accept arguments' + }); + }); + + it('ignores regular slash-like messages', () => { + expect(parseCursorSpecialCommand('/summarizer')).toEqual({ type: null }); + expect(parseCursorSpecialCommand('please /summarize')).toEqual({ type: null }); + expect(parseCursorSpecialCommand('/clearing')).toEqual({ type: null }); + }); +}); diff --git a/cli/src/cursor/cursorSpecialCommands.ts b/cli/src/cursor/cursorSpecialCommands.ts new file mode 100644 index 00000000..5bafa0ff --- /dev/null +++ b/cli/src/cursor/cursorSpecialCommands.ts @@ -0,0 +1,32 @@ +export type CursorSpecialCommand = + | { type: 'summarize'; message: string } + | { type: 'clear' } + | { type: 'invalid'; command: 'clear'; message: string } + | { type: null }; + +/** + * Parse Cursor-specific slash commands for remote sessions. + * Summarize accepts optional trailing instructions after the command. + * Messages are still passed verbatim to `agent -p` — this parser is for detection and UI contract only. + */ +export function parseCursorSpecialCommand(message: string): CursorSpecialCommand { + const trimmed = message.trim(); + + if (trimmed === '/summarize' || trimmed.startsWith('/summarize ')) { + return { type: 'summarize', message: trimmed }; + } + + if (trimmed === '/clear') { + return { type: 'clear' }; + } + + if (trimmed.startsWith('/clear ')) { + return { + type: 'invalid', + command: 'clear', + message: '/clear does not accept arguments' + }; + } + + return { type: null }; +} diff --git a/cli/src/cursor/cursorUserMessageQueue.test.ts b/cli/src/cursor/cursorUserMessageQueue.test.ts new file mode 100644 index 00000000..f99ce023 --- /dev/null +++ b/cli/src/cursor/cursorUserMessageQueue.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { MessageQueue2 } from '@/utils/MessageQueue2'; +import { parseCursorSpecialCommand } from './cursorSpecialCommands'; +import { enqueueCursorUserMessage } from './cursorUserMessageQueue'; +import type { EnhancedMode } from './loop'; + +const mode: EnhancedMode = { permissionMode: 'default' }; + +describe('enqueueCursorUserMessage', () => { + it('does not batch invalid /clear with a following prompt', async () => { + const queue = new MessageQueue2((m) => m.permissionMode); + enqueueCursorUserMessage(queue, '/clear now', mode, 'a'); + enqueueCursorUserMessage(queue, 'continue work', mode, 'b'); + + const first = await queue.waitForMessagesAndGetAsString(); + expect(first?.message).toBe('/clear now'); + expect(parseCursorSpecialCommand(first!.message).type).toBe('invalid'); + + const second = await queue.waitForMessagesAndGetAsString(); + expect(second?.message).toBe('continue work'); + }); + + it('isolates /summarize from a following same-mode prompt', async () => { + const queue = new MessageQueue2((m) => m.permissionMode); + enqueueCursorUserMessage(queue, '/summarize keep recap', mode, 'a'); + enqueueCursorUserMessage(queue, 'next task', mode, 'b'); + + const first = await queue.waitForMessagesAndGetAsString(); + expect(first?.message).toBe('/summarize keep recap'); + expect(parseCursorSpecialCommand(first!.message).type).toBe('summarize'); + + const second = await queue.waitForMessagesAndGetAsString(); + expect(second?.message).toBe('next task'); + }); + + it('preserves a normal prompt queued before a slash command', async () => { + const queue = new MessageQueue2((m) => m.permissionMode); + enqueueCursorUserMessage(queue, 'first work', mode, 'a'); + enqueueCursorUserMessage(queue, '/summarize', mode, 'b'); + enqueueCursorUserMessage(queue, 'after summarize', mode, 'c'); + + const first = await queue.waitForMessagesAndGetAsString(); + expect(first?.message).toBe('first work'); + expect(first?.isolate).toBe(false); + + const second = await queue.waitForMessagesAndGetAsString(); + expect(second?.message).toBe('/summarize'); + expect(second?.isolate).toBe(true); + + const third = await queue.waitForMessagesAndGetAsString(); + expect(third?.message).toBe('after summarize'); + }); +}); diff --git a/cli/src/cursor/cursorUserMessageQueue.ts b/cli/src/cursor/cursorUserMessageQueue.ts new file mode 100644 index 00000000..28ae7242 --- /dev/null +++ b/cli/src/cursor/cursorUserMessageQueue.ts @@ -0,0 +1,21 @@ +import type { EnhancedMode } from './loop'; +import { parseCursorSpecialCommand } from './cursorSpecialCommands'; +import type { MessageQueue2 } from '@/utils/MessageQueue2'; + +/** + * Enqueue a Cursor user message. Special slash commands are isolated so they are + * never newline-batched with adjacent same-mode prompts. + */ +export function enqueueCursorUserMessage( + messageQueue: MessageQueue2, + formattedText: string, + enhancedMode: EnhancedMode, + localId?: string +): void { + const specialCommand = parseCursorSpecialCommand(formattedText); + if (specialCommand.type !== null) { + messageQueue.pushIsolated(formattedText.trim(), enhancedMode, localId); + return; + } + messageQueue.push(formattedText, enhancedMode, localId); +} diff --git a/cli/src/cursor/runCursor.ts b/cli/src/cursor/runCursor.ts index 3f2a1d69..282de7e9 100644 --- a/cli/src/cursor/runCursor.ts +++ b/cli/src/cursor/runCursor.ts @@ -11,6 +11,7 @@ import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } f import { registerSessionConfigRpc } from '@/agent/sessionConfigRpc'; import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; import { getInvokedCwd } from '@/utils/invokedCwd'; +import { enqueueCursorUserMessage } from './cursorUserMessageQueue'; const formatFailureReason = (message: string): string => { const maxLength = 200; @@ -96,7 +97,7 @@ export async function runCursor(opts: { model: currentModel }; const formattedText = formatMessageWithAttachments(message.content.text, message.content.attachments); - messageQueue.push(formattedText, enhancedMode, localId); + enqueueCursorUserMessage(messageQueue, formattedText, enhancedMode, localId); }); session.onCancelQueuedMessage((localId) => { diff --git a/cli/src/utils/MessageQueue2.test.ts b/cli/src/utils/MessageQueue2.test.ts index 8c03f7a6..d54d621d 100644 --- a/cli/src/utils/MessageQueue2.test.ts +++ b/cli/src/utils/MessageQueue2.test.ts @@ -367,6 +367,28 @@ describe('MessageQueue2', () => { expect(batch1?.mode.type).toBe('A'); }); + it('should preserve pending messages when pushIsolated is used', async () => { + const queue = new MessageQueue2<{ type: string }>((mode) => mode.type); + + queue.push('message1', { type: 'A' }); + queue.push('message2', { type: 'A' }); + + queue.pushIsolated('isolated', { type: 'A' }); + + queue.push('message3', { type: 'A' }); + + const batch1 = await queue.waitForMessagesAndGetAsString(); + expect(batch1?.message).toBe('message1\nmessage2'); + expect(batch1?.isolate).toBe(false); + + const batch2 = await queue.waitForMessagesAndGetAsString(); + expect(batch2?.message).toBe('isolated'); + expect(batch2?.isolate).toBe(true); + + const batch3 = await queue.waitForMessagesAndGetAsString(); + expect(batch3?.message).toBe('message3'); + }); + it('should isolate messages pushed with pushIsolateAndClear', async () => { const queue = new MessageQueue2<{ type: string }>((mode) => mode.type); diff --git a/cli/src/utils/MessageQueue2.ts b/cli/src/utils/MessageQueue2.ts index fb59ec16..fea79030 100644 --- a/cli/src/utils/MessageQueue2.ts +++ b/cli/src/utils/MessageQueue2.ts @@ -107,6 +107,43 @@ export class MessageQueue2 { logger.debug(`[MessageQueue2] pushImmediate() completed. Queue size: ${this.queue.length}`); } + /** + * Push a message that must be processed in isolation, preserving any + * messages already queued ahead of it. The new message is never batched + * with siblings (neither the ones before it, nor any that arrive after). + * Use this when a slash command must run alone but earlier prompts must + * still be delivered in order. + */ + pushIsolated(message: string, mode: T, localId?: string): void { + if (this.closed) { + throw new Error('Cannot push to closed queue'); + } + + const modeHash = this.modeHasher(mode); + logger.debug(`[MessageQueue2] pushIsolated() called with mode hash: ${modeHash} - preserving ${this.queue.length} pending messages`); + + this.queue.push({ + message, + mode, + modeHash, + localId, + isolate: true + }); + + if (this.onMessageHandler) { + this.onMessageHandler(message, mode); + } + + if (this.waiter) { + logger.debug(`[MessageQueue2] Notifying waiter for isolated message`); + const waiter = this.waiter; + this.waiter = null; + waiter(true); + } + + logger.debug(`[MessageQueue2] pushIsolated() completed. Queue size: ${this.queue.length}`); + } + /** * Push a message that must be processed in complete isolation. * Clears any pending messages and ensures this message is never batched with others. diff --git a/shared/src/slashCommands.ts b/shared/src/slashCommands.ts index fc752b71..4572ac81 100644 --- a/shared/src/slashCommands.ts +++ b/shared/src/slashCommands.ts @@ -39,7 +39,10 @@ export const BUILTIN_SLASH_COMMANDS = { { name: 'default', description: 'Return OpenCode permission mode to default', source: 'builtin' }, { name: 'init', description: 'Generate or refresh AGENTS.md for this project', source: 'builtin' }, ], - cursor: [], + cursor: [ + { name: 'summarize', description: 'Summarize conversation context to free window space (pass-through to Cursor agent)', source: 'builtin' }, + { name: 'clear', description: 'Clear conversation context if supported by Cursor agent', source: 'builtin' }, + ], } as const satisfies Record export function getBuiltinSlashCommands(agent: string): SlashCommand[] { diff --git a/web/src/lib/codexSlashCommands.test.ts b/web/src/lib/codexSlashCommands.test.ts index 3915dbb9..93125ec3 100644 --- a/web/src/lib/codexSlashCommands.test.ts +++ b/web/src/lib/codexSlashCommands.test.ts @@ -19,6 +19,12 @@ describe('getBuiltinSlashCommands', () => { 'permission', ])) }) + + it('exposes Cursor summarize and clear builtins', () => { + expect(getBuiltinSlashCommands('cursor').map((command) => command.name)).toEqual( + expect.arrayContaining(['summarize', 'clear']) + ) + }) }) describe('mergeSlashCommands', () => {