@gavin-hu/my-pi-agent 0.11.4 → 0.13.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 +38 -26
- package/README.zh-CN.md +33 -23
- package/extensions/bytefly/README.md +381 -225
- package/extensions/bytefly/chrome.ts +7 -2
- package/extensions/bytefly/doctor/register.ts +6 -0
- package/extensions/bytefly/doctor/runtime.ts +52 -6
- package/extensions/bytefly/doctor/tools.ts +1 -3
- package/extensions/bytefly/footer/collect/snapshot.ts +12 -2
- package/extensions/bytefly/footer/config.ts +7 -1
- package/extensions/bytefly/footer/delegation.ts +100 -0
- package/extensions/bytefly/footer/price/config.ts +54 -15
- package/extensions/bytefly/footer/price/price.ts +20 -7
- package/extensions/bytefly/footer/price/types.ts +13 -7
- package/extensions/bytefly/footer/register.ts +13 -2
- package/extensions/bytefly/footer/render/format.ts +3 -2
- package/extensions/bytefly/footer/render/lines.ts +2 -0
- package/extensions/bytefly/footer/render/segments.ts +30 -2
- package/extensions/bytefly/footer/types.ts +12 -0
- package/extensions/bytefly/header/component.ts +21 -6
- package/extensions/bytefly/header/config.ts +3 -0
- package/extensions/bytefly/header/register.ts +34 -3
- package/extensions/bytefly/index.ts +15 -4
- package/extensions/bytefly/notify/config.ts +2 -0
- package/extensions/bytefly/notify/format.ts +19 -1
- package/extensions/bytefly/notify/register.ts +37 -13
- package/extensions/bytefly/notify/types.ts +2 -0
- package/extensions/bytefly/panel/commands.ts +34 -13
- package/extensions/bytefly/panel/settings-runtime.ts +71 -28
- package/extensions/bytefly/panel/settings.ts +293 -48
- package/extensions/bytefly/panel/tui/entries.ts +58 -0
- package/extensions/bytefly/panel/tui/extensions-view.ts +86 -0
- package/extensions/bytefly/panel/tui/frame.ts +71 -0
- package/extensions/bytefly/panel/tui/nav.ts +75 -0
- package/extensions/bytefly/panel/tui/settings-view.ts +115 -0
- package/extensions/bytefly/panel/tui.ts +205 -319
- package/extensions/bytefly/picker/runtime.ts +61 -2
- package/extensions/bytefly/picker/toggle.ts +56 -0
- package/extensions/bytefly/update/cache.ts +73 -0
- package/extensions/bytefly/update/check.ts +59 -0
- package/extensions/bytefly/update/format.ts +87 -0
- package/extensions/bytefly/update/register.ts +158 -0
- package/extensions/bytefly/update/state.ts +40 -0
- package/extensions/bytefly/update/version.ts +83 -0
- package/extensions/document/README.md +24 -14
- package/extensions/document/config.ts +4 -0
- package/extensions/document/deadline.ts +61 -0
- package/extensions/document/tool.ts +14 -5
- package/extensions/file-browse/README.md +12 -8
- package/extensions/file-browse/client.ts +6 -7
- package/extensions/file-browse/files.ts +0 -4
- package/extensions/file-browse/git.ts +9 -10
- package/extensions/file-browse/html.ts +17 -7
- package/extensions/file-browse/router.ts +24 -15
- package/extensions/file-open/README.md +30 -15
- package/extensions/file-open/index.ts +4 -3
- package/extensions/file-open/open.ts +52 -21
- package/extensions/goal/README.md +45 -13
- package/extensions/goal/clarify-prompt.ts +5 -36
- package/extensions/goal/clarify.ts +55 -86
- package/extensions/goal/commands.ts +41 -35
- package/extensions/goal/format.ts +12 -4
- package/extensions/goal/schema.ts +6 -2
- package/extensions/goal/tools.ts +28 -8
- package/extensions/goal/tui.ts +1 -1
- package/extensions/goal/types.ts +6 -2
- package/extensions/job/README.md +16 -12
- package/extensions/job/runtime.ts +23 -6
- package/extensions/job/status.ts +4 -2
- package/extensions/memory/README.md +56 -14
- package/extensions/memory/commands.ts +20 -2
- package/extensions/memory/doctor.ts +137 -0
- package/extensions/memory/format.ts +24 -7
- package/extensions/memory/runtime.ts +49 -0
- package/extensions/memory/store.ts +31 -0
- package/extensions/memory/tui.ts +136 -12
- package/extensions/plan/README.md +83 -36
- package/extensions/plan/blocks.ts +1 -35
- package/extensions/plan/commands.ts +84 -19
- package/extensions/plan/index.ts +35 -11
- package/extensions/plan/plan-index.ts +234 -0
- package/extensions/plan/plan-text.ts +95 -0
- package/extensions/plan/plans.ts +91 -2
- package/extensions/plan/policy.ts +4 -3
- package/extensions/plan/review.ts +246 -0
- package/extensions/plan/runtime.ts +73 -24
- package/extensions/plan/steps.ts +60 -39
- package/extensions/plan/todos.ts +29 -60
- package/extensions/plan/tools.ts +188 -244
- package/extensions/plan/tui.ts +18 -31
- package/extensions/plan/types.ts +12 -0
- package/extensions/plan/web-client.ts +51 -18
- package/extensions/plan/web-page.ts +7 -2
- package/extensions/plan/web-router.ts +11 -3
- package/extensions/plan/web.ts +106 -41
- package/extensions/question/README.md +1 -1
- package/extensions/question/tools.ts +1 -1
- package/extensions/rewind/README.md +28 -8
- package/extensions/rewind/commands.ts +1 -1
- package/extensions/rewind/format.ts +4 -4
- package/extensions/rewind/git.ts +56 -18
- package/extensions/rewind/index.ts +3 -5
- package/extensions/rewind/path-picker.ts +198 -0
- package/extensions/rewind/paths.ts +128 -0
- package/extensions/rewind/restore.ts +69 -28
- package/extensions/rewind/rewind.ts +80 -3
- package/extensions/rewind/runtime.ts +13 -5
- package/extensions/rewind/snapshot-run.ts +5 -2
- package/extensions/rewind/types.ts +2 -0
- package/extensions/subagent/README.md +7 -1
- package/extensions/subagent/accounting.ts +34 -0
- package/extensions/subagent/index.ts +23 -3
- package/extensions/subagent/stream.ts +2 -0
- package/extensions/subagent/types.ts +2 -0
- package/extensions/todo/README.md +19 -11
- package/extensions/todo/index.ts +5 -3
- package/extensions/todo/nudge.ts +58 -9
- package/extensions/todo/runtime.ts +28 -17
- package/extensions/watch/README.md +8 -3
- package/extensions/watch/index.ts +19 -0
- package/extensions/web-access/README.md +46 -16
- package/extensions/web-access/charset.ts +58 -0
- package/extensions/web-access/fetch/find.ts +11 -7
- package/extensions/web-access/fetch/format.ts +64 -18
- package/extensions/web-access/fetch/page.ts +5 -1
- package/extensions/web-access/fetch/tool.ts +20 -21
- package/extensions/web-access/http.ts +49 -8
- package/extensions/web-access/search/facts.ts +38 -0
- package/extensions/web-access/search/tool.ts +14 -3
- package/extensions/web-access/text.ts +50 -11
- package/extensions/wechat/README.md +5 -4
- package/extensions/wechat/tools.ts +0 -2
- package/extensions/worktree/README.md +74 -15
- package/extensions/worktree/config.ts +11 -0
- package/extensions/worktree/exit.ts +35 -3
- package/extensions/worktree/gaps.ts +74 -0
- package/extensions/worktree/guard.ts +50 -4
- package/extensions/worktree/handoff.ts +55 -0
- package/extensions/worktree/index.ts +12 -2
- package/extensions/worktree/runtime.ts +20 -0
- package/extensions/worktree/status.ts +15 -2
- package/extensions/worktree/tools.ts +3 -2
- package/lib/README.md +12 -9
- package/lib/config.ts +43 -1
- package/lib/env.ts +12 -0
- package/lib/events.ts +86 -1
- package/lib/goal-state.ts +95 -0
- package/lib/requirements.ts +340 -46
- package/lib/todo-state.ts +62 -0
- package/lib/tui/README.md +91 -0
- package/lib/tui/glyphs.ts +2 -0
- package/lib/web/layout.ts +12 -2
- package/package.json +5 -3
- package/themes/bytefly-dark.json +1 -1
- package/assets/bytefly.schema.json +0 -785
- package/assets/chrome.png +0 -0
- package/assets/file-browse.png +0 -0
- package/assets/job.png +0 -0
- package/assets/plan-review.png +0 -0
- package/assets/question.png +0 -0
- package/assets/rewind.png +0 -0
- package/assets/todo.png +0 -0
- package/assets/tool-renderings.png +0 -0
- package/assets/tools/ask_user_question-cancelled.png +0 -0
- package/assets/tools/ask_user_question.png +0 -0
- package/assets/tools/bash.png +0 -0
- package/assets/tools/edit.png +0 -0
- package/assets/tools/enter_plan_mode.png +0 -0
- package/assets/tools/enter_worktree.png +0 -0
- package/assets/tools/exit_plan_mode.png +0 -0
- package/assets/tools/exit_worktree.png +0 -0
- package/assets/tools/find.png +0 -0
- package/assets/tools/goal.png +0 -0
- package/assets/tools/grep.png +0 -0
- package/assets/tools/job.png +0 -0
- package/assets/tools/list_worktrees.png +0 -0
- package/assets/tools/ls.png +0 -0
- package/assets/tools/memory-long-subject.png +0 -0
- package/assets/tools/memory.png +0 -0
- package/assets/tools/notice.png +0 -0
- package/assets/tools/open_path.png +0 -0
- package/assets/tools/prune_worktrees.png +0 -0
- package/assets/tools/read.png +0 -0
- package/assets/tools/read_doc-running.png +0 -0
- package/assets/tools/read_doc.png +0 -0
- package/assets/tools/run_doctor.png +0 -0
- package/assets/tools/send_wechat.png +0 -0
- package/assets/tools/set_plan_steps.png +0 -0
- package/assets/tools/subagent.png +0 -0
- package/assets/tools/todo.png +0 -0
- package/assets/tools/turn-rule.png +0 -0
- package/assets/tools/user-message.png +0 -0
- package/assets/tools/watch.png +0 -0
- package/assets/tools/web_fetch-rejected.png +0 -0
- package/assets/tools/web_fetch.png +0 -0
- package/assets/tools/web_search.png +0 -0
- package/assets/tools/write.png +0 -0
- package/assets/tools/write_plan.png +0 -0
|
@@ -2,21 +2,26 @@
|
|
|
2
2
|
* Slash command for the plan-mode extension.
|
|
3
3
|
*
|
|
4
4
|
* `/plan` toggles plan mode, or enters it and sends an optional task;
|
|
5
|
-
* `Ctrl+Alt+P` toggles too. `/plan review [path]`
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* `Ctrl+Alt+P` toggles too. Two subcommands are reserved: `/plan review [path]`
|
|
6
|
+
* opens a plan in the browser (and sends saved comments to the model as a
|
|
7
|
+
* refinement), and `/plan list` shows the plans directory, newest first, with
|
|
8
|
+
* Enter opening a review.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
11
12
|
import { isRemoteTurn } from "../../lib/interaction.ts";
|
|
12
13
|
import { feedbackSummary } from "./feedback.ts";
|
|
14
|
+
import { PlanIndexComponent, formatPlanIndex } from "./plan-index.ts";
|
|
13
15
|
import { READ_ONLY_SUMMARY } from "./policy.ts";
|
|
16
|
+
import { runBrowserReview } from "./review.ts";
|
|
14
17
|
import type { PlanRuntime } from "./runtime.ts";
|
|
18
|
+
import type { PlanSummary } from "./plans.ts";
|
|
15
19
|
import { createPlanWebReview, type PlanWebReview } from "./web.ts";
|
|
16
20
|
|
|
17
21
|
const ENABLED_NOTICE = `Plan mode enabled — ${READ_ONLY_SUMMARY}.`;
|
|
18
22
|
const DISABLED_NOTICE = "Plan mode disabled — full access restored.";
|
|
19
23
|
const REVIEW_PREFIX = "review";
|
|
24
|
+
const LIST_PREFIX = "list";
|
|
20
25
|
|
|
21
26
|
/** Notice text for the current mode; shared by `/plan` and the `Ctrl+Alt+P` shortcut. */
|
|
22
27
|
export function planModeNotice(enabled: boolean): string {
|
|
@@ -29,6 +34,14 @@ function reviewTarget(args: string): string | undefined {
|
|
|
29
34
|
return target === "" ? undefined : target;
|
|
30
35
|
}
|
|
31
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Whether a command line is the reserved `list` subcommand. Exact match only, so
|
|
39
|
+
* `list the untracked files` stays a task instead of silently listing plans.
|
|
40
|
+
*/
|
|
41
|
+
function isListSubcommand(prompt: string): boolean {
|
|
42
|
+
return prompt.toLowerCase() === LIST_PREFIX;
|
|
43
|
+
}
|
|
44
|
+
|
|
32
45
|
/**
|
|
33
46
|
* Whether a command line is the reserved `review` subcommand. Bare `review`
|
|
34
47
|
* always is; a path after it is, but `review the auth flow` stays a task, so a
|
|
@@ -57,34 +70,39 @@ async function reviewInBrowser(
|
|
|
57
70
|
// to the newest plan file in the plans directory.
|
|
58
71
|
const file = path ? await runtime.plans.read(ctx.cwd, path) : await runtime.plans.latest(ctx.cwd);
|
|
59
72
|
if (!file) {
|
|
60
|
-
if (path) ctx.ui.notify(`
|
|
61
|
-
else ctx.ui.notify("
|
|
73
|
+
if (path) ctx.ui.notify(`Plan review: no plan file at ${path}`, "error");
|
|
74
|
+
else ctx.ui.notify("Plan review: no plan to review; write one with write_plan or pass a path.", "warning");
|
|
62
75
|
return;
|
|
63
76
|
}
|
|
64
77
|
// The server is loopback-only, so a remote turn cannot drive it.
|
|
65
78
|
if (isRemoteTurn(ctx)) {
|
|
66
|
-
ctx.ui.notify("
|
|
79
|
+
ctx.ui.notify("Plan review: browser review needs the local machine.", "warning");
|
|
67
80
|
return;
|
|
68
81
|
}
|
|
69
82
|
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
83
|
+
const baseline = runtime.reviewed(file.path);
|
|
84
|
+
const result = await runBrowserReview(web, ctx.cwd, file, {
|
|
85
|
+
canApprove: false,
|
|
86
|
+
baseline,
|
|
87
|
+
// Mark only once the page is actually open: a failed start must not consume
|
|
88
|
+
// the baseline for a plan nobody saw.
|
|
89
|
+
onOpened: (session) => {
|
|
90
|
+
runtime.markReviewed(file.path, file.content);
|
|
91
|
+
ctx.ui.notify(`Plan review: opened ${session.url}`, "info");
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
if (!result) {
|
|
95
|
+
ctx.ui.notify("Plan review: could not start the browser review server.", "error");
|
|
75
96
|
return;
|
|
76
97
|
}
|
|
77
|
-
ctx.ui.notify(`plan review: opened ${session.url}`, "info");
|
|
78
|
-
const result = await session.done;
|
|
79
|
-
await session.close();
|
|
80
98
|
|
|
81
99
|
if (result.status === "cancelled") {
|
|
82
|
-
ctx.ui.notify("
|
|
100
|
+
ctx.ui.notify("Plan review: closed without saving.", "info");
|
|
83
101
|
return;
|
|
84
102
|
}
|
|
85
103
|
const summary = feedbackSummary(result.annotations);
|
|
86
104
|
if (!summary) {
|
|
87
|
-
ctx.ui.notify("
|
|
105
|
+
ctx.ui.notify("Plan review: saved with no comments.", "info");
|
|
88
106
|
return;
|
|
89
107
|
}
|
|
90
108
|
const message = runtime.isEnabled()
|
|
@@ -94,6 +112,47 @@ async function reviewInBrowser(
|
|
|
94
112
|
else await pi.sendUserMessage(message, { deliverAs: "followUp" });
|
|
95
113
|
}
|
|
96
114
|
|
|
115
|
+
/**
|
|
116
|
+
* `/plan list`: show the plans directory, newest first. The TUI gets a docked
|
|
117
|
+
* list whose Enter opens the focused plan's review; every other mode gets the
|
|
118
|
+
* same facts as a notice, with each plan's path.
|
|
119
|
+
*/
|
|
120
|
+
async function openPlanIndex(
|
|
121
|
+
pi: ExtensionAPI,
|
|
122
|
+
runtime: PlanRuntime,
|
|
123
|
+
web: PlanWebReview,
|
|
124
|
+
ctx: ExtensionContext,
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
const dir = await runtime.plans.dirFor(ctx.cwd);
|
|
127
|
+
const summarise = async (): Promise<Awaited<ReturnType<typeof runtime.plans.index>>> => runtime.plans.index(ctx.cwd);
|
|
128
|
+
const summaries = await summarise();
|
|
129
|
+
|
|
130
|
+
if (ctx.mode !== "tui") {
|
|
131
|
+
ctx.ui.notify(formatPlanIndex(summaries, dir), "info");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// The review runs after the screen closes, so the input focus is free again.
|
|
136
|
+
let selected: (PlanSummary | undefined) | undefined;
|
|
137
|
+
await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
|
|
138
|
+
const component = new PlanIndexComponent({
|
|
139
|
+
summaries,
|
|
140
|
+
dir,
|
|
141
|
+
theme,
|
|
142
|
+
onSelect: (summary) => {
|
|
143
|
+
selected = summary;
|
|
144
|
+
done();
|
|
145
|
+
},
|
|
146
|
+
onClose: () => done(),
|
|
147
|
+
reload: summarise,
|
|
148
|
+
requestRender: () => tui.requestRender(),
|
|
149
|
+
viewportRows: () => tui.terminal?.rows,
|
|
150
|
+
});
|
|
151
|
+
return component;
|
|
152
|
+
});
|
|
153
|
+
if (selected) await reviewInBrowser(pi, runtime, web, selected.path, ctx);
|
|
154
|
+
}
|
|
155
|
+
|
|
97
156
|
export function registerCommands(
|
|
98
157
|
pi: ExtensionAPI,
|
|
99
158
|
runtime: PlanRuntime,
|
|
@@ -101,16 +160,22 @@ export function registerCommands(
|
|
|
101
160
|
): void {
|
|
102
161
|
pi.registerCommand("plan", {
|
|
103
162
|
description:
|
|
104
|
-
"Toggle plan mode (read-only exploration), enter it with an optional task, or review
|
|
163
|
+
"Toggle plan mode (read-only exploration), enter it with an optional task, list saved plans, or review one in the browser",
|
|
105
164
|
getArgumentCompletions: (prefix) => {
|
|
106
165
|
const text = prefix.trimStart();
|
|
107
166
|
if (text.includes(" ")) return null;
|
|
108
|
-
|
|
167
|
+
const subcommands = [LIST_PREFIX, REVIEW_PREFIX].filter((name) => name.startsWith(text));
|
|
168
|
+
return subcommands.length > 0 ? subcommands.map((value) => ({ value, label: value })) : null;
|
|
109
169
|
},
|
|
110
170
|
handler: async (args, ctx) => {
|
|
111
171
|
const prompt = args.trim();
|
|
112
172
|
|
|
113
|
-
// `/plan review [path]`
|
|
173
|
+
// `/plan list` and `/plan review [path]` are reserved subcommands;
|
|
174
|
+
// everything else is a task.
|
|
175
|
+
if (isListSubcommand(prompt)) {
|
|
176
|
+
await openPlanIndex(pi, runtime, web, ctx);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
114
179
|
if (isReviewSubcommand(prompt)) {
|
|
115
180
|
await reviewInBrowser(pi, runtime, web, reviewTarget(prompt), ctx);
|
|
116
181
|
return;
|
package/extensions/plan/index.ts
CHANGED
|
@@ -21,7 +21,10 @@ import { Key } from "@earendil-works/pi-tui";
|
|
|
21
21
|
import { fileURLToPath } from "node:url";
|
|
22
22
|
import { hasPathInput } from "./path-guard.ts";
|
|
23
23
|
import { isExtensionEnabled } from "../../lib/env.ts";
|
|
24
|
+
import { sanitizeLine } from "../../lib/format.ts";
|
|
25
|
+
import { lastGoal, type PublishedGoal } from "../../lib/goal-state.ts";
|
|
24
26
|
import { ASK_USER_QUESTION_TOOL, SUBAGENT_READ_ONLY_PARAM, SUBAGENT_TOOL } from "../../lib/tool-names.ts";
|
|
27
|
+
import { clip } from "../../lib/tui/columns.ts";
|
|
25
28
|
import { STATUS_KEYS } from "../../lib/tui/glyphs.ts";
|
|
26
29
|
import { planModeNotice, registerCommands } from "./commands.ts";
|
|
27
30
|
import { checkReadOnlyGit } from "./git-bash.ts";
|
|
@@ -39,15 +42,34 @@ import { createPlanWebReview } from "./web.ts";
|
|
|
39
42
|
/** Marker embedded in the injected prompt and used to filter stale context. */
|
|
40
43
|
export const PLAN_MODE_MARKER = "[PLAN MODE ACTIVE]";
|
|
41
44
|
|
|
42
|
-
/**
|
|
43
|
-
|
|
44
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Longest goal text restated here. `goal` injects the full objective every turn
|
|
47
|
+
* regardless of plan mode, so this line is a short pointer; the clip is what keeps
|
|
48
|
+
* the whole context inside the prompt-budget ceiling.
|
|
49
|
+
*/
|
|
50
|
+
const GOAL_CONTEXT_CHARS = 64;
|
|
45
51
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Injected each turn while planning: the facts and the next action, not the tool
|
|
54
|
+
* contracts. An active session goal becomes one extra bullet, so a plan is
|
|
55
|
+
* designed against the objective instead of beside it.
|
|
56
|
+
*/
|
|
57
|
+
export function planModeContext(goal?: PublishedGoal | null): string {
|
|
58
|
+
const lines = [
|
|
59
|
+
PLAN_MODE_MARKER,
|
|
60
|
+
"You are in plan mode: read-only exploration for safe code analysis.",
|
|
61
|
+
"",
|
|
62
|
+
`- Do not modify anything except the plan file: while planning, ${READ_ONLY_SUMMARY}.`,
|
|
63
|
+
"- bash is git-only: read-only commands (git status, git diff, git log) may be chained with && or ;, but pipes, substitution, and redirection are blocked and powershell is unavailable. The cwd is already the project root, so do not prefix commands with cd; use read, grep, find, and ls for files.",
|
|
64
|
+
"- Investigate and design a concrete plan, then save it with write_plan and call exit_plan_mode with the returned plan_path for approval.",
|
|
65
|
+
"- Delegate broad exploration with subagent; while planning it is forced read-only.",
|
|
66
|
+
`- To choose between approaches, ask with ${ASK_USER_QUESTION_TOOL} before finalizing.`,
|
|
67
|
+
];
|
|
68
|
+
if (goal?.status === "active") {
|
|
69
|
+
lines.push(`- Current session goal: ${clip(sanitizeLine(goal.objective), GOAL_CONTEXT_CHARS)}`);
|
|
70
|
+
}
|
|
71
|
+
return lines.join("\n");
|
|
72
|
+
}
|
|
51
73
|
|
|
52
74
|
/** This extension entry file, as Pi records it for our tool registrations. */
|
|
53
75
|
function extensionEntryPath(): string | undefined {
|
|
@@ -158,10 +180,12 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
158
180
|
return reason ? { block: true, reason } : undefined;
|
|
159
181
|
});
|
|
160
182
|
|
|
161
|
-
// Tell the model it is planning
|
|
162
|
-
|
|
183
|
+
// Tell the model it is planning, and restate an active goal so the plan is
|
|
184
|
+
// designed against it. Reads the branch, so no extra model call is involved.
|
|
185
|
+
pi.on("before_agent_start", (_event, ctx) => {
|
|
163
186
|
if (!runtime.isEnabled()) return undefined;
|
|
164
|
-
|
|
187
|
+
const goal = lastGoal(ctx.sessionManager.getBranch());
|
|
188
|
+
return { message: { customType: "plan-mode-context", content: planModeContext(goal), display: false } };
|
|
165
189
|
});
|
|
166
190
|
|
|
167
191
|
// Keep stale plan-mode context out of later, non-plan turns (for example after /resume).
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `/plan list` surface: the plan index as a notice, and as a docked list
|
|
3
|
+
* screen.
|
|
4
|
+
*
|
|
5
|
+
* A plan is a file, and the plans directory is its only index, so this module
|
|
6
|
+
* presents what `PlanStore.index` walked: the title, the modified time, and how
|
|
7
|
+
* far its steps got, newest first. Enter on the screen hands the focused plan
|
|
8
|
+
* back to the caller, which reuses the browser review; every other mode gets the
|
|
9
|
+
* same facts as one line per plan, with the path to reopen.
|
|
10
|
+
*
|
|
11
|
+
* Derived state only: the summaries are passed in, a reload replaces them, and a
|
|
12
|
+
* render reads them — like the `/memory` picker.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { type Component, Key, matchesKey, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
|
|
17
|
+
import { sanitizeLine, shortenPath } from "../../lib/format.ts";
|
|
18
|
+
import { fitRows, formatRange, isCloseKey, ListCursor, navigateList, scrollList } from "../../lib/tui/cursor.ts";
|
|
19
|
+
import { dockListFrame, screenHint, type ViewportRowsSource } from "../../lib/tui/chrome.ts";
|
|
20
|
+
import { clip } from "../../lib/tui/columns.ts";
|
|
21
|
+
import { emptyState, listRow } from "../../lib/tui/rows.ts";
|
|
22
|
+
import { titleRule } from "../../lib/tui/rules.ts";
|
|
23
|
+
import { paint } from "../../lib/tui/tokens.ts";
|
|
24
|
+
import type { PlanSummary } from "./plans.ts";
|
|
25
|
+
|
|
26
|
+
/** Rows the screen shows when the terminal height is unknown. */
|
|
27
|
+
const DEFAULT_ROWS = 14;
|
|
28
|
+
/** Fixed rows around the list: header, summary, blank, range, detail, hint, blank. */
|
|
29
|
+
const CHROME = 7;
|
|
30
|
+
/** Longest title a row draws before the trailing meta wins the column. */
|
|
31
|
+
const TITLE_WIDTH = 56;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* How long ago a plan was modified: `today`, `3d ago`, `2mo ago`, `1y ago`.
|
|
35
|
+
* Coarse on purpose — a list row compares plans, it does not time them.
|
|
36
|
+
*/
|
|
37
|
+
export function relativeAge(modifiedMs: number, nowMs: number): string {
|
|
38
|
+
const days = Math.floor(Math.max(0, nowMs - modifiedMs) / 86_400_000);
|
|
39
|
+
if (days < 1) return "today";
|
|
40
|
+
if (days < 30) return `${days}d ago`;
|
|
41
|
+
const months = Math.floor(days / 30);
|
|
42
|
+
if (months < 12) return `${months}mo ago`;
|
|
43
|
+
return `${Math.floor(days / 365)}y ago`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `3/7 steps`, `2/2 steps`, or `steps unknown` for a file the store would not have written. */
|
|
47
|
+
function stepsText(summary: PlanSummary): string {
|
|
48
|
+
if (!summary.steps) return "steps unknown";
|
|
49
|
+
return `${summary.steps.done}/${summary.steps.total} steps`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Local `YYYY-MM-DD HH:mm`, matching the stamp the file name carries. */
|
|
53
|
+
function localTime(ms: number): string {
|
|
54
|
+
const date = new Date(ms);
|
|
55
|
+
const pad = (value: number): string => String(value).padStart(2, "0");
|
|
56
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The non-TUI listing: one line per plan with its title, step progress, modified
|
|
61
|
+
* time, and path, newest first. The directory is named once in the header.
|
|
62
|
+
*/
|
|
63
|
+
export function formatPlanIndex(summaries: readonly PlanSummary[], dir: string): string {
|
|
64
|
+
const where = sanitizeLine(shortenPath(dir));
|
|
65
|
+
if (summaries.length === 0) {
|
|
66
|
+
return `No plans in ${where} yet; write_plan saves one there.`;
|
|
67
|
+
}
|
|
68
|
+
const lines = summaries.map(
|
|
69
|
+
(summary) =>
|
|
70
|
+
` ${sanitizeLine(summary.title)} — ${stepsText(summary)} — ${localTime(summary.modified)} — ${sanitizeLine(summary.relativePath)}`,
|
|
71
|
+
);
|
|
72
|
+
return [`Plans (${summaries.length}) — ${where}`, ...lines].join("\n");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface PlanIndexOptions {
|
|
76
|
+
summaries: readonly PlanSummary[];
|
|
77
|
+
/** Directory the plans live in, for the header. */
|
|
78
|
+
dir: string;
|
|
79
|
+
theme: Theme;
|
|
80
|
+
/** Enter on the focused plan; the caller opens its review after the screen closes. */
|
|
81
|
+
onSelect: (summary: PlanSummary) => void;
|
|
82
|
+
onClose: () => void;
|
|
83
|
+
/** `r`: re-read the directory. Resolves with the new rows. */
|
|
84
|
+
reload: () => Promise<readonly PlanSummary[]>;
|
|
85
|
+
requestRender: () => void;
|
|
86
|
+
viewportRows?: ViewportRowsSource;
|
|
87
|
+
/** Clock for the relative ages; defaults to the wall clock. */
|
|
88
|
+
now?: () => number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Docked `/plan list` screen: the plans directory, newest first. */
|
|
92
|
+
export class PlanIndexComponent implements Component {
|
|
93
|
+
private readonly cursor: ListCursor;
|
|
94
|
+
private summaries: readonly PlanSummary[];
|
|
95
|
+
private notice: string | undefined;
|
|
96
|
+
private componentFocused = false;
|
|
97
|
+
|
|
98
|
+
constructor(private readonly options: PlanIndexOptions) {
|
|
99
|
+
this.summaries = options.summaries;
|
|
100
|
+
this.cursor = new ListCursor(() => options.requestRender());
|
|
101
|
+
// A screen can take its first key before it is drawn; keep the cursor valid.
|
|
102
|
+
this.cursor.sync(this.summaries.length);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Replace the rows after a reload, keeping the focused plan if it is still there. */
|
|
106
|
+
private setSummaries(next: readonly PlanSummary[]): void {
|
|
107
|
+
this.summaries = next;
|
|
108
|
+
this.cursor.sync(next.length);
|
|
109
|
+
this.cursor.selected = Math.min(this.cursor.selected, Math.max(0, next.length - 1));
|
|
110
|
+
this.requestRender();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private requestRender(): void {
|
|
114
|
+
this.options.requestRender();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private reload(): void {
|
|
118
|
+
void this.options
|
|
119
|
+
.reload()
|
|
120
|
+
.then((next) => {
|
|
121
|
+
this.setSummaries(next);
|
|
122
|
+
this.notice = next.length === 0 ? "No plans yet." : undefined;
|
|
123
|
+
})
|
|
124
|
+
.catch((error: unknown) => {
|
|
125
|
+
this.notice = `Could not read the plans directory: ${error instanceof Error ? error.message : String(error)}`;
|
|
126
|
+
this.requestRender();
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
handleInput(data: string): void {
|
|
131
|
+
if (isCloseKey(data) || data === "q") {
|
|
132
|
+
this.options.onClose();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (this.notice !== undefined) this.notice = undefined;
|
|
136
|
+
|
|
137
|
+
if (navigateList(data, this.cursor, this.summaries.length)) {
|
|
138
|
+
this.requestRender();
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
// The hint advertises `g`/`G`, so bind them to the ends.
|
|
142
|
+
if (data === "g" || data === "G") {
|
|
143
|
+
this.cursor.set(data === "g" ? 0 : this.summaries.length - 1, this.summaries.length);
|
|
144
|
+
this.requestRender();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (data === "r") {
|
|
148
|
+
this.reload();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (matchesKey(data, Key.enter)) {
|
|
152
|
+
const focused = this.summaries[this.cursor.selected];
|
|
153
|
+
if (focused) this.options.onSelect(focused);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Wheel scrolling moves the selection, matching the other list screens. */
|
|
158
|
+
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
159
|
+
return scrollList(event, this.cursor, this.summaries.length);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Rows are built per render from the current theme and summaries; nothing is memoized. */
|
|
163
|
+
invalidate(): void {}
|
|
164
|
+
|
|
165
|
+
get focused(): boolean {
|
|
166
|
+
return this.componentFocused;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
set focused(value: boolean) {
|
|
170
|
+
this.componentFocused = value;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private row(summary: PlanSummary, selected: boolean, width: number): string {
|
|
174
|
+
const now = (this.options.now ?? Date.now)();
|
|
175
|
+
const trailing = `${stepsText(summary)} · ${relativeAge(summary.modified, now)}`;
|
|
176
|
+
return listRow(this.options.theme, width, {
|
|
177
|
+
selected,
|
|
178
|
+
label: clip(sanitizeLine(summary.title), TITLE_WIDTH),
|
|
179
|
+
labelRole: selected ? "accent" : "label",
|
|
180
|
+
trailing,
|
|
181
|
+
trailingRole: "meta",
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private summaryLine(width: number): string {
|
|
186
|
+
const { theme } = this.options;
|
|
187
|
+
const count = this.summaries.length;
|
|
188
|
+
const text = `${paint(theme, "accent", "Plans")} ${paint(theme, "meta", `· ${count} ${count === 1 ? "plan" : "plans"}`)}`;
|
|
189
|
+
const where = paint(theme, "faint", ` · ${sanitizeLine(shortenPath(this.options.dir))}`);
|
|
190
|
+
return clip(` ${text}${where}`, width);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private detailLines(width: number): string[] {
|
|
194
|
+
const focused = this.summaries[this.cursor.selected];
|
|
195
|
+
if (!focused) return [];
|
|
196
|
+
const { theme } = this.options;
|
|
197
|
+
const path = paint(theme, "meta", sanitizeLine(focused.relativePath));
|
|
198
|
+
const time = paint(theme, "faint", ` · modified ${localTime(focused.modified)}`);
|
|
199
|
+
return [clip(` ${path}${time}`, width)];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
render(width: number): string[] {
|
|
203
|
+
const { theme } = this.options;
|
|
204
|
+
const w = Math.max(1, width);
|
|
205
|
+
this.cursor.visible = fitRows(this.options.viewportRows, this.summaries.length, CHROME, DEFAULT_ROWS);
|
|
206
|
+
this.cursor.sync(this.summaries.length);
|
|
207
|
+
|
|
208
|
+
const body: string[] = [];
|
|
209
|
+
if (this.summaries.length === 0) {
|
|
210
|
+
body.push(...emptyState(theme, w, { label: "No plans yet. write_plan saves one under .pi/plans." }));
|
|
211
|
+
} else {
|
|
212
|
+
const { start, end } = this.cursor.window(this.summaries.length);
|
|
213
|
+
for (let index = start; index < end; index++) {
|
|
214
|
+
body.push(this.row(this.summaries[index], index === this.cursor.selected, w));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const hint =
|
|
219
|
+
this.notice !== undefined
|
|
220
|
+
? clip(` ${paint(theme, "meta", this.notice)}`, w)
|
|
221
|
+
: screenHint(theme, w, ["Esc close", { text: "Enter review", color: "accent" }, "r reload", "↑/↓", "g/G"]);
|
|
222
|
+
|
|
223
|
+
return dockListFrame({
|
|
224
|
+
header: titleRule(theme, w, "Plan index"),
|
|
225
|
+
summary: this.summaryLine(w),
|
|
226
|
+
body,
|
|
227
|
+
range: this.cursor.clipped(this.summaries.length)
|
|
228
|
+
? formatRange(theme, w, { ...this.cursor.window(this.summaries.length), total: this.summaries.length })
|
|
229
|
+
: undefined,
|
|
230
|
+
detail: this.detailLines(w),
|
|
231
|
+
hint,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plan text as a review shows and compares it (pure).
|
|
3
|
+
*
|
|
4
|
+
* Two needs pull in the same direction. A plan is model-authored text, so it is
|
|
5
|
+
* neutralized before it reaches a terminal or a page; and a review has to compare
|
|
6
|
+
* what it shows against what the previous review showed. Both start from the same
|
|
7
|
+
* normalization — control characters stripped, tabs expanded, stored feedback
|
|
8
|
+
* removed — so the digest a review writes and the digest a later review compares
|
|
9
|
+
* cannot disagree about a plan that did not change.
|
|
10
|
+
*
|
|
11
|
+
* The baseline is one digest per heading section rather than the previous plan
|
|
12
|
+
* text, so it can live in a session entry without copying the plan file into the
|
|
13
|
+
* branch on every review.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import { stripControlChars } from "../../lib/format.ts";
|
|
18
|
+
import { stripFeedback } from "./feedback.ts";
|
|
19
|
+
import { extractPlanSections } from "./steps.ts";
|
|
20
|
+
|
|
21
|
+
/** Spaces a tab expands to; the terminal's own tab stops are not width-modelled. */
|
|
22
|
+
const TAB_WIDTH = 4;
|
|
23
|
+
|
|
24
|
+
/** One heading section's identity and content digest. */
|
|
25
|
+
export interface SectionDigest {
|
|
26
|
+
/** Plain heading text, or `""` for the content before the first heading. */
|
|
27
|
+
title: string;
|
|
28
|
+
/** SHA-256 of the section's normalized text, heading included. */
|
|
29
|
+
digest: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Replace control characters (including ESC and the C1 block) and expand tabs
|
|
34
|
+
* so model-authored plan text cannot drive the terminal. Newlines are kept, so
|
|
35
|
+
* callers can still render or split the text line by line.
|
|
36
|
+
*/
|
|
37
|
+
export function sanitizePlanText(text: string): string {
|
|
38
|
+
return stripControlChars(text.replace(/\t/g, " ".repeat(TAB_WIDTH)));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The text a review compares: terminal-safe, with tabs expanded and stored
|
|
43
|
+
* feedback removed. A review comment a browser wrote into the file is a note to
|
|
44
|
+
* the model, not an edit, so it never reads as a changed section.
|
|
45
|
+
*/
|
|
46
|
+
export function reviewText(content: string): string {
|
|
47
|
+
return stripFeedback(sanitizePlanText(content));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Digest one section's text; the heading line is part of it. */
|
|
51
|
+
function digestOf(text: string): string {
|
|
52
|
+
return createHash("sha256").update(text.trim()).digest("hex");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** One digest per heading section, in document order, for a persisted baseline. */
|
|
56
|
+
export function sectionDigests(content: string): SectionDigest[] {
|
|
57
|
+
return extractPlanSections(reviewText(content)).map((section) => ({
|
|
58
|
+
title: section.title,
|
|
59
|
+
digest: digestOf(section.text),
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Whether two baselines describe the same content, section for section. */
|
|
64
|
+
export function sameSections(a: readonly SectionDigest[], b: readonly SectionDigest[]): boolean {
|
|
65
|
+
if (a.length !== b.length) return false;
|
|
66
|
+
return a.every((section, index) => section.title === b[index].title && section.digest === b[index].digest);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Per-section change flags for a second review: `boolean[]` aligned with
|
|
71
|
+
* `extractPlanSections(reviewText(current))`, so duplicate headings are flagged
|
|
72
|
+
* by occurrence rather than by title. An absent baseline flags nothing, which is
|
|
73
|
+
* what a first review of a plan looks like; `stripFeedback` only ever drops
|
|
74
|
+
* quote-marker lines, so the flags stay aligned with the sections a screen
|
|
75
|
+
* renders from the same content.
|
|
76
|
+
*/
|
|
77
|
+
export function changedSections(baseline: readonly SectionDigest[] | undefined, current: string): boolean[] {
|
|
78
|
+
const sections = extractPlanSections(reviewText(current));
|
|
79
|
+
if (baseline === undefined) return sections.map(() => false);
|
|
80
|
+
|
|
81
|
+
const before = new Map<string, string[]>();
|
|
82
|
+
for (const section of baseline) {
|
|
83
|
+
const digests = before.get(section.title);
|
|
84
|
+
if (digests) digests.push(section.digest);
|
|
85
|
+
else before.set(section.title, [section.digest]);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const seen = new Map<string, number>();
|
|
89
|
+
return sections.map((section) => {
|
|
90
|
+
const index = seen.get(section.title) ?? 0;
|
|
91
|
+
seen.set(section.title, index + 1);
|
|
92
|
+
const prior = before.get(section.title)?.[index];
|
|
93
|
+
return prior === undefined || prior !== digestOf(section.text);
|
|
94
|
+
});
|
|
95
|
+
}
|