@oh-my-pi/pi-coding-agent 8.4.0 → 8.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/package.json +6 -6
  3. package/scripts/format-prompts.ts +65 -23
  4. package/src/commit/agentic/prompts/session-user.md +0 -1
  5. package/src/commit/agentic/prompts/split-confirm.md +1 -1
  6. package/src/commit/agentic/prompts/system.md +1 -1
  7. package/src/commit/prompts/analysis-system.md +23 -26
  8. package/src/commit/prompts/analysis-user.md +1 -1
  9. package/src/commit/prompts/changelog-system.md +1 -2
  10. package/src/commit/prompts/changelog-user.md +1 -2
  11. package/src/commit/prompts/file-observer-system.md +1 -3
  12. package/src/commit/prompts/file-observer-user.md +1 -2
  13. package/src/commit/prompts/reduce-system.md +16 -16
  14. package/src/commit/prompts/reduce-user.md +1 -1
  15. package/src/commit/prompts/summary-retry.md +1 -2
  16. package/src/commit/prompts/summary-system.md +10 -10
  17. package/src/commit/prompts/summary-user.md +1 -1
  18. package/src/commit/prompts/types-description.md +1 -1
  19. package/src/config/keybindings.ts +3 -0
  20. package/src/config/settings-manager.ts +5 -0
  21. package/src/internal-urls/index.ts +1 -0
  22. package/src/internal-urls/plan-protocol.ts +95 -0
  23. package/src/modes/components/status-line/presets.ts +7 -7
  24. package/src/modes/components/status-line/segments.ts +16 -0
  25. package/src/modes/components/status-line/types.ts +4 -0
  26. package/src/modes/components/status-line-segment-editor.ts +1 -0
  27. package/src/modes/components/status-line.ts +16 -2
  28. package/src/modes/controllers/command-controller.ts +42 -0
  29. package/src/modes/controllers/event-controller.ts +13 -0
  30. package/src/modes/controllers/input-controller.ts +16 -0
  31. package/src/modes/interactive-mode.ts +219 -1
  32. package/src/modes/theme/theme.ts +7 -0
  33. package/src/modes/types.ts +7 -0
  34. package/src/patch/index.ts +9 -3
  35. package/src/plan-mode/state.ts +6 -0
  36. package/src/prompts/agents/explore.md +1 -1
  37. package/src/prompts/agents/frontmatter.md +1 -1
  38. package/src/prompts/agents/init.md +1 -1
  39. package/src/prompts/agents/plan.md +33 -49
  40. package/src/prompts/agents/reviewer.md +7 -7
  41. package/src/prompts/agents/task.md +1 -2
  42. package/src/prompts/compaction/branch-summary-preamble.md +1 -1
  43. package/src/prompts/compaction/branch-summary.md +3 -1
  44. package/src/prompts/compaction/compaction-summary.md +3 -1
  45. package/src/prompts/compaction/compaction-turn-prefix.md +2 -1
  46. package/src/prompts/compaction/compaction-update-summary.md +3 -1
  47. package/src/prompts/review-request.md +4 -1
  48. package/src/prompts/system/custom-system-prompt.md +8 -8
  49. package/src/prompts/system/file-operations.md +1 -1
  50. package/src/prompts/system/plan-mode-active.md +113 -0
  51. package/src/prompts/system/plan-mode-approved.md +16 -0
  52. package/src/prompts/system/plan-mode-reference.md +14 -0
  53. package/src/prompts/system/plan-mode-subagent.md +36 -0
  54. package/src/prompts/system/summarization-system.md +1 -1
  55. package/src/prompts/system/system-prompt.md +17 -27
  56. package/src/prompts/system/title-system.md +1 -1
  57. package/src/prompts/system/ttsr-interrupt.md +1 -1
  58. package/src/prompts/system/web-search.md +1 -1
  59. package/src/prompts/tools/ask.md +1 -3
  60. package/src/prompts/tools/bash.md +1 -1
  61. package/src/prompts/tools/calculator.md +1 -1
  62. package/src/prompts/tools/enter-plan-mode.md +92 -0
  63. package/src/prompts/tools/exit-plan-mode.md +38 -0
  64. package/src/prompts/tools/fetch.md +1 -1
  65. package/src/prompts/tools/find.md +1 -1
  66. package/src/prompts/tools/gemini-image.md +1 -1
  67. package/src/prompts/tools/grep.md +1 -1
  68. package/src/prompts/tools/lsp.md +1 -1
  69. package/src/prompts/tools/patch.md +1 -3
  70. package/src/prompts/tools/python.md +2 -4
  71. package/src/prompts/tools/read.md +1 -1
  72. package/src/prompts/tools/replace.md +16 -16
  73. package/src/prompts/tools/ssh.md +1 -4
  74. package/src/prompts/tools/task.md +1 -3
  75. package/src/prompts/tools/todo-write.md +13 -16
  76. package/src/prompts/tools/web-search.md +1 -1
  77. package/src/prompts/tools/write.md +1 -1
  78. package/src/sdk.ts +61 -10
  79. package/src/session/agent-session.ts +267 -0
  80. package/src/task/executor.ts +1 -0
  81. package/src/task/index.ts +18 -4
  82. package/src/tools/enter-plan-mode.ts +76 -0
  83. package/src/tools/exit-plan-mode.ts +62 -0
  84. package/src/tools/find.ts +5 -2
  85. package/src/tools/grep.ts +13 -12
  86. package/src/tools/index.ts +19 -1
  87. package/src/tools/plan-mode-guard.ts +46 -0
  88. package/src/tools/read.ts +8 -4
  89. package/src/tools/write.ts +3 -2
  90. package/src/utils/tools-manager.ts +38 -9
  91. package/src/web/search/providers/perplexity.ts +3 -1
  92. package/src/web/search/types.ts +3 -1
@@ -3,7 +3,7 @@ import type { PresetDef, StatusLinePreset } from "./types";
3
3
  export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
4
4
  default: {
5
5
  // Matches current behavior
6
- leftSegments: ["pi", "model", "path", "git", "context_pct", "token_total", "cost"],
6
+ leftSegments: ["pi", "model", "plan_mode", "path", "git", "context_pct", "token_total", "cost"],
7
7
  rightSegments: [],
8
8
  separator: "powerline-thin",
9
9
  segmentOptions: {
@@ -15,7 +15,7 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
15
15
 
16
16
  minimal: {
17
17
  leftSegments: ["path", "git"],
18
- rightSegments: ["context_pct"],
18
+ rightSegments: ["plan_mode", "context_pct"],
19
19
  separator: "slash",
20
20
  segmentOptions: {
21
21
  path: { abbreviate: true, maxLength: 30 },
@@ -24,7 +24,7 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
24
24
  },
25
25
 
26
26
  compact: {
27
- leftSegments: ["model", "git"],
27
+ leftSegments: ["model", "plan_mode", "git"],
28
28
  rightSegments: ["cost", "context_pct"],
29
29
  separator: "powerline-thin",
30
30
  segmentOptions: {
@@ -34,7 +34,7 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
34
34
  },
35
35
 
36
36
  full: {
37
- leftSegments: ["pi", "hostname", "model", "path", "git", "subagents"],
37
+ leftSegments: ["pi", "hostname", "model", "plan_mode", "path", "git", "subagents"],
38
38
  rightSegments: ["token_in", "token_out", "cache_read", "cost", "context_pct", "time_spent", "time"],
39
39
  separator: "powerline",
40
40
  segmentOptions: {
@@ -47,7 +47,7 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
47
47
 
48
48
  nerd: {
49
49
  // Full preset with all Nerd Font icons
50
- leftSegments: ["pi", "hostname", "model", "path", "git", "session", "subagents"],
50
+ leftSegments: ["pi", "hostname", "model", "plan_mode", "path", "git", "session", "subagents"],
51
51
  rightSegments: [
52
52
  "token_in",
53
53
  "token_out",
@@ -70,7 +70,7 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
70
70
 
71
71
  ascii: {
72
72
  // No Nerd Font dependencies
73
- leftSegments: ["model", "path", "git"],
73
+ leftSegments: ["model", "plan_mode", "path", "git"],
74
74
  rightSegments: ["token_total", "cost", "context_pct"],
75
75
  separator: "ascii",
76
76
  segmentOptions: {
@@ -82,7 +82,7 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
82
82
 
83
83
  custom: {
84
84
  // User-defined - these are just defaults that get overridden
85
- leftSegments: ["model", "path", "git"],
85
+ leftSegments: ["model", "plan_mode", "path", "git"],
86
86
  rightSegments: ["token_total", "cost", "context_pct"],
87
87
  separator: "powerline-thin",
88
88
  segmentOptions: {},
@@ -71,6 +71,21 @@ const modelSegment: StatusLineSegment = {
71
71
  },
72
72
  };
73
73
 
74
+ const planModeSegment: StatusLineSegment = {
75
+ id: "plan_mode",
76
+ render(ctx) {
77
+ const status = ctx.planMode;
78
+ if (!status || (!status.enabled && !status.paused)) {
79
+ return { content: "", visible: false };
80
+ }
81
+
82
+ const label = status.paused ? "Plan ⏸" : "Plan";
83
+ const content = withIcon(theme.icon.plan, label);
84
+ const color = status.paused ? "warning" : "accent";
85
+ return { content: theme.fg(color, content), visible: true };
86
+ },
87
+ };
88
+
74
89
  const pathSegment: StatusLineSegment = {
75
90
  id: "path",
76
91
  render(ctx) {
@@ -322,6 +337,7 @@ const cacheWriteSegment: StatusLineSegment = {
322
337
  export const SEGMENTS: Record<StatusLineSegmentId, StatusLineSegment> = {
323
338
  pi: piSegment,
324
339
  model: modelSegment,
340
+ plan_mode: planModeSegment,
325
341
  path: pathSegment,
326
342
  git: gitSegment,
327
343
  subagents: subagentsSegment,
@@ -26,6 +26,10 @@ export interface SegmentContext {
26
26
  session: AgentSession;
27
27
  width: number;
28
28
  options: StatusLineSegmentOptions;
29
+ planMode: {
30
+ enabled: boolean;
31
+ paused: boolean;
32
+ } | null;
29
33
  // Cached values for performance (computed once per render)
30
34
  usageStats: {
31
35
  input: number;
@@ -17,6 +17,7 @@ import { ALL_SEGMENT_IDS } from "./status-line/segments";
17
17
  const SEGMENT_INFO: Record<StatusLineSegmentId, { label: string; short: string }> = {
18
18
  pi: { label: "Pi", short: "π icon" },
19
19
  model: { label: "Model", short: "model name" },
20
+ plan_mode: { label: "Plan Mode", short: "plan status" },
20
21
  path: { label: "Path", short: "working dir" },
21
22
  git: { label: "Git", short: "branch/status" },
22
23
  subagents: { label: "Agents", short: "subagent count" },
@@ -52,6 +52,7 @@ export class StatusLineComponent implements Component {
52
52
  private hookStatuses: Map<string, string> = new Map();
53
53
  private subagentCount: number = 0;
54
54
  private sessionStartTime: number = Date.now();
55
+ private planModeStatus: { enabled: boolean; paused: boolean } | null = null;
55
56
 
56
57
  // Git status caching (1s TTL)
57
58
  private cachedGitStatus: { staged: number; unstaged: number; untracked: number } | null = null;
@@ -79,6 +80,10 @@ export class StatusLineComponent implements Component {
79
80
  this.sessionStartTime = time;
80
81
  }
81
82
 
83
+ setPlanModeStatus(status: { enabled: boolean; paused: boolean } | undefined): void {
84
+ this.planModeStatus = status ?? null;
85
+ }
86
+
82
87
  setHookStatus(key: string, text: string | undefined): void {
83
88
  if (text === undefined) {
84
89
  this.hookStatuses.delete(key);
@@ -237,6 +242,7 @@ export class StatusLineComponent implements Component {
237
242
  session: this.session,
238
243
  width,
239
244
  options: this.resolveSettings().segmentOptions ?? {},
245
+ planMode: this.planModeStatus,
240
246
  usageStats,
241
247
  contextPercent,
242
248
  contextWindow,
@@ -256,6 +262,7 @@ export class StatusLineComponent implements Component {
256
262
  StatusLineSettings {
257
263
  const preset = this.settings.preset ?? "default";
258
264
  const presetDef = getPreset(preset);
265
+ const useCustomSegments = preset === "custom";
259
266
  const mergedSegmentOptions: StatusLineSettings["segmentOptions"] = {};
260
267
 
261
268
  for (const [segment, options] of Object.entries(presetDef.segmentOptions ?? {})) {
@@ -270,10 +277,17 @@ export class StatusLineComponent implements Component {
270
277
  };
271
278
  }
272
279
 
280
+ const leftSegments = useCustomSegments
281
+ ? (this.settings.leftSegments ?? presetDef.leftSegments)
282
+ : presetDef.leftSegments;
283
+ const rightSegments = useCustomSegments
284
+ ? (this.settings.rightSegments ?? presetDef.rightSegments)
285
+ : presetDef.rightSegments;
286
+
273
287
  return {
274
288
  ...this.settings,
275
- leftSegments: this.settings.leftSegments ?? presetDef.leftSegments,
276
- rightSegments: this.settings.rightSegments ?? presetDef.rightSegments,
289
+ leftSegments,
290
+ rightSegments,
277
291
  separator: this.settings.separator ?? presetDef.separator,
278
292
  segmentOptions: mergedSegmentOptions,
279
293
  };
@@ -339,6 +339,7 @@ export class CommandController {
339
339
 
340
340
  handleHotkeysCommand(): void {
341
341
  const expandToolsKey = this.ctx.keybindings.getDisplayString("expandTools") || "Ctrl+O";
342
+ const planModeKey = this.ctx.keybindings.getDisplayString("togglePlanMode") || "Alt+Shift+P";
342
343
  const hotkeys = `
343
344
  **Navigation**
344
345
  | Key | Action |
@@ -370,6 +371,7 @@ export class CommandController {
370
371
  | \`Shift+Ctrl+P\` | Cycle role models (temporary) |
371
372
  | \`Alt+P\` | Select model (temporary) |
372
373
  | \`Ctrl+L\` | Select model (set roles) |
374
+ | \`${planModeKey}\` | Toggle plan mode |
373
375
  | \`Ctrl+R\` | Search prompt history |
374
376
  | \`${expandToolsKey}\` | Toggle tool output expansion |
375
377
  | \`Ctrl+T\` | Toggle todo list expansion |
@@ -626,6 +628,46 @@ export class CommandController {
626
628
  }
627
629
  await this.ctx.flushCompactionQueue({ willRetry: false });
628
630
  }
631
+
632
+ async handleHandoffCommand(customInstructions?: string): Promise<void> {
633
+ const entries = this.ctx.sessionManager.getEntries();
634
+ const messageCount = entries.filter(e => e.type === "message").length;
635
+
636
+ if (messageCount < 2) {
637
+ this.ctx.showWarning("Nothing to hand off (no messages yet)");
638
+ return;
639
+ }
640
+
641
+ try {
642
+ // The agent will visibly generate the handoff document in chat
643
+ const result = await this.ctx.session.handoff(customInstructions);
644
+
645
+ if (!result) {
646
+ this.ctx.showError("Handoff cancelled");
647
+ return;
648
+ }
649
+
650
+ // Rebuild chat from the new session (which now contains the handoff document)
651
+ this.ctx.rebuildChatFromMessages();
652
+
653
+ this.ctx.statusLine.invalidate();
654
+ this.ctx.updateEditorTopBorder();
655
+ await this.ctx.reloadTodos();
656
+
657
+ this.ctx.chatContainer.addChild(new Spacer(1));
658
+ this.ctx.chatContainer.addChild(
659
+ new Text(`${theme.fg("accent", `${theme.status.success} New session started with handoff context`)}`, 1, 1),
660
+ );
661
+ } catch (error) {
662
+ const message = error instanceof Error ? error.message : String(error);
663
+ if (message === "Handoff cancelled" || (error instanceof Error && error.name === "AbortError")) {
664
+ this.ctx.showError("Handoff cancelled");
665
+ } else {
666
+ this.ctx.showError(`Handoff failed: ${message}`);
667
+ }
668
+ }
669
+ this.ctx.ui.requestRender();
670
+ }
629
671
  }
630
672
 
631
673
  const BAR_WIDTH = 24;
@@ -7,6 +7,7 @@ import { TtsrNotificationComponent } from "../../modes/components/ttsr-notificat
7
7
  import { getSymbolTheme, theme } from "../../modes/theme/theme";
8
8
  import type { InteractiveModeContext, TodoItem } from "../../modes/types";
9
9
  import type { AgentSessionEvent } from "../../session/agent-session";
10
+ import type { ExitPlanModeDetails } from "../../tools";
10
11
  import { detectNotificationProtocol, isNotificationSuppressed, sendNotification } from "../../utils/terminal-notify";
11
12
 
12
13
  export class EventController {
@@ -246,6 +247,18 @@ export class EventController {
246
247
  this.ctx.setTodos(details.todos);
247
248
  }
248
249
  }
250
+ if (event.toolName === "enter_plan_mode" && !event.isError) {
251
+ const details = event.result.details as import("../../tools").EnterPlanModeDetails | undefined;
252
+ if (details) {
253
+ await this.ctx.handleEnterPlanModeTool(details);
254
+ }
255
+ }
256
+ if (event.toolName === "exit_plan_mode" && !event.isError) {
257
+ const details = event.result.details as ExitPlanModeDetails | undefined;
258
+ if (details) {
259
+ await this.ctx.handleExitPlanModeTool(details);
260
+ }
261
+ }
249
262
  break;
250
263
  }
251
264
 
@@ -88,6 +88,11 @@ export class InputController {
88
88
  this.ctx.editor.setCustomKeyHandler(key, () => this.handleDequeue());
89
89
  }
90
90
 
91
+ const planModeKeys = this.ctx.keybindings.getKeys("togglePlanMode");
92
+ for (const key of planModeKeys) {
93
+ this.ctx.editor.setCustomKeyHandler(key, () => void this.ctx.handlePlanModeCommand());
94
+ }
95
+
91
96
  this.ctx.editor.onChange = (text: string) => {
92
97
  const wasBashMode = this.ctx.isBashMode;
93
98
  const wasPythonMode = this.ctx.isPythonMode;
@@ -180,6 +185,11 @@ export class InputController {
180
185
  this.ctx.editor.setText("");
181
186
  return;
182
187
  }
188
+ if (text === "/plan") {
189
+ await this.ctx.handlePlanModeCommand();
190
+ this.ctx.editor.setText("");
191
+ return;
192
+ }
183
193
  if (text === "/model" || text === "/models") {
184
194
  this.ctx.showModelSelector();
185
195
  this.ctx.editor.setText("");
@@ -265,6 +275,12 @@ export class InputController {
265
275
  await this.ctx.handleCompactCommand(customInstructions);
266
276
  return;
267
277
  }
278
+ if (text === "/handoff" || text.startsWith("/handoff ")) {
279
+ const customInstructions = text.startsWith("/handoff ") ? text.slice(9).trim() : undefined;
280
+ this.ctx.editor.setText("");
281
+ await this.ctx.handleHandoffCommand(customInstructions);
282
+ return;
283
+ }
268
284
  if (text === "/background" || text === "/bg") {
269
285
  this.ctx.editor.setText("");
270
286
  this.handleBackgroundCommand();
@@ -4,7 +4,8 @@
4
4
  */
5
5
  import * as path from "node:path";
6
6
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
7
- import type { AssistantMessage, ImageContent, Message, UsageReport } from "@oh-my-pi/pi-ai";
7
+ import type { AssistantMessage, ImageContent, Message, Model, UsageReport } from "@oh-my-pi/pi-ai";
8
+ import { resolvePlanUrlToPath } from "@oh-my-pi/pi-coding-agent/internal-urls";
8
9
  import type { Component, Loader, SlashCommand } from "@oh-my-pi/pi-tui";
9
10
  import {
10
11
  CombinedAutocompleteProvider,
@@ -18,14 +19,17 @@ import {
18
19
  import { isEnoent, logger, postmortem } from "@oh-my-pi/pi-utils";
19
20
  import chalk from "chalk";
20
21
  import { KeybindingsManager } from "../config/keybindings";
22
+ import { renderPromptTemplate } from "../config/prompt-templates";
21
23
  import type { SettingsManager } from "../config/settings-manager";
22
24
  import type { ExtensionUIContext } from "../extensibility/extensions";
23
25
  import type { CompactOptions } from "../extensibility/extensions/types";
24
26
  import { loadSlashCommands } from "../extensibility/slash-commands";
27
+ import planModeApprovedPrompt from "../prompts/system/plan-mode-approved.md" with { type: "text" };
25
28
  import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
26
29
  import { HistoryStorage } from "../session/history-storage";
27
30
  import type { SessionContext, SessionManager } from "../session/session-manager";
28
31
  import { getRecentSessions } from "../session/session-manager";
32
+ import type { EnterPlanModeDetails, ExitPlanModeDetails } from "../tools";
29
33
  import { setTerminalTitle } from "../utils/title-generator";
30
34
  import type { AssistantMessageComponent } from "./components/assistant-message";
31
35
  import type { BashExecutionComponent } from "./components/bash-execution";
@@ -87,6 +91,9 @@ export class InteractiveMode implements InteractiveModeContext {
87
91
  public isBashMode = false;
88
92
  public toolOutputExpanded = false;
89
93
  public todoExpanded = false;
94
+ public planModeEnabled = false;
95
+ public planModePaused = false;
96
+ public planModePlanFilePath: string | undefined = undefined;
90
97
  public todoItems: TodoItem[] = [];
91
98
  public hideThinkingBlock = false;
92
99
  public pendingImages: ImageContent[] = [];
@@ -124,6 +131,9 @@ export class InteractiveMode implements InteractiveModeContext {
124
131
  private cleanupUnsubscribe?: () => void;
125
132
  private readonly version: string;
126
133
  private readonly changelogMarkdown: string | undefined;
134
+ private planModePreviousTools: string[] | undefined;
135
+ private planModePreviousModel: Model<any> | undefined;
136
+ private planModeHasEntered = false;
127
137
  public readonly lspServers: Array<{ name: string; status: "ready" | "error"; fileTypes: string[] }> | undefined =
128
138
  undefined;
129
139
  public mcpManager?: import("@oh-my-pi/pi-coding-agent/mcp").MCPManager;
@@ -185,6 +195,7 @@ export class InteractiveMode implements InteractiveModeContext {
185
195
  // Define slash commands for autocomplete
186
196
  const slashCommands: SlashCommand[] = [
187
197
  { name: "settings", description: "Open settings menu" },
198
+ { name: "plan", description: "Toggle plan mode (agent plans before executing)" },
188
199
  { name: "model", description: "Select model (opens selector UI)" },
189
200
  { name: "export", description: "Export session to HTML file" },
190
201
  { name: "dump", description: "Copy session transcript to clipboard" },
@@ -202,6 +213,7 @@ export class InteractiveMode implements InteractiveModeContext {
202
213
  { name: "logout", description: "Logout from OAuth provider" },
203
214
  { name: "new", description: "Start a new session" },
204
215
  { name: "compact", description: "Manually compact the session context" },
216
+ { name: "handoff", description: "Hand off the session context to a new session" },
205
217
  { name: "background", description: "Detach UI and continue running in background" },
206
218
  { name: "bg", description: "Alias for /background" },
207
219
  { name: "resume", description: "Resume a different session" },
@@ -469,6 +481,208 @@ export class InteractiveMode implements InteractiveModeContext {
469
481
  this.renderTodoList();
470
482
  }
471
483
 
484
+ private getPlanFilePath(): string {
485
+ const sessionId = this.sessionManager.getSessionId();
486
+ return `plan://${sessionId}/plan.md`;
487
+ }
488
+
489
+ private resolvePlanFilePath(planFilePath: string): string {
490
+ if (planFilePath.startsWith("plan://")) {
491
+ return resolvePlanUrlToPath(planFilePath, {
492
+ getPlansDirectory: this.settingsManager.getPlansDirectory.bind(this.settingsManager),
493
+ cwd: this.sessionManager.getCwd(),
494
+ });
495
+ }
496
+ return planFilePath;
497
+ }
498
+
499
+ private updatePlanModeStatus(): void {
500
+ const status =
501
+ this.planModeEnabled || this.planModePaused
502
+ ? {
503
+ enabled: this.planModeEnabled,
504
+ paused: this.planModePaused,
505
+ }
506
+ : undefined;
507
+ this.statusLine.setPlanModeStatus(status);
508
+ this.updateEditorTopBorder();
509
+ this.ui.requestRender();
510
+ }
511
+
512
+ private async applyPlanModeModel(): Promise<void> {
513
+ const slowModel = this.session.resolveRoleModel("slow");
514
+ if (!slowModel) return;
515
+ const currentModel = this.session.model;
516
+ if (currentModel && currentModel.provider === slowModel.provider && currentModel.id === slowModel.id) {
517
+ return;
518
+ }
519
+ this.planModePreviousModel = currentModel;
520
+ try {
521
+ await this.session.setModelTemporary(slowModel);
522
+ } catch (error) {
523
+ this.showWarning(
524
+ `Failed to switch to slow model for plan mode: ${error instanceof Error ? error.message : String(error)}`,
525
+ );
526
+ }
527
+ }
528
+
529
+ private async enterPlanMode(options?: {
530
+ planFilePath?: string;
531
+ workflow?: "parallel" | "iterative";
532
+ }): Promise<void> {
533
+ if (this.planModeEnabled) {
534
+ return;
535
+ }
536
+
537
+ this.planModePaused = false;
538
+
539
+ const planFilePath = options?.planFilePath ?? this.getPlanFilePath();
540
+ const previousTools = this.session.getActiveToolNames();
541
+ const hasExitTool = this.session.getToolByName("exit_plan_mode") !== undefined;
542
+ const planTools = hasExitTool ? [...previousTools, "exit_plan_mode"] : previousTools;
543
+ const uniquePlanTools = [...new Set(planTools)];
544
+
545
+ this.planModePreviousTools = previousTools;
546
+ this.planModePlanFilePath = planFilePath;
547
+ this.planModeEnabled = true;
548
+
549
+ await this.session.setActiveToolsByName(uniquePlanTools);
550
+ this.session.setPlanModeState({
551
+ enabled: true,
552
+ planFilePath,
553
+ workflow: options?.workflow ?? "parallel",
554
+ reentry: this.planModeHasEntered,
555
+ });
556
+ this.planModeHasEntered = true;
557
+ await this.applyPlanModeModel();
558
+ this.updatePlanModeStatus();
559
+ this.showStatus(`Plan mode enabled. Plan file: ${planFilePath}`);
560
+ }
561
+
562
+ private async exitPlanMode(options?: { silent?: boolean; paused?: boolean }): Promise<void> {
563
+ if (!this.planModeEnabled) {
564
+ return;
565
+ }
566
+
567
+ const previousTools = this.planModePreviousTools;
568
+ if (previousTools && previousTools.length > 0) {
569
+ await this.session.setActiveToolsByName(previousTools);
570
+ }
571
+ if (this.planModePreviousModel) {
572
+ await this.session.setModelTemporary(this.planModePreviousModel);
573
+ }
574
+
575
+ this.session.setPlanModeState(undefined);
576
+ this.planModeEnabled = false;
577
+ this.planModePaused = options?.paused ?? false;
578
+ this.planModePlanFilePath = undefined;
579
+ this.planModePreviousTools = undefined;
580
+ this.planModePreviousModel = undefined;
581
+ this.updatePlanModeStatus();
582
+ if (!options?.silent) {
583
+ this.showStatus(this.planModePaused ? "Plan mode paused." : "Plan mode disabled.");
584
+ }
585
+ }
586
+
587
+ private async readPlanFile(planFilePath: string): Promise<string | null> {
588
+ const resolvedPath = this.resolvePlanFilePath(planFilePath);
589
+ try {
590
+ return await Bun.file(resolvedPath).text();
591
+ } catch (error) {
592
+ if (isEnoent(error)) {
593
+ return null;
594
+ }
595
+ throw error;
596
+ }
597
+ }
598
+
599
+ private renderPlanPreview(planContent: string): void {
600
+ this.chatContainer.addChild(new Spacer(1));
601
+ this.chatContainer.addChild(new DynamicBorder());
602
+ this.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "Plan Review")), 1, 1));
603
+ this.chatContainer.addChild(new Spacer(1));
604
+ this.chatContainer.addChild(new Markdown(planContent, 1, 1, getMarkdownTheme()));
605
+ this.chatContainer.addChild(new DynamicBorder());
606
+ this.ui.requestRender();
607
+ }
608
+
609
+ private async approvePlan(planContent: string): Promise<void> {
610
+ const previousTools = this.planModePreviousTools ?? this.session.getActiveToolNames();
611
+ await this.exitPlanMode({ silent: true, paused: false });
612
+ await this.handleClearCommand();
613
+ if (previousTools.length > 0) {
614
+ await this.session.setActiveToolsByName(previousTools);
615
+ }
616
+ this.session.markPlanReferenceSent();
617
+ const prompt = renderPromptTemplate(planModeApprovedPrompt, { planContent });
618
+ await this.session.prompt(prompt);
619
+ }
620
+
621
+ async handlePlanModeCommand(): Promise<void> {
622
+ if (this.planModeEnabled) {
623
+ const confirmed = await this.showHookConfirm(
624
+ "Exit plan mode?",
625
+ "This exits plan mode without approving a plan.",
626
+ );
627
+ if (!confirmed) return;
628
+ await this.exitPlanMode({ paused: true });
629
+ return;
630
+ }
631
+ await this.enterPlanMode();
632
+ }
633
+
634
+ async handleEnterPlanModeTool(details: EnterPlanModeDetails): Promise<void> {
635
+ if (this.planModeEnabled) {
636
+ this.showWarning("Plan mode is already active.");
637
+ return;
638
+ }
639
+
640
+ const confirmed = await this.showHookConfirm(
641
+ "Enter plan mode?",
642
+ "This enables read-only planning and creates a plan file for approval.",
643
+ );
644
+ if (!confirmed) {
645
+ return;
646
+ }
647
+
648
+ const planFilePath = details.planFilePath || this.getPlanFilePath();
649
+ this.planModePlanFilePath = planFilePath;
650
+ await this.enterPlanMode({ planFilePath, workflow: details.workflow });
651
+ }
652
+
653
+ async handleExitPlanModeTool(details: ExitPlanModeDetails): Promise<void> {
654
+ if (!this.planModeEnabled) {
655
+ this.showWarning("Plan mode is not active.");
656
+ return;
657
+ }
658
+
659
+ const planFilePath = details.planFilePath || this.planModePlanFilePath || this.getPlanFilePath();
660
+ this.planModePlanFilePath = planFilePath;
661
+ const planContent = await this.readPlanFile(planFilePath);
662
+ if (!planContent) {
663
+ this.showError(`Plan file not found at ${planFilePath}`);
664
+ return;
665
+ }
666
+
667
+ this.renderPlanPreview(planContent);
668
+ const choice = await this.showHookSelector("Plan mode - next step", [
669
+ "Approve and execute",
670
+ "Refine plan",
671
+ "Stay in plan mode",
672
+ ]);
673
+
674
+ if (choice === "Approve and execute") {
675
+ await this.approvePlan(planContent);
676
+ return;
677
+ }
678
+ if (choice === "Refine plan") {
679
+ const refinement = await this.showHookInput("What should be refined?");
680
+ if (refinement) {
681
+ this.editor.setText(refinement);
682
+ }
683
+ }
684
+ }
685
+
472
686
  stop(): void {
473
687
  if (this.loadingAnimation) {
474
688
  this.loadingAnimation.stop();
@@ -680,6 +894,10 @@ export class InteractiveMode implements InteractiveModeContext {
680
894
  return this.commandController.handleCompactCommand(customInstructions);
681
895
  }
682
896
 
897
+ handleHandoffCommand(customInstructions?: string): Promise<void> {
898
+ return this.commandController.handleHandoffCommand(customInstructions);
899
+ }
900
+
683
901
  executeCompaction(customInstructionsOrOptions?: string | CompactOptions, isAuto?: boolean): Promise<void> {
684
902
  return this.commandController.executeCompaction(customInstructionsOrOptions, isAuto);
685
903
  }
@@ -81,6 +81,7 @@ export type SymbolKey =
81
81
  | "sep.pipe"
82
82
  // Icons
83
83
  | "icon.model"
84
+ | "icon.plan"
84
85
  | "icon.folder"
85
86
  | "icon.file"
86
87
  | "icon.git"
@@ -278,6 +279,8 @@ const UNICODE_SYMBOLS: SymbolMap = {
278
279
  // Icons
279
280
  // pick: ◈ | alt: ◆ ⬢ ◇
280
281
  "icon.model": "◈",
282
+ // pick: 📋 | alt: 🗒 📝
283
+ "icon.plan": "📋",
281
284
  // pick: 📁 | alt: 📂 🗂 🗃
282
285
  "icon.folder": "📁",
283
286
  // pick: 📄 | alt: 📃 📝
@@ -517,6 +520,8 @@ const NERD_SYMBOLS: SymbolMap = {
517
520
  // Icons - Nerd Font specific
518
521
  // pick:  | alt:   ◆
519
522
  "icon.model": "\uec19",
523
+ // pick:  | alt:  
524
+ "icon.plan": "\uf2d2",
520
525
  // pick:  | alt:  
521
526
  "icon.folder": "\uf115",
522
527
  // pick:  | alt:  
@@ -705,6 +710,7 @@ const ASCII_SYMBOLS: SymbolMap = {
705
710
  "sep.pipe": " | ",
706
711
  // Icons
707
712
  "icon.model": "[M]",
713
+ "icon.plan": "plan",
708
714
  "icon.folder": "[D]",
709
715
  "icon.file": "[F]",
710
716
  "icon.git": "git:",
@@ -1397,6 +1403,7 @@ export class Theme {
1397
1403
  get icon() {
1398
1404
  return {
1399
1405
  model: this.symbols["icon.model"],
1406
+ plan: this.symbols["icon.plan"],
1400
1407
  folder: this.symbols["icon.folder"],
1401
1408
  file: this.symbols["icon.file"],
1402
1409
  git: this.symbols["icon.git"],
@@ -9,6 +9,7 @@ import type { MCPManager } from "../mcp";
9
9
  import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
10
10
  import type { HistoryStorage } from "../session/history-storage";
11
11
  import type { SessionContext, SessionManager } from "../session/session-manager";
12
+ import type { ExitPlanModeDetails } from "../tools";
12
13
  import type { AssistantMessageComponent } from "./components/assistant-message";
13
14
  import type { BashExecutionComponent } from "./components/bash-execution";
14
15
  import type { CustomEditor } from "./components/custom-editor";
@@ -58,6 +59,8 @@ export interface InteractiveModeContext {
58
59
  isBashMode: boolean;
59
60
  toolOutputExpanded: boolean;
60
61
  todoExpanded: boolean;
62
+ planModeEnabled: boolean;
63
+ planModePlanFilePath?: string;
61
64
  hideThinkingBlock: boolean;
62
65
  pendingImages: ImageContent[];
63
66
  compactionQueuedMessages: CompactionQueuedMessage[];
@@ -145,6 +148,7 @@ export interface InteractiveModeContext {
145
148
  handleBashCommand(command: string, excludeFromContext?: boolean): Promise<void>;
146
149
  handlePythonCommand(code: string, excludeFromContext?: boolean): Promise<void>;
147
150
  handleCompactCommand(customInstructions?: string): Promise<void>;
151
+ handleHandoffCommand(customInstructions?: string): Promise<void>;
148
152
  executeCompaction(customInstructionsOrOptions?: string | CompactOptions, isAuto?: boolean): Promise<void>;
149
153
  openInBrowser(urlOrPath: string): void;
150
154
 
@@ -173,6 +177,9 @@ export interface InteractiveModeContext {
173
177
  toggleThinkingBlockVisibility(): void;
174
178
  openExternalEditor(): void;
175
179
  registerExtensionShortcuts(): void;
180
+ handlePlanModeCommand(): Promise<void>;
181
+ handleEnterPlanModeTool(details: import("../tools").EnterPlanModeDetails): Promise<void>;
182
+ handleExitPlanModeTool(details: ExitPlanModeDetails): Promise<void>;
176
183
 
177
184
  // Hook UI methods
178
185
  initHooksAndCustomTools(): Promise<void>;