From 9c8bd3d21567d41748e07c7a9a39fbeea5a7763d Mon Sep 17 00:00:00 2001 From: t Date: Thu, 3 Sep 2026 09:35:59 +0800 Subject: [PATCH] feat(cli,desktop): /plan enters plan mode directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /mode plan already did this; /plan is the word Claude Code users type, and the parity table carried it as a deferred row for exactly that reason. Registered in both surfaces that own a slash registry — the CLI and the desktop palette — with the same three behaviours: bare /plan switches to plan mode, /plan off switches back to default, and a trailing prompt is refused with a pointer instead of silently dropped, because half-obeying a command misteaches what it did. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 6 ++++++ apps/cli/src/commands.test.ts | 19 +++++++++++++++++++ apps/cli/src/commands.ts | 18 ++++++++++++++++++ apps/desktop/src/lib/slash-commands.test.ts | 8 ++++++++ apps/desktop/src/lib/slash-commands.ts | 11 +++++++++++ docs/BEHAVIOR_PARITY.md | 2 +- 6 files changed, 63 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcf056d..f7f79cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the entries exact. A session restored from its log has only the text the model saw, so replayed cards degrade to today's plain-text body. +- **`/plan`** enters plan mode directly (`/plan off` leaves it) — the same + switch `/mode plan` already threw, one word shorter and matching what Claude + Code users type. A trailing prompt (`/plan do X`) is refused with a pointer + rather than silently dropped, because half-obeying a command teaches the + wrong lesson about what it did. + - **Persistent shells now last a whole CLI session, and `/shells` shows them.** The registry landed in #273 owned by a single `runAgent` call, which meant a shell opened in one turn was gone by the next — a slower `Bash` with extra diff --git a/apps/cli/src/commands.test.ts b/apps/cli/src/commands.test.ts index 3ac1e83..841430d 100644 --- a/apps/cli/src/commands.test.ts +++ b/apps/cli/src/commands.test.ts @@ -119,6 +119,25 @@ describe('built-in command behavior', () => { expect(ctx.mode).toBe('plan'); }); + it('/plan enters plan mode and /plan off leaves it', async () => { + const reg = new CommandRegistry(); + const ctx = makeContext(); + await reg.match('/plan')!.cmd.run([], ctx); + expect(ctx.mode).toBe('plan'); + await reg.match('/plan off')!.cmd.run(['off'], ctx); + expect(ctx.mode).toBe('default'); + }); + + it('/plan refuses a trailing prompt instead of silently dropping it', async () => { + const reg = new CommandRegistry(); + const ctx = makeContext(); + const out = await reg + .match('/plan refactor the auth flow')! + .cmd.run(['refactor', 'the', 'auth', 'flow'], ctx); + expect(out.join('\n')).toMatch(/takes no prompt/); + expect(ctx.mode).toBe('default'); // unchanged — nothing half-happened + }); + it('/effort switches when valid', async () => { const reg = new CommandRegistry(); const ctx = makeContext(); diff --git a/apps/cli/src/commands.ts b/apps/cli/src/commands.ts index 63abb84..891c19c 100644 --- a/apps/cli/src/commands.ts +++ b/apps/cli/src/commands.ts @@ -287,6 +287,23 @@ export const ModeCommand: SlashCommand = { }, }; +export const PlanCommand: SlashCommand = { + name: '/plan', + description: 'Enter plan mode (read-only exploring; same as /mode plan).', + run(args, ctx) { + // `/plan off` reads as the obvious way back out, so honor it rather than + // teaching one more incantation. Anything else after /plan is a mistake — + // the prompt itself is typed as a normal message once the mode is set. + if (args.length > 0 && args[0] !== 'off') { + return ['/plan takes no prompt. Enter plan mode first, then type your message.']; + } + const next = args[0] === 'off' ? 'default' : 'plan'; + if (ctx.mode === next) return [`Already in ${next} mode.`]; + ctx.mode = next; + return [`Mode switched to ${next}.`]; + }, +}; + // Effort tier UI metadata surfaced by `/effort` with no args. // why: the maxTokens/temperature numbers are NOT defined here — they are read // from EFFORT_PARAMS in @deepcode/core, the single source of truth the REPL and @@ -1440,6 +1457,7 @@ export const BUILTIN_COMMANDS: SlashCommand[] = [ StatusCommand, ModelCommand, ModeCommand, + PlanCommand, EffortCommand, CostCommand, ContextCommand, diff --git a/apps/desktop/src/lib/slash-commands.test.ts b/apps/desktop/src/lib/slash-commands.test.ts index aa44582..78c2540 100644 --- a/apps/desktop/src/lib/slash-commands.test.ts +++ b/apps/desktop/src/lib/slash-commands.test.ts @@ -63,6 +63,14 @@ describe('parseSlash', () => { expect(parseSlash('/effort max')).toEqual({ kind: 'set-effort', value: 'max' }); }); + it('/plan enters plan mode, /plan off leaves, a trailing prompt is refused', () => { + expect(parseSlash('/plan')).toEqual({ kind: 'set-mode', value: 'plan' }); + expect(parseSlash('/plan off')).toEqual({ kind: 'set-mode', value: 'default' }); + const r = parseSlash('/plan refactor auth'); + expect(r?.kind).toBe('error'); + expect(r && 'message' in r && r.message).toContain('takes no prompt'); + }); + it('rejects an invalid argument with the usage line, not silently', () => { const r = parseSlash('/effort ludicrous'); expect(r?.kind).toBe('error'); diff --git a/apps/desktop/src/lib/slash-commands.ts b/apps/desktop/src/lib/slash-commands.ts index a5d0c79..d9765a1 100644 --- a/apps/desktop/src/lib/slash-commands.ts +++ b/apps/desktop/src/lib/slash-commands.ts @@ -44,6 +44,7 @@ export const DESKTOP_COMMANDS: SlashCommand[] = [ args: '', summary: 'Switch approval mode (default, plan, acceptEdits, …)', }, + { name: '/plan', args: '[off]', summary: 'Enter plan mode (read-only); /plan off leaves it' }, { name: '/effort', args: '', summary: 'Switch effort tier (low … max)' }, { name: '/cost', summary: 'Spend and token usage this conversation' }, { name: '/context', summary: 'How much of the context window is used' }, @@ -122,6 +123,16 @@ export function parseSlash(input: string): SlashAction | null { return (MODES as string[]).includes(arg) ? { kind: 'set-mode', value: arg as AgentMode } : { kind: 'error', message: `Usage: /mode ${MODES.join(' | ')}` }; + case '/plan': + // Same switch /mode plan throws, one word shorter. A trailing prompt is + // refused rather than silently dropped — type it as a normal message. + if (arg === '' || arg === 'off') { + return { kind: 'set-mode', value: arg === 'off' ? 'default' : 'plan' }; + } + return { + kind: 'error', + message: '/plan takes no prompt — enter plan mode, then type your message.', + }; case '/effort': return (EFFORTS as string[]).includes(arg) ? { kind: 'set-effort', value: arg as Effort } diff --git a/docs/BEHAVIOR_PARITY.md b/docs/BEHAVIOR_PARITY.md index f96f176..56a41f8 100644 --- a/docs/BEHAVIOR_PARITY.md +++ b/docs/BEHAVIOR_PARITY.md @@ -53,7 +53,7 @@ Legend: `✅` matches · `🟡` matches with caveats · `🔄` deferred · `⚠ | `/batch` | ✓ | ✗ | 🔄 — batch-of-prompts not yet wired (use `/background` per prompt) | | `/tasks` | ✓ | ✓ | ✅ — lists this session's background tasks; `/tasks ` shows one's status + output | | `/shells` | ✗ | ✓ | 🆕 DeepCode-only — lists this session's persistent shells; `/shells close ` closes one | -| `/plan` | ✓ | ✗ | 🔄 — set via `/mode plan` in DeepCode | +| `/plan` | ✓ | ✓ | ✅ — `/plan` enters plan mode, `/plan off` leaves; `/mode plan` still works | | `/login` / `/logout` | ✓ | ✓ | ✅ — /logout clears creds + exits; /login stores a new key (next launch) | | `/export` | ✓ | ✓ | ✅ — writes the conversation to a markdown file | | `/bug` (alias `/feedback`) | ✓ | ✓ | ✅ — prints a prefilled GitHub issue link (model/mode/effort in the body) |