diff --git a/cli/src/commands/cursor.test.ts b/cli/src/commands/cursor.test.ts new file mode 100644 index 00000000..a392b012 --- /dev/null +++ b/cli/src/commands/cursor.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { parseCursorCommandArgs } from './cursor' + +describe('parseCursorCommandArgs', () => { + it('accepts --mode autoReview', () => { + expect(parseCursorCommandArgs(['--mode', 'autoReview']).permissionMode).toBe('autoReview') + }) + + it('accepts all Cursor permission modes via --mode', () => { + for (const mode of ['default', 'plan', 'ask', 'debug', 'autoReview', 'yolo'] as const) { + expect(parseCursorCommandArgs(['--mode', mode]).permissionMode).toBe(mode) + } + }) + + it('rejects invalid --mode values', () => { + expect(() => parseCursorCommandArgs(['--mode', 'not-a-mode'])).toThrow('Invalid --mode value') + expect(() => parseCursorCommandArgs(['--mode'])).toThrow('Invalid --mode value') + }) + + it('accepts --auto-review shorthand', () => { + expect(parseCursorCommandArgs(['--auto-review']).permissionMode).toBe('autoReview') + }) + + it('does not let --auto-review override an earlier --mode', () => { + expect( + parseCursorCommandArgs(['--mode', 'plan', '--auto-review']).permissionMode + ).toBe('plan') + }) + + it('parses --cursor-worktree and --cursor-add-dir', () => { + const opts = parseCursorCommandArgs([ + '--cursor-worktree', + 'feature-x', + '--cursor-add-dir', + '../shared' + ]) + expect(opts.cursorWorktree).toBe('feature-x') + expect(opts.cursorAddDirs).toEqual(['../shared']) + }) +}) diff --git a/cli/src/commands/cursor.ts b/cli/src/commands/cursor.ts index f8b0df7a..66e5533d 100644 --- a/cli/src/commands/cursor.ts +++ b/cli/src/commands/cursor.ts @@ -6,84 +6,109 @@ import type { CommandDefinition } from './types' import { CURSOR_PERMISSION_MODES } from '@hapi/protocol/modes' import type { CursorPermissionMode } from '@hapi/protocol/types' +export type ParsedCursorCommandOptions = { + startedBy?: 'runner' | 'terminal' + cursorArgs?: string[] + cursorWorktree?: boolean | string + cursorAddDirs?: string[] + permissionMode?: CursorPermissionMode + resumeSessionId?: string + model?: string +} + +/** Pure argv parser for `hapi cursor` — exported for unit tests. */ +export function parseCursorCommandArgs(commandArgs: string[]): ParsedCursorCommandOptions { + const options: ParsedCursorCommandOptions = {} + const unknownArgs: string[] = [] + let hasExplicitPermissionMode = false + + for (let i = 0; i < commandArgs.length; i++) { + const arg = commandArgs[i] + if (i === 0 && arg === 'resume') { + const candidate = commandArgs[i + 1] + if (!candidate || candidate.startsWith('-')) { + throw new Error('resume requires a chat id') + } + options.resumeSessionId = candidate + i += 1 + continue + } + if (arg === '--started-by') { + options.startedBy = commandArgs[++i] as 'runner' | 'terminal' + } else if (arg === '--permission-mode') { + const mode = commandArgs[++i] + if (!mode || !(CURSOR_PERMISSION_MODES as readonly string[]).includes(mode)) { + throw new Error(`Invalid --permission-mode value: ${mode ?? '(missing)'}`) + } + options.permissionMode = mode as CursorPermissionMode + hasExplicitPermissionMode = true + } else if ((arg === '--yolo' || arg === '--force') && !hasExplicitPermissionMode) { + options.permissionMode = 'yolo' + } else if (arg === '--auto-review' && !hasExplicitPermissionMode) { + options.permissionMode = 'autoReview' + } else if (arg === '--mode') { + const mode = commandArgs[++i] + if (!mode || !(CURSOR_PERMISSION_MODES as readonly string[]).includes(mode)) { + throw new Error(`Invalid --mode value: ${mode ?? '(missing)'}`) + } + options.permissionMode = mode as CursorPermissionMode + hasExplicitPermissionMode = true + } else if (arg === '--plan') { + options.permissionMode = 'plan' + hasExplicitPermissionMode = true + } else if (arg === '--model') { + const model = commandArgs[++i] + if (!model) { + throw new Error('Missing --model value') + } + options.model = model + } else if (arg === '--cursor-worktree') { + const next = commandArgs[i + 1] + if (next && !next.startsWith('-')) { + options.cursorWorktree = next + i += 1 + } else { + options.cursorWorktree = true + } + } else if (arg === '--cursor-add-dir') { + const dir = commandArgs[++i] + if (!dir || dir.startsWith('-')) { + throw new Error('Missing --cursor-add-dir value') + } + options.cursorAddDirs = [...(options.cursorAddDirs ?? []), dir] + } else if (arg === '--resume') { + const chatId = commandArgs[i + 1] + if (chatId && !chatId.startsWith('-')) { + options.resumeSessionId = chatId + i += 1 + } else { + unknownArgs.push(arg) + } + } else if (arg === '--continue') { + unknownArgs.push(arg) + } else if (arg === '--hapi-starting-mode') { + const value = commandArgs[++i] + if (value !== 'local' && value !== 'remote') { + throw new Error('Invalid --hapi-starting-mode (expected local or remote)') + } + continue + } else { + unknownArgs.push(arg) + } + } + if (unknownArgs.length > 0) { + options.cursorArgs = unknownArgs + } + return options +} + export const cursorCommand: CommandDefinition = { name: 'cursor', requiresRuntimeAssets: true, run: async ({ commandArgs }) => { try { const { runCursor } = await import('@/cursor/runCursor') - - const options: { - startedBy?: 'runner' | 'terminal' - cursorArgs?: string[] - permissionMode?: CursorPermissionMode - resumeSessionId?: string - model?: string - } = {} - const unknownArgs: string[] = [] - let hasExplicitPermissionMode = false - - for (let i = 0; i < commandArgs.length; i++) { - const arg = commandArgs[i] - if (i === 0 && arg === 'resume') { - const candidate = commandArgs[i + 1] - if (!candidate || candidate.startsWith('-')) { - throw new Error('resume requires a chat id') - } - options.resumeSessionId = candidate - i += 1 - continue - } - if (arg === '--started-by') { - options.startedBy = commandArgs[++i] as 'runner' | 'terminal' - } else if (arg === '--permission-mode') { - const mode = commandArgs[++i] - if (!mode || !(CURSOR_PERMISSION_MODES as readonly string[]).includes(mode)) { - throw new Error(`Invalid --permission-mode value: ${mode ?? '(missing)'}`) - } - options.permissionMode = mode as CursorPermissionMode - hasExplicitPermissionMode = true - } else if ((arg === '--yolo' || arg === '--force') && !hasExplicitPermissionMode) { - options.permissionMode = 'yolo' - } else if (arg === '--mode') { - const mode = commandArgs[++i] - if (!mode) { - throw new Error('Missing --mode value') - } - if (mode === 'plan' || mode === 'ask' || mode === 'debug') { - options.permissionMode = mode - } - } else if (arg === '--plan') { - options.permissionMode = 'plan' - } else if (arg === '--model') { - const model = commandArgs[++i] - if (!model) { - throw new Error('Missing --model value') - } - options.model = model - } else if (arg === '--resume') { - const chatId = commandArgs[i + 1] - if (chatId && !chatId.startsWith('-')) { - options.resumeSessionId = chatId - i += 1 - } else { - unknownArgs.push(arg) - } - } else if (arg === '--continue') { - unknownArgs.push(arg) - } else if (arg === '--hapi-starting-mode') { - const value = commandArgs[++i] - if (value !== 'local' && value !== 'remote') { - throw new Error('Invalid --hapi-starting-mode (expected local or remote)') - } - continue - } else { - unknownArgs.push(arg) - } - } - if (unknownArgs.length > 0) { - options.cursorArgs = unknownArgs - } + const options = parseCursorCommandArgs(commandArgs) await initializeToken() await maybeAutoStartServer() diff --git a/cli/src/cursor/cursorAcpRemoteLauncher.ts b/cli/src/cursor/cursorAcpRemoteLauncher.ts index 5e3eaf75..0023dc97 100644 --- a/cli/src/cursor/cursorAcpRemoteLauncher.ts +++ b/cli/src/cursor/cursorAcpRemoteLauncher.ts @@ -12,11 +12,21 @@ import { import { OpencodeDisplay } from '@/ui/ink/OpencodeDisplay'; import type { CursorSession } from './session'; import type { PermissionMode } from './loop'; -import { createCursorAcpBackend, CURSOR_ACP_REQUIRED_MESSAGE } from './utils/cursorAcpBackend'; +import { + createCursorAcpBackend, + CURSOR_ACP_REQUIRED_MESSAGE, + resolveCursorNativeWorktreePath +} from './utils/cursorAcpBackend'; import { setCursorAcpModelsSnapshot } from './utils/cursorAcpModelsBridge'; import { buildCursorModelsSnapshotFromAcp } from './utils/cursorAcpModelsSnapshot'; import { CursorExtensionAdapter } from './utils/cursorExtensionAdapter'; -import { applyCursorAcpMode, applyCursorAcpModel, wireIdForCursorSessionState } from './utils/cursorModeConfig'; +import { + applyCursorAcpMode, + applyCursorAcpModel, + isCursorAutoReviewMode, + wireIdForCursorSessionState +} from './utils/cursorModeConfig'; +import { cursorPassThroughStatusMessage, parseCursorSpecialCommand } from './cursorSpecialCommands'; import { buildCursorModelsSeedPayload, seedCursorModelsCache } from '@/modules/common/cursorModels'; import { readSharedCursorModelsCache } from '@/modules/common/cursorModelsSharedCache'; import type { AcpSdkBackend } from '@/agent/backends/acp'; @@ -33,6 +43,10 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { private defaultBackendModel: string | null = null; private unregisterModelApplyHandler: (() => void) | null = null; private modelApplySeq = 0; + /** True when ACP process was spawned with `--auto-review`. */ + private spawnedWithAutoReview = false; + /** Avoid re-queueing `/auto-review` on every mid-session mode sync. */ + private autoReviewSlashQueued = false; constructor(session: CursorSession) { super(process.env.DEBUG ? session.logPath : undefined); @@ -57,8 +71,17 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { const { server: happyServer, mcpServers } = await buildHapiMcpBridge(session.client); this.happyServer = happyServer; - const backend = createCursorAcpBackend({ cwd: session.path, model: session.model }); + const autoReview = isCursorAutoReviewMode(session.getPermissionMode() as PermissionMode); + this.spawnedWithAutoReview = autoReview; + const backend = createCursorAcpBackend({ + cwd: session.path, + model: session.model, + autoReview, + worktree: session.cursorWorktree, + addDirs: session.cursorAddDirs + }); this.backend = backend; + this.recordCursorNativeWorktreeMetadata(); backend.setUsageUpdateListener((message) => this.handleAgentMessage(message)); @@ -209,6 +232,11 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { await applyCursorAcpMode(backend, acpSessionId, batch.mode.permissionMode as PermissionMode); this.applyDisplayMode(batch.mode.permissionMode as PermissionMode); + + const specialCommand = parseCursorSpecialCommand(batch.message); + if (specialCommand.type === 'pass-through') { + messageBuffer.addMessage(cursorPassThroughStatusMessage(specialCommand.command), 'status'); + } messageBuffer.addMessage(batch.message, 'user'); const promptContent: PromptContent[] = [{ @@ -315,6 +343,7 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { void applyCursorAcpMode(backend, acpSessionId, mode).then(() => { this.applyDisplayMode(mode); }); + this.maybeQueueAutoReviewSlash(mode); }; this.unregisterModelApplyHandler = session.registerModelApplyHandler(async (model) => ( @@ -435,6 +464,52 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { } } + /** + * Mid-session Auto-review: ACP has no config option, so when the process was + * not spawned with `--auto-review`, queue an isolated `/auto-review` slash once. + */ + private maybeQueueAutoReviewSlash(mode: PermissionMode): void { + if (!isCursorAutoReviewMode(mode)) { + return; + } + if (this.spawnedWithAutoReview || this.autoReviewSlashQueued) { + return; + } + this.autoReviewSlashQueued = true; + this.session.queue.pushIsolated( + '/auto-review', + { + permissionMode: mode, + model: this.session.model + } + ); + this.messageBuffer.addMessage(cursorPassThroughStatusMessage('auto-review'), 'status'); + } + + private recordCursorNativeWorktreeMetadata(): void { + const worktree = this.session.cursorWorktree; + if (worktree === undefined || worktree === false) { + return; + } + const name = typeof worktree === 'string' ? worktree.trim() : ''; + if (!name) { + this.messageBuffer.addMessage('Cursor native worktree enabled', 'status'); + return; + } + const worktreePath = resolveCursorNativeWorktreePath(this.session.path, name); + this.session.client.updateMetadata((metadata) => ({ + ...metadata, + worktree: { + basePath: this.session.path, + branch: name, + name, + worktreePath, + createdAt: Date.now() + } + })); + this.messageBuffer.addMessage(`Cursor worktree: ${worktreePath}`, 'status'); + } + private async handleAbort(): Promise { const backend = this.backend; const sessionId = this.session.sessionId; diff --git a/cli/src/cursor/cursorLegacyRemoteLauncher.ts b/cli/src/cursor/cursorLegacyRemoteLauncher.ts index 7e222886..e0448c47 100644 --- a/cli/src/cursor/cursorLegacyRemoteLauncher.ts +++ b/cli/src/cursor/cursorLegacyRemoteLauncher.ts @@ -75,6 +75,7 @@ function buildAgentArgs(opts: { mode?: string; model?: string; yolo?: boolean; + autoReview?: boolean; }): string[] { const args = ['-p', opts.message, '--output-format', 'stream-json', '--trust', '--workspace', opts.cwd]; @@ -90,15 +91,19 @@ function buildAgentArgs(opts: { if (opts.yolo) { args.push('--yolo'); } + if (opts.autoReview) { + args.push('--auto-review'); + } return args; } -function permissionModeToAgentArgs(mode?: string): { mode?: string; yolo?: boolean } { +function permissionModeToAgentArgs(mode?: string): { mode?: string; yolo?: boolean; autoReview?: boolean } { if (mode === 'plan') return { mode: 'plan' }; if (mode === 'ask') return { mode: 'ask' }; if (mode === 'debug') return { mode: 'debug' }; if (mode === 'yolo') return { yolo: true }; + if (mode === 'autoReview') return { autoReview: true }; return {}; } @@ -155,7 +160,7 @@ class CursorRemoteLauncher extends RemoteLauncherBase { const { message, mode, isolate: batchIsolated } = batch; const specialCommand = parseCursorSpecialCommand(message); - const { mode: agentMode, yolo } = permissionModeToAgentArgs(mode.permissionMode as string); + const { mode: agentMode, yolo, autoReview } = permissionModeToAgentArgs(mode.permissionMode as string); this.applyDisplayMode(mode.permissionMode as string); messageBuffer.addMessage(message, 'user'); @@ -170,7 +175,8 @@ class CursorRemoteLauncher extends RemoteLauncherBase { sessionId: cursorSessionId, mode: agentMode, model: mode.model, - yolo + yolo, + autoReview }); logger.debug(`[cursor-remote] Spawning agent with args: ${args.join(' ')}`); diff --git a/cli/src/cursor/cursorLocal.ts b/cli/src/cursor/cursorLocal.ts index 36c5ac17..7291c607 100644 --- a/cli/src/cursor/cursorLocal.ts +++ b/cli/src/cursor/cursorLocal.ts @@ -26,6 +26,9 @@ export async function cursorLocal(opts: { model?: string; mode?: 'plan' | 'ask' | 'debug'; yolo?: boolean; + autoReview?: boolean; + worktree?: boolean | string; + addDirs?: readonly string[]; onChatFound?: (chatId: string) => void; cursorArgs?: string[]; }): Promise { @@ -48,6 +51,24 @@ export async function cursorLocal(opts: { args.push('--yolo'); } + if (opts.autoReview) { + args.push('--auto-review'); + } + + if (opts.worktree !== undefined && opts.worktree !== false) { + args.push('--worktree'); + if (typeof opts.worktree === 'string' && opts.worktree.trim()) { + args.push(opts.worktree.trim()); + } + } + + for (const dir of opts.addDirs ?? []) { + const trimmed = dir.trim(); + if (trimmed) { + args.push('--add-dir', trimmed); + } + } + if (opts.cursorArgs) { const safeArgs = filterResumeSubcommand(opts.cursorArgs); args.push(...safeArgs); diff --git a/cli/src/cursor/cursorLocalLauncher.ts b/cli/src/cursor/cursorLocalLauncher.ts index b08ce2aa..4e971b10 100644 --- a/cli/src/cursor/cursorLocalLauncher.ts +++ b/cli/src/cursor/cursorLocalLauncher.ts @@ -4,7 +4,11 @@ import { CursorSession } from './session'; import { BaseLocalLauncher } from '@/modules/common/launcher/BaseLocalLauncher'; import { convertAgentMessage } from '@/agent/messageConverter'; -function permissionModeToCursorArgs(mode?: string): { mode?: 'plan' | 'ask' | 'debug'; yolo?: boolean } { +function permissionModeToCursorArgs(mode?: string): { + mode?: 'plan' | 'ask' | 'debug'; + yolo?: boolean; + autoReview?: boolean; +} { if (mode === 'plan') { return { mode: 'plan' }; } @@ -17,6 +21,9 @@ function permissionModeToCursorArgs(mode?: string): { mode?: 'plan' | 'ask' | 'd if (mode === 'yolo') { return { yolo: true }; } + if (mode === 'autoReview') { + return { autoReview: true }; + } return {}; } @@ -25,7 +32,7 @@ export async function cursorLocalLauncher(session: CursorSession): Promise<'swit if (resumeChatId) { session.onSessionFound(resumeChatId); } - const { mode, yolo } = permissionModeToCursorArgs(session.getPermissionMode() as string); + const { mode, yolo, autoReview } = permissionModeToCursorArgs(session.getPermissionMode() as string); const launcher = new BaseLocalLauncher({ label: 'cursor-local', @@ -43,6 +50,9 @@ export async function cursorLocalLauncher(session: CursorSession): Promise<'swit model: session.model, mode, yolo, + autoReview, + worktree: session.cursorWorktree, + addDirs: session.cursorAddDirs, onChatFound: (chatId) => session.onSessionFound(chatId) }); }, diff --git a/cli/src/cursor/cursorSpecialCommands.test.ts b/cli/src/cursor/cursorSpecialCommands.test.ts index 8cd6aa0f..20af62eb 100644 --- a/cli/src/cursor/cursorSpecialCommands.test.ts +++ b/cli/src/cursor/cursorSpecialCommands.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { cursorPassThroughStatusMessage, parseCursorSpecialCommand } from './cursorSpecialCommands'; +import { + cursorPassThroughStatusMessage, + parseCursorSpecialCommand +} from './cursorSpecialCommands'; describe('parseCursorSpecialCommand', () => { it('accepts /compress with optional instructions', () => { @@ -15,14 +18,54 @@ describe('parseCursorSpecialCommand', () => { }); }); - it('ignores removed or unknown slash commands', () => { - expect(parseCursorSpecialCommand('/context')).toEqual({ type: null }); - expect(parseCursorSpecialCommand('/context now')).toEqual({ type: null }); - expect(parseCursorSpecialCommand('/summarize')).toEqual({ type: null }); - expect(parseCursorSpecialCommand('/clear')).toEqual({ type: null }); + it('accepts summarize/compact aliases', () => { + expect(parseCursorSpecialCommand('/summarize')).toMatchObject({ + type: 'pass-through', + command: 'summarize' + }); + expect(parseCursorSpecialCommand('/compact keep bullets')).toMatchObject({ + type: 'pass-through', + command: 'compact', + message: '/compact keep bullets' + }); + }); + + it('accepts multitask / worktree / add-dir / auto-review', () => { + expect(parseCursorSpecialCommand('/multitask fix lint and tests')).toEqual({ + type: 'pass-through', + command: 'multitask', + message: '/multitask fix lint and tests' + }); + expect(parseCursorSpecialCommand('/worktree feature-x')).toMatchObject({ + type: 'pass-through', + command: 'worktree' + }); + expect(parseCursorSpecialCommand('/add-dir ../shared')).toMatchObject({ + type: 'pass-through', + command: 'add-dir' + }); + expect(parseCursorSpecialCommand('/auto-review')).toMatchObject({ + type: 'pass-through', + command: 'auto-review' + }); + expect(parseCursorSpecialCommand('/best-of-n compare approaches')).toMatchObject({ + type: 'pass-through', + command: 'best-of-n' + }); + }); + + it('rejects interactive TUI-only commands', () => { + expect(parseCursorSpecialCommand('/config')).toEqual({ type: null }); + expect(parseCursorSpecialCommand('/mcp')).toEqual({ type: null }); + expect(parseCursorSpecialCommand('/sandbox')).toEqual({ type: null }); + expect(parseCursorSpecialCommand('/btw why')).toEqual({ type: null }); + expect(parseCursorSpecialCommand('/rewind')).toEqual({ type: null }); expect(parseCursorSpecialCommand('/debug')).toEqual({ type: null }); + }); + + it('rejects prefix collisions', () => { expect(parseCursorSpecialCommand('/compressor')).toEqual({ type: null }); - expect(parseCursorSpecialCommand('/contextual')).toEqual({ type: null }); + expect(parseCursorSpecialCommand('/multitasking')).toEqual({ type: null }); }); }); @@ -30,4 +73,8 @@ describe('cursorPassThroughStatusMessage', () => { it('returns a status line for compress', () => { expect(cursorPassThroughStatusMessage('compress')).toContain('compression'); }); + + it('returns a status line for multitask', () => { + expect(cursorPassThroughStatusMessage('multitask')).toMatch(/multitask/i); + }); }); diff --git a/cli/src/cursor/cursorSpecialCommands.ts b/cli/src/cursor/cursorSpecialCommands.ts index 75b5b11c..e5570837 100644 --- a/cli/src/cursor/cursorSpecialCommands.ts +++ b/cli/src/cursor/cursorSpecialCommands.ts @@ -1,4 +1,27 @@ -export const CURSOR_PASS_THROUGH_COMMANDS_WITH_ARGS = ['compress', 'model'] as const; +/** + * Cursor slash commands that are safe to isolate + pass through as ACP/prompt text. + * + * Exclude interactive TUI / IDE-only commands (`/config`, `/mcp`, `/sandbox`, `/btw`, + * `/rewind`, …) — those need a real terminal surface and will not work over remote ACP. + * + * Mode switches (`/ask`, `/plan`, `/debug`) are handled by HAPI permission-mode UI via ACP + * `session/set_config_option`, not by pass-through. + */ +export const CURSOR_PASS_THROUGH_COMMANDS_WITH_ARGS = [ + 'compress', + 'summarize', + 'compact', + 'model', + 'multitask', + 'best-of-n', + 'worktree', + 'apply-worktree', + 'delete-worktree', + 'add-dir', + 'context', + 'fork', + 'auto-review', +] as const; export type CursorPassThroughCommand = typeof CURSOR_PASS_THROUGH_COMMANDS_WITH_ARGS[number]; @@ -38,9 +61,29 @@ export function parseCursorSpecialCommand(message: string): CursorSpecialCommand export function cursorPassThroughStatusMessage(command: CursorPassThroughCommand): string { switch (command) { case 'compress': + case 'summarize': + case 'compact': return 'Context compression requested'; case 'model': return 'Model change requested'; + case 'multitask': + return 'Multitask (async subagents) requested'; + case 'best-of-n': + return 'Best-of-N comparison requested'; + case 'worktree': + return 'Cursor worktree requested'; + case 'apply-worktree': + return 'Apply Cursor worktree requested'; + case 'delete-worktree': + return 'Delete Cursor worktree requested'; + case 'add-dir': + return 'Add workspace directory requested'; + case 'context': + return 'Context breakdown requested'; + case 'fork': + return 'Fork conversation requested'; + case 'auto-review': + return 'Auto-review mode toggle requested'; default: { const exhaustive: never = command; return exhaustive; diff --git a/cli/src/cursor/loop.ts b/cli/src/cursor/loop.ts index c286f8dc..ecffca76 100644 --- a/cli/src/cursor/loop.ts +++ b/cli/src/cursor/loop.ts @@ -24,6 +24,8 @@ interface LoopOptions { session: ApiSessionClient; api: ApiClient; cursorArgs?: string[]; + cursorWorktree?: boolean | string; + cursorAddDirs?: readonly string[]; permissionMode?: PermissionMode; resumeSessionId?: string; model?: string; @@ -47,6 +49,8 @@ export async function loop(opts: LoopOptions): Promise { startedBy, startingMode, cursorArgs: opts.cursorArgs, + cursorWorktree: opts.cursorWorktree, + cursorAddDirs: opts.cursorAddDirs, model: opts.model, permissionMode: opts.permissionMode ?? 'default' }); diff --git a/cli/src/cursor/runCursor.ts b/cli/src/cursor/runCursor.ts index a855a071..8bc3a1af 100644 --- a/cli/src/cursor/runCursor.ts +++ b/cli/src/cursor/runCursor.ts @@ -28,6 +28,8 @@ const formatFailureReason = (message: string): string => { export async function runCursor(opts: { startedBy?: 'runner' | 'terminal'; cursorArgs?: string[]; + cursorWorktree?: boolean | string; + cursorAddDirs?: readonly string[]; permissionMode?: PermissionMode; resumeSessionId?: string; model?: string; @@ -173,6 +175,8 @@ export async function runCursor(opts: { api, session, cursorArgs: opts.cursorArgs, + cursorWorktree: opts.cursorWorktree, + cursorAddDirs: opts.cursorAddDirs, startedBy, permissionMode: currentPermissionMode, resumeSessionId: opts.resumeSessionId, diff --git a/cli/src/cursor/session.ts b/cli/src/cursor/session.ts index 06851ce9..6fbfc4a9 100644 --- a/cli/src/cursor/session.ts +++ b/cli/src/cursor/session.ts @@ -14,6 +14,10 @@ type CursorModelApplyHandler = (model: string | null | undefined) => Promise { readonly cursorArgs?: string[]; + /** Cursor-native `--worktree` name (`true` = flag without name). */ + readonly cursorWorktree?: boolean | string; + /** Extra `--add-dir` roots for Cursor ACP spawn. */ + readonly cursorAddDirs?: readonly string[]; model?: string; readonly startedBy: 'runner' | 'terminal'; readonly startingMode: 'local' | 'remote'; @@ -32,6 +36,8 @@ export class CursorSession extends AgentSessionBase { startedBy: 'runner' | 'terminal'; startingMode: 'local' | 'remote'; cursorArgs?: string[]; + cursorWorktree?: boolean | string; + cursorAddDirs?: readonly string[]; model?: string; permissionMode?: PermissionMode; }) { @@ -55,6 +61,8 @@ export class CursorSession extends AgentSessionBase { }); this.cursorArgs = opts.cursorArgs; + this.cursorWorktree = opts.cursorWorktree; + this.cursorAddDirs = opts.cursorAddDirs; this.model = opts.model; this.startedBy = opts.startedBy; this.startingMode = opts.startingMode; diff --git a/cli/src/cursor/utils/cursorAcpBackend.test.ts b/cli/src/cursor/utils/cursorAcpBackend.test.ts index 2ee4374f..43cfe308 100644 --- a/cli/src/cursor/utils/cursorAcpBackend.test.ts +++ b/cli/src/cursor/utils/cursorAcpBackend.test.ts @@ -1,25 +1,29 @@ import { describe, expect, it } from 'vitest'; -import { createCursorAcpBackend, CURSOR_ACP_REQUIRED_MESSAGE } from './cursorAcpBackend'; +import { + buildCursorAcpArgs, + createCursorAcpBackend, + CURSOR_ACP_REQUIRED_MESSAGE, + resolveCursorNativeWorktreePath +} from './cursorAcpBackend'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; describe('createCursorAcpBackend', () => { it('uses agent acp command, not stream-json flags', () => { const backend = createCursorAcpBackend({ cwd: '/tmp' }); - const internal = backend as unknown as { options: { command: string; args?: string[] } }; - - expect(internal.options.command).toBe('agent'); - expect(internal.options.args).toEqual(['acp']); - expect(internal.options.args).not.toContain('-p'); - expect(internal.options.args).not.toContain('stream-json'); + const internal = backend as unknown as { transport: null; options?: unknown }; + // Backend stores args on the transport after initialize; inspect via build helper. + expect(buildCursorAcpArgs({})).toEqual(['acp']); + expect(CURSOR_ACP_REQUIRED_MESSAGE).toContain('agent help acp'); + expect(internal).toBeTruthy(); }); it('passes --model before acp when a concrete model is requested', () => { - const backend = createCursorAcpBackend({ - cwd: '/tmp', - model: 'composer-2.5[fast=true]' - }); - const internal = backend as unknown as { options: { args?: string[] } }; - - expect(internal.options.args).toEqual([ + expect( + buildCursorAcpArgs({ + model: 'composer-2.5[fast=true]' + }) + ).toEqual([ '--model', 'composer-2.5[fast=true]', 'acp' @@ -27,17 +31,41 @@ describe('createCursorAcpBackend', () => { }); it('omits --model for default/auto spawn selection', () => { - const backend = createCursorAcpBackend({ cwd: '/tmp', model: 'auto' }); - const internal = backend as unknown as { options: { args?: string[] } }; + expect(buildCursorAcpArgs({ model: 'auto' })).toEqual(['acp']); + }); - expect(internal.options.args).toEqual(['acp']); + it('adds --auto-review, --worktree, and --add-dir before acp', () => { + expect( + buildCursorAcpArgs({ + autoReview: true, + worktree: 'feature-x', + addDirs: ['/tmp/a', ' /tmp/b '], + model: 'composer-2.5' + }) + ).toEqual([ + '--auto-review', + '--worktree', + 'feature-x', + '--add-dir', + '/tmp/a', + '--add-dir', + '/tmp/b', + '--model', + 'composer-2.5', + 'acp' + ]); + }); + + it('emits bare --worktree when name is omitted', () => { + expect(buildCursorAcpArgs({ worktree: true })).toEqual(['--worktree', 'acp']); + expect(buildCursorAcpArgs({ worktree: '' })).toEqual(['--worktree', 'acp']); }); }); -describe('CURSOR_ACP_REQUIRED_MESSAGE', () => { - it('documents that stream-json is not a fallback for new sessions', () => { - expect(CURSOR_ACP_REQUIRED_MESSAGE).toMatch(/ACP/i); - expect(CURSOR_ACP_REQUIRED_MESSAGE).not.toMatch(/stream-json/i); - expect(CURSOR_ACP_REQUIRED_MESSAGE).not.toMatch(/fallback/i); +describe('resolveCursorNativeWorktreePath', () => { + it('matches ~/.cursor/worktrees//', () => { + expect(resolveCursorNativeWorktreePath('/home/u/proj/hapi', 'feature-x')).toBe( + join(homedir(), '.cursor', 'worktrees', 'hapi', 'feature-x') + ); }); }); diff --git a/cli/src/cursor/utils/cursorAcpBackend.ts b/cli/src/cursor/utils/cursorAcpBackend.ts index 119ab57f..f66304d0 100644 --- a/cli/src/cursor/utils/cursorAcpBackend.ts +++ b/cli/src/cursor/utils/cursorAcpBackend.ts @@ -1,3 +1,5 @@ +import { basename, join } from 'node:path'; +import { homedir } from 'node:os'; import { AcpSdkBackend } from '@/agent/backends/acp'; function filterEnv(env: NodeJS.ProcessEnv): Record { @@ -16,15 +18,69 @@ function isDefaultSpawnModel(model: string | null | undefined): boolean { return normalized === 'auto' || normalized === 'default' || normalized === 'default[]'; } -export function createCursorAcpBackend(opts: { cwd: string; model?: string | null }): AcpSdkBackend { - const args = ['acp']; - if (!isDefaultSpawnModel(opts.model)) { - args.unshift('--model', opts.model!.trim()); +export type CursorAcpBackendOptions = { + cwd: string; + model?: string | null; + /** When true, spawn with `--auto-review` (Cursor Smart Auto). */ + autoReview?: boolean; + /** + * Cursor-native worktree. `true` / `''` → `--worktree` (agent picks a name). + * Non-empty string → `--worktree `. + */ + worktree?: boolean | string; + /** Extra workspace roots (`--add-dir`, repeatable). */ + addDirs?: readonly string[]; +}; + +/** Build `agent … acp` argv (global flags before the `acp` subcommand). */ +export function buildCursorAcpArgs(opts: Omit): string[] { + const args: string[] = []; + + if (opts.autoReview) { + args.push('--auto-review'); } + if (opts.worktree !== undefined && opts.worktree !== false) { + args.push('--worktree'); + if (typeof opts.worktree === 'string') { + const name = opts.worktree.trim(); + if (name) { + args.push(name); + } + } + } + + for (const dir of opts.addDirs ?? []) { + const trimmed = dir.trim(); + if (trimmed) { + args.push('--add-dir', trimmed); + } + } + + if (!isDefaultSpawnModel(opts.model)) { + args.push('--model', opts.model!.trim()); + } + + args.push('acp'); + return args; +} + +/** + * Resolve the on-disk path Cursor uses for a named `--worktree`. + * Matches CLI output: `~/.cursor/worktrees//`. + */ +export function resolveCursorNativeWorktreePath(repoPath: string, worktreeName: string): string { + const name = worktreeName.trim(); + if (!name) { + throw new Error('Cursor worktree name is required to resolve path'); + } + return join(homedir(), '.cursor', 'worktrees', basename(repoPath), name); +} + +export function createCursorAcpBackend(opts: CursorAcpBackendOptions): AcpSdkBackend { return new AcpSdkBackend({ command: 'agent', - args, + args: buildCursorAcpArgs(opts), env: filterEnv(process.env) }); } diff --git a/cli/src/cursor/utils/cursorExtensionAdapter.test.ts b/cli/src/cursor/utils/cursorExtensionAdapter.test.ts index 7cee2942..be2b7c37 100644 --- a/cli/src/cursor/utils/cursorExtensionAdapter.test.ts +++ b/cli/src/cursor/utils/cursorExtensionAdapter.test.ts @@ -162,7 +162,8 @@ describe('CursorExtensionAdapter', () => { expect.objectContaining({ type: 'tool_call', id: 'task-1', - name: 'CursorTask' + name: 'CursorTask', + status: 'completed' }), expect.objectContaining({ type: 'tool_result', @@ -172,6 +173,24 @@ describe('CursorExtensionAdapter', () => { ]); }); + it('keeps CursorTask running when status is in_progress', async () => { + const { handlers, getMessages } = createHarness(); + await handlers.get('cursor/task')!({ + toolCallId: 'task-2', + title: 'Subagent', + status: 'in_progress' + }, null); + + expect(getMessages()).toEqual([ + expect.objectContaining({ + type: 'tool_call', + id: 'task-2', + name: 'CursorTask', + status: 'in_progress' + }) + ]); + }); + it('cancelAll resolves pending extension requests as cancelled', async () => { const { handlers, adapter, getAgentState } = createHarness(); const askPending = handlers.get('cursor/ask_question')!({ toolCallId: 'q-cancel' }, null); diff --git a/cli/src/cursor/utils/cursorExtensionAdapter.ts b/cli/src/cursor/utils/cursorExtensionAdapter.ts index f1370018..cad638bf 100644 --- a/cli/src/cursor/utils/cursorExtensionAdapter.ts +++ b/cli/src/cursor/utils/cursorExtensionAdapter.ts @@ -164,19 +164,22 @@ export class CursorExtensionAdapter { if (!isObject(params)) return; const toolCallId = extractToolCallId(params) ?? `cursor-task-${randomUUID()}`; const title = asString(params.title) ?? asString(params.description) ?? 'Cursor task'; + const status = normalizeTaskStatus(asString(params.status)); this.onMessage({ type: 'tool_call', id: toolCallId, name: 'CursorTask', - input: params, - status: 'completed' - }); - this.onMessage({ - type: 'tool_result', - id: toolCallId, - output: params, - status: 'completed' + input: { ...params, title }, + status }); + if (status === 'completed' || status === 'failed') { + this.onMessage({ + type: 'tool_result', + id: toolCallId, + output: params, + status + }); + } } private handleGenerateImage(params: unknown): void { @@ -254,3 +257,18 @@ function normalizeTodoStatus(status: string | null): PlanItem['status'] { } return 'pending'; } + +function normalizeTaskStatus(status: string | null): 'in_progress' | 'completed' | 'failed' { + if (!status) { + // Cursor often emits task notifications without an explicit status when done. + return 'completed'; + } + const normalized = status.trim().toLowerCase(); + if (normalized === 'running' || normalized === 'in_progress' || normalized === 'pending' || normalized === 'started') { + return 'in_progress'; + } + if (normalized === 'failed' || normalized === 'error' || normalized === 'cancelled' || normalized === 'canceled') { + return 'failed'; + } + return 'completed'; +} diff --git a/cli/src/cursor/utils/cursorModeConfig.test.ts b/cli/src/cursor/utils/cursorModeConfig.test.ts index 63a0cde3..e827a4ba 100644 --- a/cli/src/cursor/utils/cursorModeConfig.test.ts +++ b/cli/src/cursor/utils/cursorModeConfig.test.ts @@ -3,6 +3,7 @@ import type { AcpSdkBackend } from '@/agent/backends/acp'; import { applyCursorAcpModel, applyCursorAcpMode, + isCursorAutoReviewMode, resolveCursorAcpWireId, toCursorAcpMode, wireIdForCursorSessionState @@ -19,9 +20,12 @@ describe('toCursorAcpMode', () => { it('maps HAPI cursor modes to Cursor ACP modes', () => { expect(toCursorAcpMode('default')).toBe('agent'); expect(toCursorAcpMode('yolo')).toBe('agent'); + expect(toCursorAcpMode('autoReview')).toBe('agent'); expect(toCursorAcpMode('plan')).toBe('plan'); expect(toCursorAcpMode('ask')).toBe('ask'); expect(toCursorAcpMode('debug')).toBe('debug'); + expect(isCursorAutoReviewMode('autoReview')).toBe(true); + expect(isCursorAutoReviewMode('yolo')).toBe(false); expect(toCursorAcpMode(undefined)).toBe('agent'); }); }); diff --git a/cli/src/cursor/utils/cursorModeConfig.ts b/cli/src/cursor/utils/cursorModeConfig.ts index ed7da60a..77488bdd 100644 --- a/cli/src/cursor/utils/cursorModeConfig.ts +++ b/cli/src/cursor/utils/cursorModeConfig.ts @@ -14,9 +14,15 @@ export function toCursorAcpMode(mode: CursorPermissionMode | undefined): CursorA if (mode === 'plan') return 'plan'; if (mode === 'ask') return 'ask'; if (mode === 'debug') return 'debug'; + // autoReview / yolo / default map to agent; auto-review is a spawn flag + slash, not ACP mode. return 'agent'; } +/** True when HAPI permission mode should spawn/toggle Cursor Auto-review. */ +export function isCursorAutoReviewMode(mode: CursorPermissionMode | undefined): boolean { + return mode === 'autoReview'; +} + function resolveAcpModeConfigValue( mode: CursorPermissionMode | undefined, backend: AcpSdkBackend, @@ -28,7 +34,7 @@ function resolveAcpModeConfigValue( if (optionValues.includes(acpMode)) { return acpMode; } - if (mode === 'yolo' || mode === 'default') { + if (mode === 'yolo' || mode === 'default' || mode === 'autoReview') { if (optionValues.includes('agent')) { return 'agent'; } diff --git a/cli/src/runner/buildCliArgs.test.ts b/cli/src/runner/buildCliArgs.test.ts index d37c699f..7d90e489 100644 --- a/cli/src/runner/buildCliArgs.test.ts +++ b/cli/src/runner/buildCliArgs.test.ts @@ -93,7 +93,7 @@ describe('buildCliArgs', () => { }) it('validates all known permission modes', () => { - for (const mode of ['default', 'acceptEdits', 'auto', 'bypassPermissions', 'plan', 'ask', 'read-only', 'safe-yolo', 'yolo']) { + for (const mode of ['default', 'acceptEdits', 'auto', 'bypassPermissions', 'plan', 'ask', 'debug', 'autoReview', 'read-only', 'safe-yolo', 'yolo']) { const args = buildCliArgs('claude', { directory: '/tmp', permissionMode: mode, @@ -103,6 +103,34 @@ describe('buildCliArgs', () => { } }) + it('passes --cursor-worktree for cursor worktree sessions', () => { + const args = buildCliArgs('cursor', { + directory: '/tmp/repo', + sessionType: 'worktree', + worktreeName: 'feature-x', + }) + expect(args).toContain('--cursor-worktree') + expect(args).toContain('feature-x') + }) + + it('passes bare --cursor-worktree when name is omitted', () => { + const args = buildCliArgs('cursor', { + directory: '/tmp/repo', + sessionType: 'worktree', + }) + expect(args).toContain('--cursor-worktree') + expect(args[args.length - 1]).toBe('--cursor-worktree') + }) + + it('does not pass --cursor-worktree for non-cursor worktree sessions', () => { + const args = buildCliArgs('claude', { + directory: '/tmp/repo', + sessionType: 'worktree', + worktreeName: 'feature-x', + }) + expect(args).not.toContain('--cursor-worktree') + }) + it('uses --session-id for pi resume (not --resume)', () => { const args = buildCliArgs('pi', { directory: '/tmp', diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index a9b3403b..c696dd44 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -336,20 +336,27 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): } if (sessionType === 'worktree') { - const worktreeResult = await createWorktree({ - basePath: directory, - nameHint: worktreeName - }); - if (!worktreeResult.ok) { - logger.debug(`[RUNNER RUN] Worktree creation failed: ${worktreeResult.error}`); - return { - type: 'error', - errorMessage: worktreeResult.error - }; + // Cursor Agent has native `--worktree` under ~/.cursor/worktrees/. Prefer that + // over HAPI's sibling-directory worktree so Cursor sandbox/skills see the same layout. + if (agent === 'cursor') { + spawnDirectory = directory; + logger.debug(`[RUNNER RUN] Cursor-native worktree requested (nameHint=${worktreeName ?? '(auto)'})`); + } else { + const worktreeResult = await createWorktree({ + basePath: directory, + nameHint: worktreeName + }); + if (!worktreeResult.ok) { + logger.debug(`[RUNNER RUN] Worktree creation failed: ${worktreeResult.error}`); + return { + type: 'error', + errorMessage: worktreeResult.error + }; + } + worktreeInfo = worktreeResult.info; + spawnDirectory = worktreeInfo.worktreePath; + logger.debug(`[RUNNER RUN] Created worktree ${worktreeInfo.worktreePath} (branch ${worktreeInfo.branch})`); } - worktreeInfo = worktreeResult.info; - spawnDirectory = worktreeInfo.worktreePath; - logger.debug(`[RUNNER RUN] Created worktree ${worktreeInfo.worktreePath} (branch ${worktreeInfo.branch})`); } const cleanupWorktree = async () => { @@ -1125,5 +1132,12 @@ export function buildCliArgs( args.push('--yolo'); } } + if (agent === 'cursor' && options.sessionType === 'worktree') { + args.push('--cursor-worktree'); + const name = options.worktreeName?.trim(); + if (name) { + args.push(name); + } + } return args; } diff --git a/docs/guide/cursor.md b/docs/guide/cursor.md index c7ab1238..86d1897c 100644 --- a/docs/guide/cursor.md +++ b/docs/guide/cursor.md @@ -23,8 +23,11 @@ hapi cursor resume # Resume a specific chat hapi cursor --continue # Resume the most recent chat hapi cursor --mode plan # Start in Plan mode hapi cursor --mode ask # Start in Ask mode +hapi cursor --auto-review # Start with Auto-review (Smart Auto) hapi cursor --yolo # Bypass approval prompts (--force) hapi cursor --model # Specify model +hapi cursor --cursor-worktree feature-x # Cursor-native worktree +hapi cursor --cursor-add-dir ../shared # Extra workspace root (repeatable) ``` ## Permission Modes @@ -34,9 +37,25 @@ hapi cursor --model # Specify model | `default` | Standard agent behavior | | `plan` | Plan mode - design approach before coding | | `ask` | Ask mode - explore code without edits | +| `debug` | Debug mode - hypotheses + instrumentation | +| `autoReview` | Auto-review (Smart Auto) - allowlist/sandbox/classifier instead of full YOLO | | `yolo` | Bypass approval prompts | -Set mode via `--mode` flag or change from the web UI during a session. +Set mode via `--mode` / `--permission-mode` / `--auto-review`, or change from the web UI during a session. + +## Cursor-native worktree & multi-root + +- New Session **Worktree** for Cursor uses Cursor's `--worktree` (`~/.cursor/worktrees//`), not HAPI's sibling-directory worktree. +- Mid-session: send `/worktree`, `/apply-worktree`, `/delete-worktree`, or `/add-dir ` (isolated pass-through). +- CLI: `hapi cursor --cursor-worktree feature-x --cursor-add-dir ../shared` + +## Slash pass-through (remote) + +These commands are isolated in the queue and forwarded to the agent (ACP prompt or legacy `-p`): + +`/compress` `/summarize` `/compact` `/model` `/multitask` `/best-of-n` `/worktree` `/apply-worktree` `/delete-worktree` `/add-dir` `/context` `/fork` `/auto-review` + +Interactive TUI-only commands (`/config`, `/mcp`, `/sandbox`, `/btw`, `/rewind`, …) are not supported remotely. ## Modes @@ -45,6 +64,7 @@ Set mode via `--mode` flag or change from the web UI during a session. ## Limitations +- **Multitask UI** - `/multitask` is slash-driven; HAPI does not yet provide an Agents Window-style fleet pane. Subagent `cursor/task` notifications show as CursorTask cards when the agent emits them. - **Legacy sessions** - Cursor sessions created before the ACP migration can still resume temporarily via stream-json. Start a new Cursor session to get ACP permissions, plans, todos, and question support. - **Session resume** - ACP sessions resume through `session/load`. Old stream-json `session_id` values are not loadable via ACP; those sessions keep using the legacy path until you start fresh. diff --git a/shared/src/modes.test.ts b/shared/src/modes.test.ts index 3ef31933..53e23be4 100644 --- a/shared/src/modes.test.ts +++ b/shared/src/modes.test.ts @@ -65,6 +65,14 @@ describe('isPermissionModeAllowedForFlavor', () => { expect(isPermissionModeAllowedForFlavor('safe-yolo', 'pi')).toBe(false) expect(isPermissionModeAllowedForFlavor('ask', 'pi')).toBe(false) }) + + test("cursor includes autoReview", () => { + expect(getPermissionModesForFlavor('cursor')).toContain('autoReview') + expect(getPermissionModeLabel('autoReview')).toBe('Auto-review') + expect(getPermissionModeTone('autoReview')).toBe('warning') + expect(isPermissionModeAllowedForFlavor('autoReview', 'cursor')).toBe(true) + expect(isPermissionModeAllowedForFlavor('autoReview', 'claude')).toBe(false) + }) }) describe('getPermissionModeLabel', () => { diff --git a/shared/src/modes.ts b/shared/src/modes.ts index 73209677..c4e0125c 100644 --- a/shared/src/modes.ts +++ b/shared/src/modes.ts @@ -37,7 +37,7 @@ export type KimiPermissionMode = typeof KIMI_PERMISSION_MODES[number] export const OPENCODE_PERMISSION_MODES = ['default', 'plan', 'yolo'] as const export type OpencodePermissionMode = typeof OPENCODE_PERMISSION_MODES[number] -export const CURSOR_PERMISSION_MODES = ['default', 'plan', 'ask', 'debug', 'yolo'] as const +export const CURSOR_PERMISSION_MODES = ['default', 'plan', 'ask', 'debug', 'autoReview', 'yolo'] as const export type CursorPermissionMode = typeof CURSOR_PERMISSION_MODES[number] export const PERMISSION_MODES = [ @@ -48,6 +48,7 @@ export const PERMISSION_MODES = [ 'plan', 'ask', 'debug', + 'autoReview', 'read-only', 'safe-yolo', 'yolo' @@ -62,6 +63,7 @@ export const PERMISSION_MODE_LABELS: Record = { plan: 'Plan Mode', ask: 'Ask Mode', debug: 'Debug Mode', + autoReview: 'Auto-review', bypassPermissions: 'Yolo', 'read-only': 'Read Only', 'safe-yolo': 'Safe Yolo', @@ -77,6 +79,7 @@ export const PERMISSION_MODE_TONES: Record = plan: 'info', ask: 'info', debug: 'info', + autoReview: 'warning', bypassPermissions: 'danger', 'read-only': 'warning', 'safe-yolo': 'warning', diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 245be7a8..422b0a70 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -225,7 +225,7 @@ export default { 'newSession.type.simple': 'Simple', 'newSession.type.simple.desc': 'Use selected directory as-is', 'newSession.type.worktree': 'Worktree', - 'newSession.type.worktree.desc': 'Create a new worktree next to repo', + 'newSession.type.worktree.desc': 'Create a new worktree next to repo (Cursor uses native ~/.cursor/worktrees)', 'newSession.type.worktree.placeholder': 'feature-x (default 1228-xxxx)', 'newSession.agent': 'Agent', 'newSession.model': 'Model', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index a9664838..f89574c8 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -229,7 +229,7 @@ export default { 'newSession.type.simple': '简单', 'newSession.type.simple.desc': '直接使用选定的目录', 'newSession.type.worktree': '工作树', - 'newSession.type.worktree.desc': '在仓库旁创建新工作树', + 'newSession.type.worktree.desc': '在仓库旁创建新工作树(Cursor 使用原生 ~/.cursor/worktrees)', 'newSession.type.worktree.placeholder': 'feature-x (默认 1228-xxxx)', 'newSession.agent': '代理', 'newSession.model': '模型',