@gavin-hu/my-pi-agent 0.12.0 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/README.md +28 -21
  2. package/README.zh-CN.md +30 -22
  3. package/extensions/bytefly/README.md +162 -70
  4. package/extensions/bytefly/chrome.ts +7 -2
  5. package/extensions/bytefly/doctor/register.ts +6 -0
  6. package/extensions/bytefly/doctor/runtime.ts +38 -6
  7. package/extensions/bytefly/footer/budget.ts +64 -0
  8. package/extensions/bytefly/footer/collect/snapshot.ts +33 -5
  9. package/extensions/bytefly/footer/config.ts +7 -1
  10. package/extensions/bytefly/footer/delegation.ts +100 -0
  11. package/extensions/bytefly/footer/price/config.ts +16 -0
  12. package/extensions/bytefly/footer/price/types.ts +5 -0
  13. package/extensions/bytefly/footer/register.ts +46 -4
  14. package/extensions/bytefly/footer/render/lines.ts +2 -0
  15. package/extensions/bytefly/footer/render/segments.ts +27 -0
  16. package/extensions/bytefly/footer/types.ts +11 -0
  17. package/extensions/bytefly/index.ts +8 -5
  18. package/extensions/bytefly/notify/config.ts +2 -0
  19. package/extensions/bytefly/notify/format.ts +19 -1
  20. package/extensions/bytefly/notify/register.ts +52 -14
  21. package/extensions/bytefly/notify/types.ts +2 -0
  22. package/extensions/bytefly/panel/commands.ts +34 -13
  23. package/extensions/bytefly/panel/settings.ts +129 -16
  24. package/extensions/bytefly/update/cache.ts +73 -0
  25. package/extensions/bytefly/update/format.ts +65 -10
  26. package/extensions/bytefly/update/register.ts +99 -13
  27. package/extensions/bytefly/update/state.ts +13 -1
  28. package/extensions/document/README.md +22 -12
  29. package/extensions/document/config.ts +4 -0
  30. package/extensions/document/deadline.ts +61 -0
  31. package/extensions/document/tool.ts +13 -2
  32. package/extensions/file-browse/README.md +12 -8
  33. package/extensions/file-browse/client.ts +6 -7
  34. package/extensions/file-browse/files.ts +0 -4
  35. package/extensions/file-browse/git.ts +9 -10
  36. package/extensions/file-browse/html.ts +17 -7
  37. package/extensions/file-browse/router.ts +24 -15
  38. package/extensions/goal/README.md +7 -1
  39. package/extensions/goal/schema.ts +6 -2
  40. package/extensions/goal/types.ts +6 -2
  41. package/extensions/job/README.md +16 -12
  42. package/extensions/job/format.ts +4 -7
  43. package/extensions/job/runtime.ts +23 -6
  44. package/extensions/job/status.ts +4 -2
  45. package/extensions/memory/README.md +55 -13
  46. package/extensions/memory/commands.ts +20 -2
  47. package/extensions/memory/doctor.ts +137 -0
  48. package/extensions/memory/format.ts +24 -7
  49. package/extensions/memory/runtime.ts +49 -0
  50. package/extensions/memory/store.ts +31 -0
  51. package/extensions/memory/tui.ts +136 -12
  52. package/extensions/plan/README.md +60 -29
  53. package/extensions/plan/commands.ts +67 -8
  54. package/extensions/plan/index.ts +35 -11
  55. package/extensions/plan/plan-index.ts +234 -0
  56. package/extensions/plan/plan-text.ts +95 -0
  57. package/extensions/plan/plans.ts +90 -1
  58. package/extensions/plan/review.ts +12 -9
  59. package/extensions/plan/runtime.ts +72 -27
  60. package/extensions/plan/steps.ts +35 -25
  61. package/extensions/plan/todos.ts +24 -2
  62. package/extensions/plan/tools.ts +124 -24
  63. package/extensions/plan/tui.ts +7 -28
  64. package/extensions/plan/types.ts +9 -0
  65. package/extensions/plan/web-page.ts +2 -2
  66. package/extensions/plan/web.ts +12 -11
  67. package/extensions/rewind/README.md +28 -8
  68. package/extensions/rewind/commands.ts +1 -1
  69. package/extensions/rewind/format.ts +4 -4
  70. package/extensions/rewind/git.ts +56 -18
  71. package/extensions/rewind/index.ts +3 -5
  72. package/extensions/rewind/path-picker.ts +198 -0
  73. package/extensions/rewind/paths.ts +128 -0
  74. package/extensions/rewind/restore.ts +69 -28
  75. package/extensions/rewind/rewind.ts +80 -3
  76. package/extensions/rewind/runtime.ts +13 -5
  77. package/extensions/rewind/snapshot-run.ts +5 -2
  78. package/extensions/rewind/types.ts +2 -0
  79. package/extensions/subagent/README.md +7 -1
  80. package/extensions/subagent/accounting.ts +34 -0
  81. package/extensions/subagent/index.ts +23 -1
  82. package/extensions/subagent/stream.ts +2 -0
  83. package/extensions/subagent/types.ts +2 -0
  84. package/extensions/todo/nudge.ts +5 -1
  85. package/extensions/watch/README.md +19 -8
  86. package/extensions/watch/commands.ts +4 -3
  87. package/extensions/watch/config.ts +1 -1
  88. package/extensions/watch/format.ts +4 -7
  89. package/extensions/watch/index.ts +19 -0
  90. package/extensions/watch/runtime.ts +83 -12
  91. package/extensions/web-access/README.md +43 -13
  92. package/extensions/web-access/charset.ts +58 -0
  93. package/extensions/web-access/fetch/find.ts +11 -7
  94. package/extensions/web-access/fetch/format.ts +64 -18
  95. package/extensions/web-access/fetch/page.ts +5 -1
  96. package/extensions/web-access/fetch/tool.ts +19 -18
  97. package/extensions/web-access/http.ts +49 -8
  98. package/extensions/web-access/search/facts.ts +38 -0
  99. package/extensions/web-access/search/tool.ts +13 -0
  100. package/extensions/web-access/text.ts +50 -11
  101. package/extensions/worktree/README.md +81 -16
  102. package/extensions/worktree/config.ts +11 -0
  103. package/extensions/worktree/enter.ts +10 -3
  104. package/extensions/worktree/exit.ts +55 -3
  105. package/extensions/worktree/gaps.ts +74 -0
  106. package/extensions/worktree/git.ts +28 -0
  107. package/extensions/worktree/guard.ts +50 -4
  108. package/extensions/worktree/handoff.ts +55 -0
  109. package/extensions/worktree/index.ts +14 -2
  110. package/extensions/worktree/root-tools.ts +1 -1
  111. package/extensions/worktree/runtime.ts +25 -0
  112. package/extensions/worktree/status.ts +15 -2
  113. package/extensions/worktree/tools.ts +3 -2
  114. package/lib/README.md +14 -10
  115. package/lib/events.ts +86 -1
  116. package/lib/goal-state.ts +95 -0
  117. package/lib/requirements.ts +361 -62
  118. package/lib/tui/README.md +91 -0
  119. package/lib/tui/columns.ts +17 -0
  120. package/lib/tui/glyphs.ts +1 -0
  121. package/lib/tui/tool.ts +6 -2
  122. package/lib/web/layout.ts +12 -2
  123. package/package.json +5 -3
  124. package/themes/bytefly-dark.json +1 -1
  125. package/assets/chrome.png +0 -0
  126. package/assets/file-browse.png +0 -0
  127. package/assets/job.png +0 -0
  128. package/assets/plan-review.png +0 -0
  129. package/assets/question.png +0 -0
  130. package/assets/rewind.png +0 -0
  131. package/assets/todo.png +0 -0
  132. package/assets/tool-renderings.png +0 -0
  133. package/assets/tools/ask_user_question-cancelled.png +0 -0
  134. package/assets/tools/ask_user_question.png +0 -0
  135. package/assets/tools/bash.png +0 -0
  136. package/assets/tools/edit.png +0 -0
  137. package/assets/tools/enter_plan_mode.png +0 -0
  138. package/assets/tools/enter_worktree.png +0 -0
  139. package/assets/tools/exit_plan_mode.png +0 -0
  140. package/assets/tools/exit_worktree.png +0 -0
  141. package/assets/tools/find.png +0 -0
  142. package/assets/tools/goal.png +0 -0
  143. package/assets/tools/grep.png +0 -0
  144. package/assets/tools/job.png +0 -0
  145. package/assets/tools/list_worktrees.png +0 -0
  146. package/assets/tools/ls.png +0 -0
  147. package/assets/tools/memory-long-subject.png +0 -0
  148. package/assets/tools/memory.png +0 -0
  149. package/assets/tools/notice.png +0 -0
  150. package/assets/tools/open_path.png +0 -0
  151. package/assets/tools/prune_worktrees.png +0 -0
  152. package/assets/tools/read.png +0 -0
  153. package/assets/tools/read_doc-running.png +0 -0
  154. package/assets/tools/read_doc.png +0 -0
  155. package/assets/tools/run_doctor.png +0 -0
  156. package/assets/tools/send_wechat.png +0 -0
  157. package/assets/tools/set_plan_steps.png +0 -0
  158. package/assets/tools/subagent.png +0 -0
  159. package/assets/tools/todo.png +0 -0
  160. package/assets/tools/turn-rule.png +0 -0
  161. package/assets/tools/user-message.png +0 -0
  162. package/assets/tools/watch.png +0 -0
  163. package/assets/tools/web_fetch-rejected.png +0 -0
  164. package/assets/tools/web_fetch.png +0 -0
  165. package/assets/tools/web_search.png +0 -0
  166. package/assets/tools/write.png +0 -0
  167. package/assets/tools/write_plan.png +0 -0
@@ -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
- /** Injected each turn while planning: the facts and the next action, not the tool contracts. */
43
- export const PLAN_MODE_CONTEXT = `${PLAN_MODE_MARKER}
44
- You are in plan mode: read-only exploration for safe code analysis.
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
- - Do not modify anything except the plan file: while planning, ${READ_ONLY_SUMMARY}.
47
- - 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.
48
- - Investigate and design a concrete plan, then save it with write_plan and call exit_plan_mode with the returned plan_path for approval.
49
- - Delegate broad exploration with subagent; while planning it is forced read-only.
50
- - To choose between approaches, ask with ${ASK_USER_QUESTION_TOOL} before finalizing.`;
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
- pi.on("before_agent_start", () => {
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
- return { message: { customType: "plan-mode-context", content: PLAN_MODE_CONTEXT, display: false } };
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
+ }
@@ -23,12 +23,14 @@ import {
23
23
  readFileSync,
24
24
  readdirSync,
25
25
  renameSync,
26
+ statSync,
26
27
  unlinkSync,
27
28
  writeFileSync,
28
29
  } from "node:fs";
29
30
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
30
31
  import { CONFIG_DIR_NAME, getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
31
32
  import { repoRootFor } from "../../lib/git/index.ts";
33
+ import { sanitizeLine } from "../../lib/format.ts";
32
34
  import { isInside, isInsideReal } from "../../lib/path.ts";
33
35
  import { formatFeedback, type PlanAnnotation } from "./feedback.ts";
34
36
  import { extractPlanSteps, matchesStepText, setStepStatuses, type StepUpdate } from "./steps.ts";
@@ -53,6 +55,23 @@ export interface WrittenPlan extends StoredPlan {
53
55
  title: string;
54
56
  }
55
57
 
58
+ /** One row of the plan index: enough to choose a plan without opening it. */
59
+ export interface PlanSummary {
60
+ /** Absolute path. */
61
+ path: string;
62
+ /** Path relative to the working directory, when it is inside it. */
63
+ relativePath: string;
64
+ /** The plan's `# title`, or a title derived from the file name. */
65
+ title: string;
66
+ /** File modification time, ms since the epoch. */
67
+ modified: number;
68
+ bytes: number;
69
+ /** Steps done and total, or undefined for a file the store would not have written. */
70
+ steps?: { done: number; total: number };
71
+ /** True when the file is over {@link MAX_PLAN_BYTES} and was listed without a read. */
72
+ oversize?: boolean;
73
+ }
74
+
56
75
  export interface WritePlanInput {
57
76
  /** Short title; drives the file-name slug. */
58
77
  title: string;
@@ -70,6 +89,11 @@ export interface PlanStore {
70
89
  read(cwd: string, planPath: string): Promise<StoredPlan | undefined>;
71
90
  /** The newest plan file in the plans directory, or undefined when there is none. */
72
91
  latest(cwd: string): Promise<StoredPlan | undefined>;
92
+ /**
93
+ * Every plan in the directory, newest first. The directory is the index, so
94
+ * this is one `readdir`, one `stat` per file, and one bounded read per plan.
95
+ */
96
+ index(cwd: string): Promise<PlanSummary[]>;
73
97
  /** Set the given top-level steps' checkboxes in an existing plan. Throws when the file or any step is invalid. */
74
98
  setSteps(cwd: string, planPath: string, updates: StepUpdate[]): Promise<StoredPlan>;
75
99
  /** Replace the plan's stored browser feedback. Throws when the file is missing or outside the plans directory. */
@@ -342,5 +366,70 @@ export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date }
342
366
  return newest ? read(cwd, newest.path) : undefined;
343
367
  };
344
368
 
345
- return { dirFor, write, read, latest, setSteps, saveFeedback };
369
+ /**
370
+ * Every plan in the directory, newest first: the directory is the index.
371
+ *
372
+ * One `readdir`, one `lstat`/`stat` per entry, and one `readFileSync` per plan
373
+ * that fits {@link MAX_PLAN_BYTES}. A larger file is listed as `oversize`
374
+ * without being read — the store would never have written it, and pulling it
375
+ * into memory to print one row is not worth the risk. A symlinked entry, a
376
+ * dotfile, or a file that vanishes mid-walk is simply not a plan.
377
+ */
378
+ const index = async (cwd: string): Promise<PlanSummary[]> => {
379
+ const dir = await dirFor(cwd);
380
+ let names: string[];
381
+ try {
382
+ names = readdirSync(dir);
383
+ } catch {
384
+ return [];
385
+ }
386
+
387
+ const summaries: PlanSummary[] = [];
388
+ for (const name of names) {
389
+ const path = join(dir, name);
390
+ if (!isPlanFileName(path)) continue;
391
+ let info: ReturnType<typeof statSync>;
392
+ try {
393
+ // lstat, not stat: a symlink is never a plan.
394
+ info = lstatSync(path);
395
+ } catch {
396
+ continue;
397
+ }
398
+ if (!info.isFile()) continue;
399
+ if (!isContainedPlan(dir, path)) continue;
400
+
401
+ const summary: PlanSummary = {
402
+ path,
403
+ relativePath: relativeTo(cwd, path),
404
+ title: planTitle(name),
405
+ modified: info.mtimeMs,
406
+ bytes: info.size,
407
+ };
408
+ if (info.size > MAX_PLAN_BYTES) {
409
+ summaries.push({ ...summary, oversize: true });
410
+ continue;
411
+ }
412
+ try {
413
+ const content = readFileSync(path, "utf-8");
414
+ const heading = /^ {0,3}#\s+(.+)$/m.exec(content)?.[1];
415
+ const title = heading === undefined ? "" : sanitizeLine(heading).trim();
416
+ const steps = extractPlanSteps(content);
417
+ summaries.push({
418
+ ...summary,
419
+ ...(title ? { title } : {}),
420
+ steps: { done: steps.filter((step) => step.status === "completed").length, total: steps.length },
421
+ });
422
+ } catch {
423
+ // An unreadable plan is still a file the user may want to see; list the
424
+ // row from its name and leave the content-derived fields out.
425
+ summaries.push(summary);
426
+ }
427
+ }
428
+ // Newest first, with the name (which carries the stamp) as the tie break —
429
+ // the same order `latest` picks from.
430
+ summaries.sort((a, b) => b.modified - a.modified || (a.path < b.path ? 1 : a.path > b.path ? -1 : 0));
431
+ return summaries;
432
+ };
433
+
434
+ return { dirFor, write, read, latest, index, setSteps, saveFeedback };
346
435
  }
@@ -18,6 +18,7 @@ import { askHuman } from "../../lib/interaction.ts";
18
18
  import { FULL_SCREEN_OVERLAY } from "../../lib/tui/chrome.ts";
19
19
  import { feedbackSummary, type PlanAnnotation } from "./feedback.ts";
20
20
  import type { PlanStore, StoredPlan } from "./plans.ts";
21
+ import type { SectionDigest } from "./plan-text.ts";
21
22
  import { extractPlanSteps } from "./steps.ts";
22
23
  import { PlanViewComponent, type PlanViewAction } from "./tui.ts";
23
24
  import type { PlanReviewResult, PlanReviewSession, PlanWebReview } from "./web.ts";
@@ -47,7 +48,9 @@ export type ReviewDecision =
47
48
 
48
49
  /** What the previous review showed, so this one can mark what changed. */
49
50
  export interface ReviewBaselines {
50
- reviewed(path: string): string | undefined;
51
+ /** The sections the last review of `path` showed, when this branch has one. */
52
+ reviewed(path: string): SectionDigest[] | undefined;
53
+ /** Remember what a review just showed, as a session entry on this branch. */
51
54
  markReviewed(path: string, content: string): void;
52
55
  }
53
56
 
@@ -106,7 +109,7 @@ function planReviewDigest(content: string): string {
106
109
  async function askPlanReview(
107
110
  ctx: ExtensionToolContext,
108
111
  plan: StoredPlan,
109
- previous: string | undefined,
112
+ baseline: SectionDigest[] | undefined,
110
113
  ): Promise<PlanReviewChoice> {
111
114
  let sentFullText = false;
112
115
  for (;;) {
@@ -116,7 +119,7 @@ async function askPlanReview(
116
119
  (tui, theme, _keybindings, done) =>
117
120
  new PlanViewComponent({
118
121
  plan,
119
- previous,
122
+ baseline,
120
123
  theme,
121
124
  onClose: (action, refinement) => done({ action, refinement }),
122
125
  requestRender: () => tui.requestRender(),
@@ -156,8 +159,8 @@ async function askPlanReview(
156
159
  export interface BrowserReviewOptions {
157
160
  /** Whether the browser may approve the plan (only the `exit_plan_mode` flow). */
158
161
  canApprove: boolean;
159
- /** The content the previous review showed, so the page can mark changed sections. */
160
- previous?: string;
162
+ /** The section digests the previous review showed, for the changed markers. */
163
+ baseline?: SectionDigest[];
161
164
  /** Aborting the turn closes the session as cancelled. */
162
165
  signal?: AbortSignal;
163
166
  /** Called once the server is listening and the browser was opened. */
@@ -176,7 +179,7 @@ export async function runBrowserReview(
176
179
  ): Promise<PlanReviewResult | undefined> {
177
180
  if (options.signal?.aborted) return { status: "cancelled" };
178
181
  const session = await web
179
- .start(cwd, plan, { canApprove: options.canApprove, previous: options.previous, signal: options.signal })
182
+ .start(cwd, plan, { canApprove: options.canApprove, baseline: options.baseline, signal: options.signal })
180
183
  .catch(() => undefined);
181
184
  if (!session) return undefined;
182
185
  options.onOpened?.(session);
@@ -200,8 +203,8 @@ export async function reviewPlan(options: ReviewPlanOptions): Promise<ReviewDeci
200
203
  if (signal?.aborted) return { action: "keep", plan };
201
204
  // Read the baseline before the screen and mark it after, so what the next
202
205
  // review compares against is the content this one actually showed.
203
- const previous = baselines.reviewed(plan.path);
204
- const choice = await askPlanReview(ctx, plan, previous);
206
+ const baseline = baselines.reviewed(plan.path);
207
+ const choice = await askPlanReview(ctx, plan, baseline);
205
208
  baselines.markReviewed(plan.path, plan.content);
206
209
 
207
210
  if (choice.action === "approve") return { action: "approve", plan, comments: [] };
@@ -218,7 +221,7 @@ export async function reviewPlan(options: ReviewPlanOptions): Promise<ReviewDeci
218
221
 
219
222
  const result = await runBrowserReview(web, ctx.cwd, plan, {
220
223
  canApprove: true,
221
- previous,
224
+ baseline,
222
225
  signal,
223
226
  onOpened: (session) => ctx.ui.notify(`Plan review: opened ${session.url}`, "info"),
224
227
  });