@gavin-hu/my-pi-agent 0.16.0 → 0.17.0
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/README.md +14 -8
- package/README.zh-CN.md +14 -9
- package/extensions/bytefly/README.md +174 -101
- package/extensions/bytefly/footer/collect/entries.ts +1 -6
- package/extensions/bytefly/footer/collect/snapshot.ts +23 -9
- package/extensions/bytefly/footer/price/price.ts +12 -8
- package/extensions/bytefly/footer/register.ts +13 -1
- package/extensions/bytefly/footer/types.ts +11 -0
- package/extensions/bytefly/footer/unpriced.ts +36 -0
- package/extensions/bytefly/header/component.ts +15 -29
- package/extensions/bytefly/header/identity.ts +71 -0
- package/extensions/bytefly/header/register.ts +9 -3
- package/extensions/bytefly/index.ts +13 -5
- package/extensions/bytefly/message/format.ts +20 -13
- package/extensions/bytefly/message/register.ts +8 -7
- package/extensions/bytefly/panel/commands.ts +46 -25
- package/extensions/bytefly/panel/format.ts +49 -14
- package/extensions/bytefly/panel/join.ts +35 -9
- package/extensions/bytefly/panel/settings.ts +10 -27
- package/extensions/bytefly/panel/skills.ts +51 -0
- package/extensions/bytefly/panel/tui/extensions-view.ts +7 -61
- package/extensions/bytefly/panel/tui/frame.ts +9 -1
- package/extensions/bytefly/panel/tui/list-view.ts +72 -0
- package/extensions/bytefly/panel/tui/nav.ts +71 -3
- package/extensions/bytefly/panel/tui/settings-section.ts +250 -0
- package/extensions/bytefly/panel/tui/skills-view.ts +50 -0
- package/extensions/bytefly/panel/tui.ts +223 -331
- package/extensions/bytefly/panel/types.ts +26 -8
- package/extensions/bytefly/picker/discovery.ts +32 -27
- package/extensions/bytefly/picker/resources.ts +16 -8
- package/extensions/bytefly/picker/runtime.ts +62 -34
- package/extensions/bytefly/picker/toggle.ts +16 -10
- package/extensions/bytefly/picker/types.ts +31 -13
- package/extensions/bytefly/update/format.ts +66 -8
- package/extensions/bytefly/update/patch.ts +66 -0
- package/extensions/bytefly/update/register.ts +4 -11
- package/extensions/bytefly/update/release.ts +65 -0
- package/extensions/bytefly/update/state.ts +14 -0
- package/extensions/bytefly/update/suppress.ts +47 -29
- package/extensions/bytefly/update/types.ts +15 -5
- package/extensions/check/tui.ts +2 -0
- package/extensions/context/README.md +21 -11
- package/extensions/context/format.ts +10 -8
- package/extensions/context/report.ts +21 -8
- package/extensions/context/tui.ts +26 -10
- package/extensions/context/types.ts +5 -3
- package/extensions/file-browse/README.md +29 -1
- package/extensions/file-browse/client.ts +16 -463
- package/extensions/file-browse/html.ts +7 -5
- package/extensions/file-browse/palette.ts +224 -0
- package/extensions/file-browse/router.ts +2 -2
- package/extensions/file-browse/styles.ts +8 -3
- package/extensions/file-browse/tree.ts +381 -0
- package/extensions/file-read/README.md +4 -1
- package/extensions/file-read/extract/odt.ts +7 -5
- package/extensions/goal/tools.ts +6 -11
- package/extensions/job/index.ts +20 -45
- package/extensions/job/tools.ts +51 -77
- package/extensions/memory/tools.ts +4 -6
- package/extensions/memory/tui.ts +5 -1
- package/extensions/plan/README.md +88 -33
- package/extensions/plan/commands.ts +19 -4
- package/extensions/plan/config.ts +27 -0
- package/extensions/plan/plan-index.ts +3 -1
- package/extensions/plan/plans.ts +31 -13
- package/extensions/plan/review.ts +34 -14
- package/extensions/plan/runtime.ts +3 -1
- package/extensions/plan/steps.ts +43 -14
- package/extensions/plan/tools/cancel.ts +79 -0
- package/extensions/plan/tools/enter.ts +10 -10
- package/extensions/plan/tools/exit.ts +54 -43
- package/extensions/plan/tools/steps.ts +7 -19
- package/extensions/plan/tools/write.ts +22 -29
- package/extensions/plan/tools.ts +5 -2
- package/extensions/plan/tui.ts +9 -7
- package/extensions/plan/types.ts +8 -0
- package/extensions/plan/web-chrome.ts +101 -0
- package/extensions/plan/web-client.ts +98 -311
- package/extensions/plan/web-markdown.ts +222 -0
- package/extensions/plan/web-page.ts +28 -2
- package/extensions/plan/web-router.ts +2 -2
- package/extensions/rewind/checklist.ts +4 -0
- package/extensions/rewind/review-tui.ts +3 -1
- package/extensions/rewind/rewind-tui.ts +4 -0
- package/extensions/subagent/README.md +25 -12
- package/extensions/subagent/format.ts +72 -25
- package/extensions/subagent/index.ts +21 -47
- package/extensions/subagent/orchestrate.ts +18 -41
- package/extensions/subagent/render.ts +29 -42
- package/extensions/subagent/stream.ts +24 -18
- package/extensions/subagent/types.ts +7 -0
- package/extensions/todo/tools.ts +5 -6
- package/extensions/todo/tui.ts +5 -1
- package/extensions/watch/index.ts +10 -35
- package/extensions/watch/tools.ts +31 -59
- package/extensions/wechat/README.md +40 -18
- package/extensions/wechat/bridge.ts +38 -40
- package/extensions/wechat/client.ts +1 -4
- package/extensions/wechat/commands.ts +1 -1
- package/extensions/wechat/interaction.ts +25 -4
- package/extensions/wechat/login-screen.ts +2 -3
- package/extensions/wechat/notices.ts +7 -5
- package/extensions/wechat/prompt.ts +19 -4
- package/extensions/wechat/qrcode.ts +9 -9
- package/extensions/wechat/slash.ts +14 -0
- package/extensions/wechat/status-screen.ts +2 -2
- package/extensions/wechat/tools.ts +3 -11
- package/extensions/worktree/README.md +48 -12
- package/extensions/worktree/commands.ts +109 -65
- package/extensions/worktree/enter.ts +1 -1
- package/extensions/worktree/exit.ts +11 -1
- package/extensions/worktree/lifecycle.ts +9 -1
- package/extensions/worktree/screen.ts +263 -0
- package/extensions/worktree/status.ts +84 -38
- package/lib/README.md +7 -6
- package/lib/config.ts +12 -0
- package/lib/format.ts +6 -2
- package/lib/interaction.ts +18 -1
- package/lib/report.ts +75 -2
- package/lib/tool-result.ts +54 -4
- package/lib/tui/README.md +16 -8
- package/lib/tui/chrome.ts +27 -6
- package/lib/tui/dock-screen.ts +6 -2
- package/lib/tui/gauge.ts +9 -5
- package/lib/usage.ts +26 -0
- package/lib/web/README.md +131 -0
- package/lib/web/base.ts +19 -2
- package/lib/web/components.ts +10 -6
- package/lib/web/layout.ts +2 -0
- package/lib/web/markup.ts +2 -1
- package/lib/web/runtime.ts +103 -25
- package/lib/web/tokens.ts +23 -7
- package/package.json +3 -1
- package/skills/changelog/SKILL.md +57 -45
- package/skills/next/SKILL.md +184 -0
|
@@ -22,6 +22,7 @@ const ENABLED_NOTICE = `Plan mode enabled — ${READ_ONLY_SUMMARY}.`;
|
|
|
22
22
|
const DISABLED_NOTICE = "Plan mode disabled — full access restored.";
|
|
23
23
|
const REVIEW_PREFIX = "review";
|
|
24
24
|
const LIST_PREFIX = "list";
|
|
25
|
+
const CANCEL_PREFIX = "cancel";
|
|
25
26
|
|
|
26
27
|
/** Notice text for the current mode; shared by `/plan` and the `Ctrl+Alt+P` shortcut. */
|
|
27
28
|
export function planModeNotice(enabled: boolean): string {
|
|
@@ -49,6 +50,11 @@ function isListSubcommand(prompt: string): boolean {
|
|
|
49
50
|
return prompt.toLowerCase() === LIST_PREFIX;
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
/** Exact `cancel` leaves plan mode without approving; a longer sentence stays a task. */
|
|
54
|
+
function isCancelSubcommand(prompt: string): boolean {
|
|
55
|
+
return prompt.toLowerCase() === CANCEL_PREFIX;
|
|
56
|
+
}
|
|
57
|
+
|
|
52
58
|
/**
|
|
53
59
|
* Whether a command line is the reserved `review` subcommand. Bare `review`
|
|
54
60
|
* always is; a path after it is, but `review the auth flow` stays a task, so a
|
|
@@ -167,18 +173,27 @@ export function registerCommands(
|
|
|
167
173
|
): void {
|
|
168
174
|
pi.registerCommand("plan", {
|
|
169
175
|
description:
|
|
170
|
-
"Toggle plan mode (read-only exploration), enter it with an optional task, list saved plans,
|
|
176
|
+
"Toggle plan mode (read-only exploration), enter it with an optional task, list saved plans, review one in the browser, or cancel planning without approving",
|
|
171
177
|
getArgumentCompletions: (prefix) => {
|
|
172
178
|
const text = prefix.trimStart();
|
|
173
179
|
if (text.includes(" ")) return null;
|
|
174
|
-
const subcommands = [LIST_PREFIX, REVIEW_PREFIX].filter((name) => name.startsWith(text));
|
|
180
|
+
const subcommands = [LIST_PREFIX, REVIEW_PREFIX, CANCEL_PREFIX].filter((name) => name.startsWith(text));
|
|
175
181
|
return subcommands.length > 0 ? subcommands.map((value) => ({ value, label: value })) : null;
|
|
176
182
|
},
|
|
177
183
|
handler: async (args, ctx) => {
|
|
178
184
|
const prompt = args.trim();
|
|
179
185
|
|
|
180
|
-
// `/plan list
|
|
181
|
-
// everything else is a task.
|
|
186
|
+
// `/plan list`, `/plan review [path]`, and `/plan cancel` are reserved
|
|
187
|
+
// subcommands; everything else is a task.
|
|
188
|
+
if (isCancelSubcommand(prompt)) {
|
|
189
|
+
if (!runtime.isEnabled()) {
|
|
190
|
+
ctx.ui.notify("Plan mode is already off.", "info");
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
runtime.disable(ctx);
|
|
194
|
+
ctx.ui.notify("Plan mode off — full access restored. The plan was not approved.", "info");
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
182
197
|
if (isListSubcommand(prompt)) {
|
|
183
198
|
await openPlanIndex(pi, runtime, web, ctx);
|
|
184
199
|
return;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config-file support for plan mode.
|
|
3
|
+
*
|
|
4
|
+
* The `plan` section of the shared `bytefly.json` (global then project, project
|
|
5
|
+
* wins). Only the approval offer is configurable; plan mode itself never depends
|
|
6
|
+
* on it.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { loadConfigSection } from "../../lib/config.ts";
|
|
10
|
+
|
|
11
|
+
export interface PlanConfig {
|
|
12
|
+
/** Ask before recording an approved plan's objective as the session goal. */
|
|
13
|
+
goalPrompt: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_CONFIG: PlanConfig = { goalPrompt: true };
|
|
17
|
+
|
|
18
|
+
/** Normalize one merged config object over the running base. */
|
|
19
|
+
export function normalizeConfig(raw: Record<string, unknown> | undefined, base: PlanConfig): PlanConfig {
|
|
20
|
+
if (!raw) return base;
|
|
21
|
+
return { goalPrompt: typeof raw.goalPrompt === "boolean" ? raw.goalPrompt : base.goalPrompt };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Effective plan config from the shared `plan` section (defaults, global, project). */
|
|
25
|
+
export function loadConfig(cwd: string): PlanConfig {
|
|
26
|
+
return loadConfigSection(cwd, "plan", DEFAULT_CONFIG, normalizeConfig);
|
|
27
|
+
}
|
|
@@ -25,7 +25,7 @@ import type { PlanSummary } from "./plans.ts";
|
|
|
25
25
|
|
|
26
26
|
/** Rows the screen shows when the terminal height is unknown. */
|
|
27
27
|
const DEFAULT_ROWS = 14;
|
|
28
|
-
/** Fixed rows around the list: header, summary, blank, range, detail, hint,
|
|
28
|
+
/** Fixed rows around the list: header, summary, blank, range, detail, hint, closing rule. */
|
|
29
29
|
const CHROME = 7;
|
|
30
30
|
/** Longest title a row draws before the trailing meta wins the column. */
|
|
31
31
|
const TITLE_WIDTH = 56;
|
|
@@ -221,6 +221,8 @@ export class PlanIndexComponent implements Component {
|
|
|
221
221
|
: screenHint(theme, w, ["Esc close", { text: "Enter review", color: "accent" }, "r reload", "↑/↓", "g/G"]);
|
|
222
222
|
|
|
223
223
|
return dockListFrame({
|
|
224
|
+
theme,
|
|
225
|
+
width: w,
|
|
224
226
|
header: titleRule(theme, w, "Plan index"),
|
|
225
227
|
summary: this.summaryLine(w),
|
|
226
228
|
body,
|
package/extensions/plan/plans.ts
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
* read the same artifact the model is working from and the plan survives
|
|
7
7
|
* outside the conversation without a partial write ever being visible. Files
|
|
8
8
|
* live under `<repo-root>/.pi/plans` (the worktree root inside a worktree), or
|
|
9
|
-
* under `<agent-dir>/plans` outside a repository.
|
|
9
|
+
* under `<agent-dir>/plans` outside a repository. An extension tool sees the
|
|
10
|
+
* session cwd rather than the worktree the user entered, so the active worktree
|
|
11
|
+
* is applied before any path is resolved.
|
|
10
12
|
*
|
|
11
13
|
* The directory is self-ignoring: the first write drops a `.gitignore` with `*`
|
|
12
14
|
* so plans never show up as untracked files or inside rewind snapshots,
|
|
@@ -29,6 +31,7 @@ import {
|
|
|
29
31
|
} from "node:fs";
|
|
30
32
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
31
33
|
import { CONFIG_DIR_NAME, getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
34
|
+
import { resolveEffectiveCwd } from "../../lib/env.ts";
|
|
32
35
|
import { repoRootFor } from "../../lib/git/index.ts";
|
|
33
36
|
import { sanitizeLine } from "../../lib/format.ts";
|
|
34
37
|
import { isInside, isInsideReal } from "../../lib/path.ts";
|
|
@@ -219,19 +222,32 @@ async function repoRoot(pi: ExtensionAPI, cwd: string): Promise<string | undefin
|
|
|
219
222
|
|
|
220
223
|
export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date } = {}): PlanStore {
|
|
221
224
|
const now = options.now ?? (() => new Date());
|
|
222
|
-
// One `git rev-parse` per
|
|
225
|
+
// One `git rev-parse` per effective root; a worktree switch changes that root.
|
|
223
226
|
const dirCache = new Map<string, string>();
|
|
224
227
|
|
|
228
|
+
/**
|
|
229
|
+
* The root every path here is resolved against.
|
|
230
|
+
*
|
|
231
|
+
* A tool registered by an extension receives the session's cwd, not the
|
|
232
|
+
* worktree the user entered: only the built-ins are re-rooted. The worktree
|
|
233
|
+
* extension publishes its root in the environment, so this is where the two
|
|
234
|
+
* meet, and it is applied before the git lookup, the relative path, and the
|
|
235
|
+
* plan path resolution.
|
|
236
|
+
*/
|
|
237
|
+
const baseOf = (cwd: string): string => resolveEffectiveCwd(cwd);
|
|
238
|
+
|
|
225
239
|
const dirFor = async (cwd: string): Promise<string> => {
|
|
226
|
-
const
|
|
240
|
+
const base = baseOf(cwd);
|
|
241
|
+
const cached = dirCache.get(base);
|
|
227
242
|
if (cached) return cached;
|
|
228
|
-
const root = await repoRoot(pi,
|
|
243
|
+
const root = await repoRoot(pi, base);
|
|
229
244
|
const dir = root ? join(root, CONFIG_DIR_NAME, "plans") : join(getAgentDir(), "plans");
|
|
230
|
-
dirCache.set(
|
|
245
|
+
dirCache.set(base, dir);
|
|
231
246
|
return dir;
|
|
232
247
|
};
|
|
233
248
|
|
|
234
249
|
const write = async (cwd: string, input: WritePlanInput): Promise<WrittenPlan> => {
|
|
250
|
+
const base = baseOf(cwd);
|
|
235
251
|
const title = input.title.trim();
|
|
236
252
|
if (!title) throw new Error("A plan title is required.");
|
|
237
253
|
const bytes = Buffer.byteLength(input.content, "utf-8");
|
|
@@ -239,13 +255,13 @@ export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date }
|
|
|
239
255
|
throw new Error(`The plan is too large (${bytes} bytes; the limit is ${MAX_PLAN_BYTES}).`);
|
|
240
256
|
}
|
|
241
257
|
|
|
242
|
-
const dir = await dirFor(
|
|
258
|
+
const dir = await dirFor(base);
|
|
243
259
|
mkdirSync(dir, { recursive: true });
|
|
244
260
|
ensureIgnored(dir);
|
|
245
261
|
|
|
246
262
|
let path: string;
|
|
247
263
|
if (input.planPath) {
|
|
248
|
-
const resolved = resolve(
|
|
264
|
+
const resolved = resolve(base, input.planPath);
|
|
249
265
|
if (!isContainedPlan(dir, resolved)) {
|
|
250
266
|
throw new Error("plan_path must be a markdown plan file inside the plans directory.");
|
|
251
267
|
}
|
|
@@ -260,18 +276,19 @@ export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date }
|
|
|
260
276
|
}
|
|
261
277
|
|
|
262
278
|
writeAtomic(path, input.content);
|
|
263
|
-
return { path, relativePath: relativeTo(
|
|
279
|
+
return { path, relativePath: relativeTo(base, path), title, content: input.content, bytes };
|
|
264
280
|
};
|
|
265
281
|
|
|
266
282
|
const read = async (cwd: string, planPath: string): Promise<StoredPlan | undefined> => {
|
|
267
|
-
const
|
|
268
|
-
const
|
|
283
|
+
const base = baseOf(cwd);
|
|
284
|
+
const dir = await dirFor(base);
|
|
285
|
+
const resolved = resolve(base, planPath);
|
|
269
286
|
if (!isContainedPlan(dir, resolved) || !existsSync(resolved)) return undefined;
|
|
270
287
|
try {
|
|
271
288
|
const content = readFileSync(resolved, "utf-8");
|
|
272
289
|
return {
|
|
273
290
|
path: resolved,
|
|
274
|
-
relativePath: relativeTo(
|
|
291
|
+
relativePath: relativeTo(base, resolved),
|
|
275
292
|
content,
|
|
276
293
|
bytes: Buffer.byteLength(content, "utf-8"),
|
|
277
294
|
};
|
|
@@ -375,7 +392,8 @@ export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date }
|
|
|
375
392
|
* dotfile, or a file that vanishes mid-walk is simply not a plan.
|
|
376
393
|
*/
|
|
377
394
|
const index = async (cwd: string): Promise<PlanSummary[]> => {
|
|
378
|
-
const
|
|
395
|
+
const base = baseOf(cwd);
|
|
396
|
+
const dir = await dirFor(base);
|
|
379
397
|
let names: string[];
|
|
380
398
|
try {
|
|
381
399
|
names = readdirSync(dir);
|
|
@@ -399,7 +417,7 @@ export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date }
|
|
|
399
417
|
|
|
400
418
|
const summary: PlanSummary = {
|
|
401
419
|
path,
|
|
402
|
-
relativePath: relativeTo(
|
|
420
|
+
relativePath: relativeTo(base, path),
|
|
403
421
|
title: planTitle(name),
|
|
404
422
|
modified: info.mtimeMs,
|
|
405
423
|
bytes: info.size,
|
|
@@ -44,7 +44,9 @@ export type ReviewDecision =
|
|
|
44
44
|
/** True when the request came from the browser, which is reported differently. */
|
|
45
45
|
fromBrowser: boolean;
|
|
46
46
|
}
|
|
47
|
-
| { action: "keep"; plan: StoredPlan }
|
|
47
|
+
| { action: "keep"; plan: StoredPlan }
|
|
48
|
+
/** Leave plan mode without approving; the plan file stays for a later review. */
|
|
49
|
+
| { action: "cancel"; plan: StoredPlan };
|
|
48
50
|
|
|
49
51
|
/** What the previous review showed, so this one can mark what changed. */
|
|
50
52
|
export interface ReviewBaselines {
|
|
@@ -69,12 +71,16 @@ export interface ReviewPlanOptions {
|
|
|
69
71
|
/** Prompt shown by the dialog-based refine editor in non-TUI modes. */
|
|
70
72
|
const REFINE_PROMPT = "Refine the plan — what should change? (Enter to save, Esc to cancel)";
|
|
71
73
|
|
|
74
|
+
/**
|
|
75
|
+
* How long a remote review waits for its answer. Much longer than a plain dialog's
|
|
76
|
+
* bound, because the human has to read the plan first and a phone is a slow place
|
|
77
|
+
* to do it; an expiry here also ends plan mode, so it must not be easy to hit.
|
|
78
|
+
*/
|
|
79
|
+
export const PLAN_REVIEW_TIMEOUT_MS = 600_000;
|
|
80
|
+
|
|
72
81
|
/** How much of one step the remote digest shows. */
|
|
73
82
|
const DIGEST_STEP_CHARS = 90;
|
|
74
83
|
|
|
75
|
-
/** The refinement request used when a browser save carried no comments. */
|
|
76
|
-
const NO_BROWSER_COMMENTS = "See the review comments in the plan file.";
|
|
77
|
-
|
|
78
84
|
/**
|
|
79
85
|
* A one-message digest of the plan for a remote review.
|
|
80
86
|
*
|
|
@@ -94,7 +100,13 @@ function planReviewDigest(content: string): string {
|
|
|
94
100
|
return `${index + 1}. ${shown}`;
|
|
95
101
|
});
|
|
96
102
|
const more = count > preview.length ? `… (${count - preview.length} more)` : "";
|
|
97
|
-
return [
|
|
103
|
+
return [
|
|
104
|
+
head,
|
|
105
|
+
...preview,
|
|
106
|
+
more,
|
|
107
|
+
"Plan mode is on — write access is off. Choose “Exit plan mode” to leave without approving.",
|
|
108
|
+
"The full plan is attached above, or sent as text if the file failed.",
|
|
109
|
+
]
|
|
98
110
|
.filter((line) => line !== "")
|
|
99
111
|
.join("\n");
|
|
100
112
|
}
|
|
@@ -135,12 +147,16 @@ async function askPlanReview(
|
|
|
135
147
|
"Approve and execute",
|
|
136
148
|
"Send the full plan",
|
|
137
149
|
"Refine the plan",
|
|
138
|
-
"Keep planning",
|
|
150
|
+
"Keep planning (stay in plan mode)",
|
|
151
|
+
"Exit plan mode (discard)",
|
|
139
152
|
]);
|
|
140
153
|
if (choice === "Approve and execute") return { action: "approve" };
|
|
141
154
|
if (choice === "Send the full plan") return { action: "send" };
|
|
142
155
|
if (choice === "Refine the plan") return { action: "refine" };
|
|
143
|
-
return { action: "keep" };
|
|
156
|
+
if (choice === "Keep planning (stay in plan mode)") return { action: "keep" };
|
|
157
|
+
// Dismissing the plan gate means leaving plan mode, not silently staying
|
|
158
|
+
// in it. The explicit Keep choice above is the only way to stay.
|
|
159
|
+
return { action: "cancel" };
|
|
144
160
|
},
|
|
145
161
|
// A remote turn gets the plan file up front, falling back to the full text;
|
|
146
162
|
// a local TUI turn ignores both and renders the component. The digest goes
|
|
@@ -149,6 +165,7 @@ async function askPlanReview(
|
|
|
149
165
|
file: sentFullText ? undefined : { path: plan.path, name: basename(plan.relativePath) },
|
|
150
166
|
body: plan.content,
|
|
151
167
|
note: sentFullText ? undefined : planReviewDigest(plan.content),
|
|
168
|
+
timeoutMs: PLAN_REVIEW_TIMEOUT_MS,
|
|
152
169
|
});
|
|
153
170
|
if (outcome.action !== "send") return outcome;
|
|
154
171
|
sentFullText = true;
|
|
@@ -209,6 +226,7 @@ export async function reviewPlan(options: ReviewPlanOptions): Promise<ReviewDeci
|
|
|
209
226
|
|
|
210
227
|
if (choice.action === "approve") return { action: "approve", plan, comments: [] };
|
|
211
228
|
if (choice.action === "keep") return { action: "keep", plan };
|
|
229
|
+
if (choice.action === "cancel") return { action: "cancel", plan };
|
|
212
230
|
if (choice.action === "refine") {
|
|
213
231
|
const refinement = choice.refinement ?? (await ctx.ui.editor(REFINE_PROMPT, ""))?.trim();
|
|
214
232
|
// An empty or cancelled editor returns to the review screen instead of
|
|
@@ -231,13 +249,15 @@ export async function reviewPlan(options: ReviewPlanOptions): Promise<ReviewDeci
|
|
|
231
249
|
}
|
|
232
250
|
if (result.status === "approved") return { action: "approve", plan: result.plan, comments: result.annotations };
|
|
233
251
|
if (result.status === "saved") {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
252
|
+
// A save with no comments asks for nothing, so it returns to the screen the
|
|
253
|
+
// way an empty refine editor does, instead of reporting a refinement nobody
|
|
254
|
+
// wrote.
|
|
255
|
+
const refinement = feedbackSummary(result.annotations);
|
|
256
|
+
if (!refinement) {
|
|
257
|
+
plan = result.plan;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
return { action: "refine", plan: result.plan, refinement, comments: result.annotations, fromBrowser: true };
|
|
241
261
|
}
|
|
242
262
|
// Cancelled or the tab closed: the file may have been rewritten underneath,
|
|
243
263
|
// so re-read it before showing the screen again.
|
|
@@ -32,6 +32,8 @@ import type { PlanModeEntry, ReviewBaselineEntry } from "./types.ts";
|
|
|
32
32
|
|
|
33
33
|
/** Tool the model calls to leave plan mode after approval. */
|
|
34
34
|
export const EXIT_TOOL = "exit_plan_mode";
|
|
35
|
+
/** Tool the model calls to leave plan mode without approving. */
|
|
36
|
+
export const CANCEL_TOOL = "cancel_plan_mode";
|
|
35
37
|
/** Tool the model calls to ask to enter plan mode. */
|
|
36
38
|
export const ENTER_TOOL = PLAN_ENTER_TOOL;
|
|
37
39
|
/** Tool the model calls to save the plan artifact. */
|
|
@@ -47,7 +49,7 @@ export const BASELINE_TYPE = "plan-baseline";
|
|
|
47
49
|
const STATUS_NAME_LENGTH = 28;
|
|
48
50
|
|
|
49
51
|
/** Tools plan mode activates itself; the guard permits only this extension's own. */
|
|
50
|
-
const CONTROL_TOOLS = [EXIT_TOOL, WRITE_PLAN_TOOL];
|
|
52
|
+
const CONTROL_TOOLS = [EXIT_TOOL, CANCEL_TOOL, WRITE_PLAN_TOOL];
|
|
51
53
|
|
|
52
54
|
export interface PlanRuntime {
|
|
53
55
|
/** Whether plan mode is currently on. */
|
package/extensions/plan/steps.ts
CHANGED
|
@@ -23,34 +23,59 @@ export const MAX_STEPS = 50;
|
|
|
23
23
|
export const MAX_STEP_LENGTH = 200;
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
|
-
* The outline a plan is guided to follow
|
|
27
|
-
*
|
|
26
|
+
* The outline a plan is guided to follow: one job per section, and every example
|
|
27
|
+
* is the shape wanted rather than a description of it. `Visual` is the one
|
|
28
|
+
* section that carries the end state — the same surface before and after when the
|
|
29
|
+
* change alters one that already exists, one frame when it is new — in a fence
|
|
30
|
+
* that stays untagged unless every line is code, so the reviewer sees the result
|
|
31
|
+
* before the steps that build it. Guidance only: `write_plan` never rejects or
|
|
32
|
+
* rewrites a plan that omits a section, and a change with no visible surface drops
|
|
33
|
+
* `Visual`.
|
|
28
34
|
*/
|
|
29
35
|
export const PLAN_OUTLINE = [
|
|
30
36
|
"# <Title>",
|
|
31
37
|
"",
|
|
32
38
|
"## Goal",
|
|
33
|
-
"
|
|
39
|
+
"One sentence: the outcome this change delivers, and what it deliberately does not do.",
|
|
34
40
|
"",
|
|
35
41
|
"## Context",
|
|
36
|
-
"
|
|
42
|
+
"- `src/load.ts:42` reads the config once at startup; that is the only caller.",
|
|
43
|
+
"- The file is user-editable, so a bad key must name the file and line.",
|
|
37
44
|
"",
|
|
38
45
|
"## Approach",
|
|
39
|
-
"
|
|
46
|
+
"- Parse into a typed record in `parseConfig`; `load()` keeps the file read.",
|
|
47
|
+
"- Rejected: validating inside `load()`, because the test would need a filesystem.",
|
|
48
|
+
"",
|
|
49
|
+
"## Visual",
|
|
50
|
+
"A bad key at startup, before and after:",
|
|
51
|
+
"```",
|
|
52
|
+
"$ bun start",
|
|
53
|
+
'config.json:12: ignoring unknown key "timeout_ms"',
|
|
54
|
+
"```",
|
|
55
|
+
"",
|
|
56
|
+
"```",
|
|
57
|
+
"$ bun start",
|
|
58
|
+
'config.json:12: unknown key "timeout_ms" — did you mean "timeoutMs"?',
|
|
59
|
+
"```",
|
|
40
60
|
"",
|
|
41
61
|
"## Steps",
|
|
42
62
|
"- [ ] Add `parseConfig` in `src/config.ts`, with a test for a missing key",
|
|
43
|
-
"-
|
|
63
|
+
" - `load()` is the only caller today; it keeps the file read.",
|
|
64
|
+
"- [ ] Report a bad key with its file and line, and note the rule in the README",
|
|
44
65
|
"",
|
|
45
66
|
"## Risks",
|
|
46
|
-
"
|
|
67
|
+
"- A missing key now fails at startup rather than on first use: name that in the README.",
|
|
68
|
+
"- Mitigated by the test in the first step.",
|
|
47
69
|
"",
|
|
48
70
|
"## Verification",
|
|
49
|
-
"
|
|
71
|
+
"```bash",
|
|
72
|
+
"bun test src/config.test.ts",
|
|
73
|
+
"bun run check",
|
|
74
|
+
"```",
|
|
50
75
|
].join("\n");
|
|
51
76
|
|
|
52
77
|
/** The outline's section titles, in order, for documentation and tests. */
|
|
53
|
-
export const PLAN_SECTIONS = ["Goal", "Context", "Approach", "Steps", "Risks", "Verification"] as const;
|
|
78
|
+
export const PLAN_SECTIONS = ["Goal", "Context", "Approach", "Visual", "Steps", "Risks", "Verification"] as const;
|
|
54
79
|
|
|
55
80
|
export interface PlanStep {
|
|
56
81
|
content: string;
|
|
@@ -233,11 +258,12 @@ const LIST_PREFIX = /^\s*(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s*)?/;
|
|
|
233
258
|
* The plan's `Verification` section as one trailing checklist item, or
|
|
234
259
|
* `undefined` when the plan has none.
|
|
235
260
|
*
|
|
236
|
-
* The section is prose ("commands and checks")
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
261
|
+
* The section is prose ("commands and checks") or a fenced block of them, and a
|
|
262
|
+
* todo item is one line, so the fence lines are dropped, the rest is stripped of
|
|
263
|
+
* list markers and inline emphasis, and the lines are joined with `; `, prefixed
|
|
264
|
+
* with `Verify:` and elided like a long step. The item is deliberately *not* a
|
|
265
|
+
* plan step: it is not in the `Steps` section, so it must not count against the
|
|
266
|
+
* step cap or appear in `set_plan_steps` numbering.
|
|
241
267
|
*
|
|
242
268
|
* A plan with no `Steps` heading feeds its shallowest list as steps (the older
|
|
243
269
|
* rule), so that list already covers the verification bullets; appending a
|
|
@@ -252,6 +278,9 @@ export function verificationItem(plan: string): PlanStep | undefined {
|
|
|
252
278
|
const items = section.text
|
|
253
279
|
.split("\n")
|
|
254
280
|
.slice(1)
|
|
281
|
+
// A fenced block is how the outline asks for commands; its fence lines are
|
|
282
|
+
// punctuation, not a check, so only the commands inside it are carried.
|
|
283
|
+
.filter((line) => !FENCE.test(line))
|
|
255
284
|
.map((line) => plainText(line.replace(LIST_PREFIX, "")))
|
|
256
285
|
.filter(Boolean);
|
|
257
286
|
if (items.length === 0) return undefined;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `cancel_plan_mode` tool registration.
|
|
3
|
+
*
|
|
4
|
+
* This is the escape hatch that does not need a plan file or a review dialog:
|
|
5
|
+
* when the user asks to leave planning, the model can restore write access
|
|
6
|
+
* without approving anything. The plan store is untouched; a later `/plan` can
|
|
7
|
+
* still review the saved artifact.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { Type } from "typebox";
|
|
12
|
+
import { reuseText, toolDetails } from "../../../lib/tui/chrome.ts";
|
|
13
|
+
import { paint } from "../../../lib/tui/tokens.ts";
|
|
14
|
+
import { callLine, detailsOrLine } from "../../../lib/tui/tool.ts";
|
|
15
|
+
import { failedResult } from "../../../lib/tool-result.ts";
|
|
16
|
+
import { CANCEL_TOOL, type PlanRuntime } from "../runtime.ts";
|
|
17
|
+
import type { CancelPlanModeDetails } from "../types.ts";
|
|
18
|
+
|
|
19
|
+
const CANCELLED_TEXT =
|
|
20
|
+
"Plan mode is off and write access is restored. The plan was not approved; no todo items or goal were recorded. " +
|
|
21
|
+
"Any saved plan file remains for a later review.";
|
|
22
|
+
|
|
23
|
+
export function registerCancelTool(pi: ExtensionAPI, runtime: PlanRuntime): void {
|
|
24
|
+
pi.registerTool({
|
|
25
|
+
name: CANCEL_TOOL,
|
|
26
|
+
label: "Cancel plan mode",
|
|
27
|
+
description:
|
|
28
|
+
"Leave plan mode without approving a plan. Restores write access; no todo items or goal are recorded. " +
|
|
29
|
+
"Use when the user asks to exit, cancel, or abandon planning.",
|
|
30
|
+
promptSnippet: "Leave plan mode without approving the plan.",
|
|
31
|
+
promptGuidelines: [
|
|
32
|
+
"Call cancel_plan_mode when the user asks to leave or abandon planning without approving a plan.",
|
|
33
|
+
],
|
|
34
|
+
parameters: Type.Object({}),
|
|
35
|
+
defaultActive: false,
|
|
36
|
+
executionMode: "sequential",
|
|
37
|
+
// The mode change touches no files; like enter_plan_mode, it needs no
|
|
38
|
+
// destructive annotation.
|
|
39
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
40
|
+
|
|
41
|
+
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
42
|
+
if (!runtime.isEnabled()) {
|
|
43
|
+
return failedResult("Not in plan mode; there is nothing to cancel.", {
|
|
44
|
+
cancelled: false,
|
|
45
|
+
} satisfies CancelPlanModeDetails);
|
|
46
|
+
}
|
|
47
|
+
runtime.disable(ctx);
|
|
48
|
+
ctx.ui.notify("Plan mode off — full access restored. The plan was not approved.", "info");
|
|
49
|
+
return {
|
|
50
|
+
content: [{ type: "text", text: CANCELLED_TEXT }],
|
|
51
|
+
details: { cancelled: true } satisfies CancelPlanModeDetails,
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
renderCall(_args, theme, context) {
|
|
56
|
+
return callLine(theme, context?.lastComponent, CANCEL_TOOL, "left plan mode");
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
renderResult(result, options, theme, context) {
|
|
60
|
+
const text = reuseText(context?.lastComponent);
|
|
61
|
+
const outcome = detailsOrLine(
|
|
62
|
+
theme,
|
|
63
|
+
result,
|
|
64
|
+
options.expanded,
|
|
65
|
+
toolDetails<CancelPlanModeDetails>(result.details),
|
|
66
|
+
);
|
|
67
|
+
if ("line" in outcome) {
|
|
68
|
+
text.setText(outcome.line);
|
|
69
|
+
return text;
|
|
70
|
+
}
|
|
71
|
+
text.setText(
|
|
72
|
+
outcome.details.cancelled
|
|
73
|
+
? paint(theme, "warn", "Plan mode off — plan not approved")
|
|
74
|
+
: paint(theme, "meta", "Not in plan mode"),
|
|
75
|
+
);
|
|
76
|
+
return text;
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
}
|
|
@@ -11,6 +11,8 @@ import { reuseText, toolDetails } from "../../../lib/tui/chrome.ts";
|
|
|
11
11
|
import { GLYPHS } from "../../../lib/tui/glyphs.ts";
|
|
12
12
|
import { paint } from "../../../lib/tui/tokens.ts";
|
|
13
13
|
import { callLine, detailsOrLine } from "../../../lib/tui/tool.ts";
|
|
14
|
+
import { failedResult } from "../../../lib/tool-result.ts";
|
|
15
|
+
import { isRemoteTurn } from "../../../lib/interaction.ts";
|
|
14
16
|
import { PLAN_CONSENT, READ_ONLY_SUMMARY } from "../../../lib/plan-entry.ts";
|
|
15
17
|
import { ENTER_TOOL, type PlanRuntime } from "../runtime.ts";
|
|
16
18
|
import type { EnterPlanModeDetails } from "../types.ts";
|
|
@@ -45,16 +47,10 @@ export function registerEnterTool(pi: ExtensionAPI, runtime: PlanRuntime): void
|
|
|
45
47
|
};
|
|
46
48
|
}
|
|
47
49
|
if (!ctx.hasUI) {
|
|
48
|
-
return
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
text: "No interactive UI is available to confirm entering plan mode. Ask the user whether they want a plan.",
|
|
53
|
-
},
|
|
54
|
-
],
|
|
55
|
-
details: { entered: false, unavailable: true } satisfies EnterPlanModeDetails,
|
|
56
|
-
isError: true,
|
|
57
|
-
};
|
|
50
|
+
return failedResult(
|
|
51
|
+
"No interactive UI is available to confirm entering plan mode. Ask the user whether they want a plan.",
|
|
52
|
+
{ entered: false, unavailable: true } satisfies EnterPlanModeDetails,
|
|
53
|
+
);
|
|
58
54
|
}
|
|
59
55
|
|
|
60
56
|
const approved = await ctx.ui.confirm("Enter plan mode?", PLAN_CONSENT);
|
|
@@ -65,6 +61,10 @@ export function registerEnterTool(pi: ExtensionAPI, runtime: PlanRuntime): void
|
|
|
65
61
|
};
|
|
66
62
|
}
|
|
67
63
|
runtime.enable(ctx);
|
|
64
|
+
if (isRemoteTurn(ctx)) {
|
|
65
|
+
// The peer UI has no footer chip; say the mode and its exit in the chat.
|
|
66
|
+
ctx.ui.notify("Plan mode on — write access is off. Say “exit plan mode” to leave without approving.", "info");
|
|
67
|
+
}
|
|
68
68
|
return {
|
|
69
69
|
content: [{ type: "text", text: ENTERED_TEXT }],
|
|
70
70
|
details: { entered: true } satisfies EnterPlanModeDetails,
|