Skip to content

Commit ab28669

Browse files
committed
Merge branch 'improve-plan-mode'
2 parents 82a3754 + 6d2c973 commit ab28669

16 files changed

Lines changed: 483 additions & 72 deletions

packages/cli/src/cli-args.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ const EPILOG = [
4747
"Inside the TUI:",
4848
" enter Send the prompt",
4949
" shift+enter Insert a newline",
50+
" shift+tab Cycle Plan Mode for the next submitted prompt",
5051
" home/end Move within the current line",
5152
" alt+left/right Move by word",
5253
" ctrl+w Delete the previous word",
@@ -56,6 +57,7 @@ const EPILOG = [
5657
" / Open the skills/commands menu",
5758
" /skills List available skills",
5859
" /model Select model, thinking mode and effort control",
60+
" /plan Switch the input to Plan Mode",
5961
" /new Start a fresh conversation",
6062
" /init Initialize an AGENTS.md file with instructions for LLM",
6163
" /resume Pick a previous conversation to continue",

packages/cli/src/tests/prompt-input-keys.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ import {
2222
renderBufferWithCursor,
2323
buildInitPromptSubmission,
2424
buildPromptDraftFromSessionMessage,
25+
extractProposedPlan,
26+
getImplementationPrompt,
27+
getPlanImplementationChoice,
2528
disableTerminalExtendedKeys,
2629
enableTerminalExtendedKeys,
2730
EMPTY_BUFFER,
@@ -140,6 +143,28 @@ test("parseTerminalInput recognizes shifted return sequences", () => {
140143
assert.equal(key.meta, false);
141144
});
142145

146+
test("parseTerminalInput recognizes shift+tab for Plan Mode cycling", () => {
147+
const { input, key } = parseTerminalInput("\u001B[Z");
148+
assert.equal(input, "");
149+
assert.equal(key.tab, true);
150+
assert.equal(key.shift, true);
151+
});
152+
153+
test("extractProposedPlan only returns a complete non-empty plan", () => {
154+
assert.equal(extractProposedPlan("<proposed_plan>\nPlan\n</proposed_plan>"), "Plan");
155+
assert.equal(extractProposedPlan("<proposed_plan>Plan"), null);
156+
assert.equal(extractProposedPlan("<proposed_plan>\n</proposed_plan>"), null);
157+
});
158+
159+
test("getImplementationPrompt uses Chinese only above five full-width punctuation marks", () => {
160+
assert.equal(getImplementationPrompt(",、;。;"), "Implement the plan.");
161+
assert.equal(getImplementationPrompt(",、;。;。"), "实现此方案。");
162+
});
163+
164+
test("getPlanImplementationChoice treats escape as staying in Plan Mode", () => {
165+
assert.equal(getPlanImplementationChoice("", { escape: true, return: false }, 0), "stay");
166+
});
167+
143168
test("prompt return key action submits on plain enter", () => {
144169
const { key } = parseTerminalInput("\r");
145170
assert.equal(getPromptReturnKeyAction(key), "submit");

packages/cli/src/tests/slash-commands.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ test("buildSlashCommands prefixes skills before built-ins", () => {
2222
assert.deepEqual(builtinNames, [
2323
"skills",
2424
"model",
25+
"plan",
2526
"new",
2627
"init",
2728
"resume",
@@ -98,6 +99,13 @@ test("findExactSlashCommand returns built-in /model", () => {
9899
assert.equal(item?.kind, "model");
99100
});
100101

102+
test("findExactSlashCommand returns built-in /plan", () => {
103+
const items = buildSlashCommands(skills);
104+
const item = findExactSlashCommand(items, "/plan");
105+
assert.ok(item);
106+
assert.equal(item?.kind, "plan");
107+
});
108+
101109
test("findExactSlashCommand returns built-in /raw", () => {
102110
const items = buildSlashCommands(skills);
103111
const item = findExactSlashCommand(items, "/raw");

packages/cli/src/ui/core/slash-commands.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export type SlashCommandKind =
44
| "skill"
55
| "skills"
66
| "model"
7+
| "plan"
78
| "new"
89
| "init"
910
| "resume"
@@ -35,6 +36,12 @@ export const BUILTIN_SLASH_COMMANDS: SlashCommandItem[] = [
3536
label: "/model",
3637
description: "Select model, thinking mode and effort control",
3738
},
39+
{
40+
kind: "plan",
41+
name: "plan",
42+
label: "/plan",
43+
description: "Switch the input to Plan Mode",
44+
},
3845
{
3946
kind: "new",
4047
name: "new",

packages/cli/src/ui/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ export {
1515
} from "./hooks/cursor";
1616
export { default as AppContainer } from "./views/AppContainer";
1717
export { AskUserQuestionPrompt } from "./views/AskUserQuestionPrompt";
18+
export {
19+
PlanImplementationPrompt,
20+
extractProposedPlan,
21+
getImplementationPrompt,
22+
getPlanImplementationChoice,
23+
} from "./views/PlanImplementationPrompt";
1824
export { MessageView } from "./components";
1925
export { parseDiffPreview } from "./components/MessageView/utils";
2026
export {

packages/cli/src/ui/views/App.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
formatAskUserQuestionAnswers,
2121
} from "../core/ask-user-question";
2222
import { PermissionPrompt, type PermissionPromptResult } from "./PermissionPrompt";
23+
import { PlanImplementationPrompt, extractProposedPlan, getImplementationPrompt } from "./PlanImplementationPrompt";
2324
import { buildExitSummaryText, buildResumeHintText } from "../exit-summary";
2425
import { RawMode, useRawModeContext } from "../contexts";
2526
import { renderMessageToStdout } from "../components/MessageView/utils";
@@ -133,6 +134,8 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp
133134
const [nowTick, setNowTick] = useState(0);
134135
const [mcpStatuses, setMcpStatuses] = useState<ReturnType<typeof sessionManager.getMcpStatus>>([]);
135136
const [showProcessStdout, setShowProcessStdout] = useState(false);
137+
const [planMode, setPlanMode] = useState(false);
138+
const [pendingPlanImplementation, setPendingPlanImplementation] = useState<string | null>(null);
136139

137140
rawModeRef.current = mode;
138141
messagesRef.current = messages;
@@ -253,6 +256,8 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp
253256
setActiveStatus(null);
254257
setActiveAskPermissions(undefined);
255258
setPendingPermissionReply(null);
259+
setPlanMode(false);
260+
setPendingPlanImplementation(null);
256261
setDismissedQuestionIds(new Set());
257262
await resetStaticView([]);
258263
await refreshSkills();
@@ -369,6 +374,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp
369374
submission.selectedSkills && submission.selectedSkills.length > 0 ? submission.selectedSkills : undefined,
370375
permissions: submission.permissions,
371376
alwaysAllows: submission.alwaysAllows,
377+
planMode: submission.planMode ?? planMode,
372378
};
373379
const activeSessionId = sessionManager.getActiveSessionId();
374380
const permissionReply =
@@ -404,6 +410,12 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp
404410
}
405411
await refreshSkills();
406412
refreshSessionsList();
413+
const completedSession = sessionManager.getSession(sessionManager.getActiveSessionId() ?? "");
414+
const proposedPlan =
415+
prompt.planMode && completedSession?.status === "completed"
416+
? extractProposedPlan(completedSession.assistantReply)
417+
: null;
418+
setPendingPlanImplementation(proposedPlan);
407419
} catch (error) {
408420
const message = error instanceof Error ? error.message : String(error);
409421
setErrorLine(message);
@@ -425,6 +437,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp
425437
refreshSessionsList,
426438
navigateToSubView,
427439
resetToWelcome,
440+
planMode,
428441
]
429442
);
430443

@@ -496,6 +509,25 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp
496509
[handlePrompt]
497510
);
498511

512+
const handlePlanImplementationChoice = useCallback(
513+
(choice: "implement" | "stay" | "default") => {
514+
const proposedPlan = pendingPlanImplementation;
515+
setPendingPlanImplementation(null);
516+
if (choice === "stay") {
517+
return;
518+
}
519+
setPlanMode(false);
520+
if (choice === "implement" && proposedPlan) {
521+
handleSubmit({
522+
text: getImplementationPrompt(proposedPlan),
523+
imageUrls: [],
524+
planMode: false,
525+
});
526+
}
527+
},
528+
[handleSubmit, pendingPlanImplementation]
529+
);
530+
499531
const handleExitShortcut = useCallback(() => {
500532
handleExit({ showCommand: false, showSummary: false });
501533
}, [handleExit]);
@@ -517,6 +549,8 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp
517549
setRunningProcesses(session?.processes ?? null);
518550
setActiveStatus(session?.status ?? null);
519551
setActiveAskPermissions(session?.askPermissions);
552+
setPlanMode(session?.planMode === true);
553+
setPendingPlanImplementation(null);
520554
if (pendingPermissionReply && pendingPermissionReply.sessionId !== sessionId) {
521555
setPendingPermissionReply(null);
522556
}
@@ -965,6 +999,8 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp
965999
onSubmit={handlePermissionResult}
9661000
onCancel={handlePermissionCancel}
9671001
/>
1002+
) : pendingPlanImplementation && !busy ? (
1003+
<PlanImplementationPrompt onSelect={handlePlanImplementationChoice} />
9681004
) : isExiting ? null : (
9691005
<PromptInput
9701006
projectRoot={projectRoot}
@@ -986,6 +1022,8 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp
9861022
placeholder="Type your message..."
9871023
statusLineSegments={statusLineSegments}
9881024
statusLineSeparator={resolvedSettings.statusline.separator}
1025+
planMode={planMode}
1026+
onPlanModeChange={setPlanMode}
9891027
/>
9901028
)}
9911029
</Box>
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import React, { useEffect, useState } from "react";
2+
import { Box, Text } from "ink";
3+
import { useTerminalInput } from "../hooks";
4+
import type { InputKey } from "../hooks";
5+
6+
type PlanImplementationChoice = "implement" | "stay" | "default";
7+
8+
type Props = {
9+
onSelect: (choice: PlanImplementationChoice) => void;
10+
};
11+
12+
const CHOICES: Array<{ value: PlanImplementationChoice; label: string }> = [
13+
{ value: "implement", label: "implement this plan" },
14+
{ value: "stay", label: "stay in Plan mode" },
15+
{ value: "default", label: "switch to Default mode" },
16+
];
17+
18+
/** Return only a complete proposed plan, so historical or partial tags cannot trigger the chooser. */
19+
export function extractProposedPlan(reply: string | null): string | null {
20+
if (!reply) {
21+
return null;
22+
}
23+
const match = reply.match(/<proposed_plan>\s*([\s\S]*?\S[\s\S]*?)\s*<\/proposed_plan>/);
24+
return match?.[1] ?? null;
25+
}
26+
27+
export function getImplementationPrompt(plan: string): string {
28+
const fullWidthPunctuationCount = (plan.match(/[]/g) ?? []).length;
29+
return fullWidthPunctuationCount > 5 ? "实现此方案。" : "Implement the plan.";
30+
}
31+
32+
export function getPlanImplementationChoice(
33+
input: string,
34+
key: Pick<InputKey, "escape" | "return">,
35+
cursor: number
36+
): PlanImplementationChoice | null {
37+
if (key.escape) {
38+
return "stay";
39+
}
40+
if (input && /^[1-3]$/.test(input)) {
41+
return CHOICES[Number(input) - 1]!.value;
42+
}
43+
return key.return ? CHOICES[cursor]!.value : null;
44+
}
45+
46+
export function PlanImplementationPrompt({ onSelect }: Props): React.ReactElement {
47+
const [cursor, setCursor] = useState(0);
48+
49+
useEffect(() => {
50+
setCursor(0);
51+
}, []);
52+
53+
useTerminalInput((input, key) => {
54+
const choice = getPlanImplementationChoice(input, key, cursor);
55+
if (choice) {
56+
onSelect(choice);
57+
return;
58+
}
59+
if (key.upArrow) {
60+
setCursor((value) => Math.max(0, value - 1));
61+
return;
62+
}
63+
if (key.downArrow) {
64+
setCursor((value) => Math.min(CHOICES.length - 1, value + 1));
65+
return;
66+
}
67+
});
68+
69+
return (
70+
<Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1} marginY={1}>
71+
<Text color="yellow" bold>
72+
Plan ready
73+
</Text>
74+
<Text dimColor>Choose what to do next:</Text>
75+
<Box flexDirection="column" marginTop={1}>
76+
{CHOICES.map((choice, index) => (
77+
<Text key={choice.value} color={index === cursor ? "cyanBright" : undefined}>
78+
{index === cursor ? "> " : " "}
79+
{index + 1}. {choice.label}
80+
</Text>
81+
))}
82+
</Box>
83+
<Box marginTop={1}>
84+
<Text dimColor>1-3 select · ↑/↓ move · Enter select</Text>
85+
</Box>
86+
</Box>
87+
);
88+
}

packages/cli/src/ui/views/PromptInput.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export type PromptSubmission = {
7272
selectedSkills?: SkillInfo[];
7373
permissions?: UserToolPermission[];
7474
alwaysAllows?: PermissionScope[];
75+
planMode?: boolean;
7576
command?: "new" | "resume" | "continue" | "undo" | "mcp" | "exit";
7677
};
7778

@@ -96,9 +97,11 @@ type Props = {
9697
promptDraft?: PromptDraft | null;
9798
statusLineSegments?: StatusSegment[];
9899
statusLineSeparator?: string;
100+
planMode: boolean;
99101
onSubmit: (submission: PromptSubmission) => void;
100102
onModelConfigChange: (selection: ModelConfigSelection) => string | Promise<string>;
101103
onRawModeChange?: (mode: string) => void;
104+
onPlanModeChange: (enabled: boolean) => void;
102105
onInterrupt: () => void;
103106
onToggleProcessStdout?: () => void;
104107
onExitShortcut?: () => void;
@@ -129,12 +132,14 @@ export const PromptInput = React.memo(function PromptInput({
129132
promptDraft,
130133
statusLineSegments,
131134
statusLineSeparator,
135+
planMode,
132136
onSubmit,
133137
onModelConfigChange,
134138
onInterrupt,
135139
onToggleProcessStdout,
136140
onExitShortcut,
137141
onRawModeChange,
142+
onPlanModeChange,
138143
}: Props): React.ReactElement {
139144
const { stdout } = useStdout();
140145
const inputTextRef = useRef<DOMElement | null>(null);
@@ -226,9 +231,10 @@ export const PromptInput = React.memo(function PromptInput({
226231
screenWidth,
227232
cursorLayoutKey ?? "default",
228233
imageUrls.length,
234+
planMode ? "plan-mode" : "default-mode",
229235
selectedSkills.map((skill) => skill.name).join("\u001F"),
230236
].join("\u001E"),
231-
[cursorLayoutKey, imageUrls.length, screenWidth, selectedSkills]
237+
[cursorLayoutKey, imageUrls.length, planMode, screenWidth, selectedSkills]
232238
);
233239
useTerminalFocusReporting(stdout, !disabled);
234240
useTerminalExtendedKeys(stdout, !disabled);
@@ -429,6 +435,11 @@ export const PromptInput = React.memo(function PromptInput({
429435
const returnAction = getPromptReturnKeyAction(key);
430436
const isPlainReturn = returnAction === "submit";
431437

438+
if (key.shift && key.tab) {
439+
onPlanModeChange(!planMode);
440+
return;
441+
}
442+
432443
if (showFileMentionMenu) {
433444
if (key.upArrow || key.downArrow || key.tab || returnAction === "submit") {
434445
return;
@@ -678,6 +689,11 @@ export const PromptInput = React.memo(function PromptInput({
678689
setShowModelDropdown(true);
679690
return;
680691
}
692+
if (item.kind === "plan") {
693+
clearSlashToken();
694+
onPlanModeChange(true);
695+
return;
696+
}
681697
if (item.kind === "raw") {
682698
clearSlashToken();
683699
setOpenRawModelDropdown(true);
@@ -744,6 +760,7 @@ export const PromptInput = React.memo(function PromptInput({
744760
text: expandPasteMarkers(buffer.text, pastesRef.current),
745761
imageUrls,
746762
selectedSkills,
763+
planMode,
747764
});
748765
resetPromptInput();
749766
}
@@ -781,6 +798,12 @@ export const PromptInput = React.memo(function PromptInput({
781798
<Text dimColor> (use /skills to edit)</Text>
782799
</Box>
783800
) : null}
801+
{planMode ? (
802+
<Box width={screenWidth} justifyContent="flex-end">
803+
<Text color="yellow">💡 Plan mode</Text>
804+
<Text dimColor> (shift+tab to cycle)</Text>
805+
</Box>
806+
) : null}
784807
{/* Input */}
785808
<Box
786809
width={screenWidth}

0 commit comments

Comments
 (0)