@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.
- package/CHANGELOG.md +15 -0
- package/package.json +6 -6
- package/scripts/format-prompts.ts +65 -23
- package/src/commit/agentic/prompts/session-user.md +0 -1
- package/src/commit/agentic/prompts/split-confirm.md +1 -1
- package/src/commit/agentic/prompts/system.md +1 -1
- package/src/commit/prompts/analysis-system.md +23 -26
- package/src/commit/prompts/analysis-user.md +1 -1
- package/src/commit/prompts/changelog-system.md +1 -2
- package/src/commit/prompts/changelog-user.md +1 -2
- package/src/commit/prompts/file-observer-system.md +1 -3
- package/src/commit/prompts/file-observer-user.md +1 -2
- package/src/commit/prompts/reduce-system.md +16 -16
- package/src/commit/prompts/reduce-user.md +1 -1
- package/src/commit/prompts/summary-retry.md +1 -2
- package/src/commit/prompts/summary-system.md +10 -10
- package/src/commit/prompts/summary-user.md +1 -1
- package/src/commit/prompts/types-description.md +1 -1
- package/src/config/keybindings.ts +3 -0
- package/src/config/settings-manager.ts +5 -0
- package/src/internal-urls/index.ts +1 -0
- package/src/internal-urls/plan-protocol.ts +95 -0
- package/src/modes/components/status-line/presets.ts +7 -7
- package/src/modes/components/status-line/segments.ts +16 -0
- package/src/modes/components/status-line/types.ts +4 -0
- package/src/modes/components/status-line-segment-editor.ts +1 -0
- package/src/modes/components/status-line.ts +16 -2
- package/src/modes/controllers/command-controller.ts +42 -0
- package/src/modes/controllers/event-controller.ts +13 -0
- package/src/modes/controllers/input-controller.ts +16 -0
- package/src/modes/interactive-mode.ts +219 -1
- package/src/modes/theme/theme.ts +7 -0
- package/src/modes/types.ts +7 -0
- package/src/patch/index.ts +9 -3
- package/src/plan-mode/state.ts +6 -0
- package/src/prompts/agents/explore.md +1 -1
- package/src/prompts/agents/frontmatter.md +1 -1
- package/src/prompts/agents/init.md +1 -1
- package/src/prompts/agents/plan.md +33 -49
- package/src/prompts/agents/reviewer.md +7 -7
- package/src/prompts/agents/task.md +1 -2
- package/src/prompts/compaction/branch-summary-preamble.md +1 -1
- package/src/prompts/compaction/branch-summary.md +3 -1
- package/src/prompts/compaction/compaction-summary.md +3 -1
- package/src/prompts/compaction/compaction-turn-prefix.md +2 -1
- package/src/prompts/compaction/compaction-update-summary.md +3 -1
- package/src/prompts/review-request.md +4 -1
- package/src/prompts/system/custom-system-prompt.md +8 -8
- package/src/prompts/system/file-operations.md +1 -1
- package/src/prompts/system/plan-mode-active.md +113 -0
- package/src/prompts/system/plan-mode-approved.md +16 -0
- package/src/prompts/system/plan-mode-reference.md +14 -0
- package/src/prompts/system/plan-mode-subagent.md +36 -0
- package/src/prompts/system/summarization-system.md +1 -1
- package/src/prompts/system/system-prompt.md +17 -27
- package/src/prompts/system/title-system.md +1 -1
- package/src/prompts/system/ttsr-interrupt.md +1 -1
- package/src/prompts/system/web-search.md +1 -1
- package/src/prompts/tools/ask.md +1 -3
- package/src/prompts/tools/bash.md +1 -1
- package/src/prompts/tools/calculator.md +1 -1
- package/src/prompts/tools/enter-plan-mode.md +92 -0
- package/src/prompts/tools/exit-plan-mode.md +38 -0
- package/src/prompts/tools/fetch.md +1 -1
- package/src/prompts/tools/find.md +1 -1
- package/src/prompts/tools/gemini-image.md +1 -1
- package/src/prompts/tools/grep.md +1 -1
- package/src/prompts/tools/lsp.md +1 -1
- package/src/prompts/tools/patch.md +1 -3
- package/src/prompts/tools/python.md +2 -4
- package/src/prompts/tools/read.md +1 -1
- package/src/prompts/tools/replace.md +16 -16
- package/src/prompts/tools/ssh.md +1 -4
- package/src/prompts/tools/task.md +1 -3
- package/src/prompts/tools/todo-write.md +13 -16
- package/src/prompts/tools/web-search.md +1 -1
- package/src/prompts/tools/write.md +1 -1
- package/src/sdk.ts +61 -10
- package/src/session/agent-session.ts +267 -0
- package/src/task/executor.ts +1 -0
- package/src/task/index.ts +18 -4
- package/src/tools/enter-plan-mode.ts +76 -0
- package/src/tools/exit-plan-mode.ts +62 -0
- package/src/tools/find.ts +5 -2
- package/src/tools/grep.ts +13 -12
- package/src/tools/index.ts +19 -1
- package/src/tools/plan-mode-guard.ts +46 -0
- package/src/tools/read.ts +8 -4
- package/src/tools/write.ts +3 -2
- package/src/utils/tools-manager.ts +38 -9
- package/src/web/search/providers/perplexity.ts +3 -1
- 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
|
|
276
|
-
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
|
}
|
package/src/modes/theme/theme.ts
CHANGED
|
@@ -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"],
|
package/src/modes/types.ts
CHANGED
|
@@ -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>;
|