@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
@@ -25,7 +25,8 @@ import {
25
25
  type PlanAnnotation,
26
26
  } from "./feedback.ts";
27
27
  import { planTitle, type PlanStore, type StoredPlan } from "./plans.ts";
28
- import { changedSections, extractPlanSections, extractPlanSteps } from "./steps.ts";
28
+ import { changedSections, type SectionDigest } from "./plan-text.ts";
29
+ import { extractPlanSections, extractPlanSteps } from "./steps.ts";
29
30
  import {
30
31
  handleReviewRequest,
31
32
  JSON_HEADERS,
@@ -59,8 +60,8 @@ export interface PlanReviewOptions {
59
60
  signal?: AbortSignal;
60
61
  /** Whether the browser may approve the plan (only the `exit_plan_mode` flow). */
61
62
  canApprove?: boolean;
62
- /** The content the previous review showed, so this page can mark changed sections. */
63
- previous?: string;
63
+ /** The section digests the previous review showed, so this page can mark changed sections. */
64
+ baseline?: SectionDigest[];
64
65
  }
65
66
 
66
67
  /** Starts and tracks review sessions. */
@@ -166,31 +167,31 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
166
167
 
167
168
  // The stripped plan the page last rendered; a save must anchor against a file
168
169
  // that still matches it, or the comments land beside the wrong text.
169
- let baseline = stripFeedback(plan.content);
170
+ let pageText = stripFeedback(plan.content);
170
171
  let savedPlan: StoredPlan | undefined;
171
172
  // Set by `onSaved`/`onCancel`; resolved once the HTTP response has flushed,
172
173
  // so the caller cannot close the socket before the browser sees the reply.
173
174
  let pendingResult: PlanReviewResult | undefined;
174
175
 
175
176
  /**
176
- * Re-read the plan into `baseline` and return it. Named apart from the page
177
+ * Re-read the plan into `pageText` and return it. Named apart from the page
177
178
  * build because it writes the state a later save validates against, so a `GET`
178
179
  * does not hide that write inside what looks like a read.
179
180
  */
180
- const refreshBaseline = async (): Promise<StoredPlan> => {
181
+ const refreshPageText = async (): Promise<StoredPlan> => {
181
182
  const current = (await store.read(cwd, plan.path)) ?? plan;
182
- baseline = stripFeedback(current.content);
183
+ pageText = stripFeedback(current.content);
183
184
  return current;
184
185
  };
185
186
 
186
187
  const page = async (): Promise<ReviewPageData> => {
187
- const stored = await refreshBaseline();
188
- const current = baseline;
188
+ const stored = await refreshPageText();
189
+ const current = pageText;
189
190
  const blocks = planBlocks(current);
190
191
  const sections = extractPlanSections(current);
191
192
  // Per-occurrence change flags, aligned with the sections list: the same shape
192
193
  // the terminal review renders, so duplicate headings cannot both light up.
193
- const changed = options.previous === undefined ? [] : changedSections(stripFeedback(options.previous), current);
194
+ const changed = changedSections(options.baseline, current);
194
195
  const outline = sections.flatMap((section, index) =>
195
196
  section.title === ""
196
197
  ? []
@@ -236,7 +237,7 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
236
237
  const save = async (annotations: PlanAnnotation[]): Promise<SaveResult> => {
237
238
  const current = await store.read(cwd, plan.path);
238
239
  if (!current) return { ok: false, status: 404, message: "The plan file is gone." };
239
- if (stripFeedback(current.content) !== baseline) {
240
+ if (stripFeedback(current.content) !== pageText) {
240
241
  return { ok: false, status: 409, message: "The plan changed on disk; reload the page and review again." };
241
242
  }
242
243
  try {
@@ -28,6 +28,12 @@ pi install ./ # install the package
28
28
  **Conversation only**, or **Code only**.
29
29
  - *Code* rewrites the working tree to the snapshot and is itself undoable
30
30
  (a `pre-restore` safety snapshot is taken first).
31
+ - When code moves, a second step asks **Restore what?** — **Everything (N
32
+ files)** or **Choose paths…**. The checklist lists every changed path
33
+ grouped by directory (`M` modified, `D` deleted since the snapshot, `A`
34
+ created after it), with all of them selected, so `Enter` twice reproduces a
35
+ whole-tree restore. `Space` toggles a file or a directory, `a`/`n` select
36
+ all/none, and the confirm that follows previews only the chosen subset.
31
37
  - *Conversation* moves the session tree back to that prompt and puts the
32
38
  prompt text back in the editor; the abandoned branch stays in the session.
33
39
  - Shows a `↺ N` status chip counting the prompts on the active branch — the
@@ -45,18 +51,23 @@ the main checkout; `list`/matching filter to the current root by default.
45
51
 
46
52
  | Command | What it does |
47
53
  |---|---|
48
- | `/rewind` | Open the timeline; pick a prompt, then a restore scope. |
54
+ | `/rewind` | Open the timeline; pick a prompt, then a restore scope, then (for code) the paths. |
55
+
56
+ The path checklist is its own `ctx.ui.custom` screen: `Space` toggles the focused
57
+ row, `a`/`n` select all or none, `Enter` returns the selection, and `Esc` cancels
58
+ the rewind.
49
59
 
50
60
  The timeline is keyboard-driven: `↑`/`↓` or `j`/`k` move, `PgUp`/`PgDn`/`Home`/`End`
51
61
  jump, `Enter` chooses the focused prompt, the mouse wheel scrolls, `Esc` closes.
52
62
  The scope choice and the confirmation (with a diff preview) run after the screen
53
- closes.
63
+ closes. The path checklist is the same grammar: `Space` toggles, `a`/`n` select
64
+ all or none, `Enter` restores the selection, `Esc` cancels the rewind.
54
65
 
55
66
  ## Behaviour by mode
56
67
 
57
68
  | Mode | Behaviour |
58
69
  |---|---|
59
- | Interactive TUI | Full selectable timeline component; scope choice and confirmation follow after it closes. |
70
+ | Interactive TUI | Full selectable timeline component, then the scope choice, the path checklist, and the confirmation. |
60
71
  | RPC (`hasUI`) | Timeline falls back to `ui.select`; scope choice and confirmation use forwarded dialogs. |
61
72
  | `print` / `json` | No picker: the prompt list is printed. |
62
73
 
@@ -110,9 +121,14 @@ A clean working tree still records a commit (so the metadata always travels with
110
121
  the snapshot); git reuses the identical tree object, so the only cost is a small
111
122
  commit. These refs are not branches and are not touched by `git push --all`.
112
123
 
124
+ ## Limitations
125
+
126
+ - **The path checklist is TUI-only.** RPC, `print`, and `json` modes restore the
127
+ whole tree; there is no typed path syntax, because a list of exact paths is what
128
+ the checklist is for and a glob would need its own validation and preview.
129
+
113
130
  ## Non-goals
114
131
 
115
- - No per-file or partial code rewind; a restore is all-or-nothing.
116
132
  - No capture of `.gitignore`d files.
117
133
  - No moving HEAD, committing, stashing, or pushing.
118
134
  - No non-git projects for code snapshots (conversation-only rewind still works
@@ -126,7 +142,7 @@ commit. These refs are not branches and are not touched by `git push --all`.
126
142
  |---|---|
127
143
  | Model-facing tools | None. `/rewind` is user-driven; automatic snapshots cover the start of every task. |
128
144
  | Commands | `/rewind` (`pi.registerCommand`). |
129
- | Events | `session_start` (reset run state, status, stale-index sweep), `session_tree` (status refresh after tree navigation), `before_agent_start` (capture the prompt and resolve the run's snapshot decision), `tool_call` (take the run's snapshot before the first mutating call), `session_shutdown` (release indexes, clear status). |
145
+ | Events | `session_start` (reset run state, status, stale-index sweep), `session_tree` (status refresh after tree navigation), `before_agent_start` (record the prompt — absent on a run a custom message starts — and resolve the run's snapshot decision), `tool_call` (take the run's snapshot before the first mutating call), `session_shutdown` (release indexes, clear status). |
130
146
  | State storage | Git refs `refs/pi/rewind/<id>` with metadata in the commit body. No tool-result `details` and no `pi.appendEntry`. |
131
147
  | Lifecycle | Index paths are created lazily and released in `session_shutdown`; status chip is set on start/tree/snapshot and cleared on shutdown. |
132
148
 
@@ -136,7 +152,9 @@ commit. These refs are not branches and are not touched by `git push --all`.
136
152
  `before_agent_start` records the prompt summary and resolves the run's root,
137
153
  policy, and config, so the first mutating `tool_call` takes the snapshot with a
138
154
  fully synchronous guard. No `await` sits between that guard's check and its
139
- claim, so parallel sibling calls cannot double-snapshot.
155
+ claim, so parallel sibling calls cannot double-snapshot. A run a custom message
156
+ starts (a wake) has no prompt to record; the snapshot it takes is otherwise the
157
+ same.
140
158
  - **The timeline comes from the session tree, not from git.** `/rewind` lists
141
159
  every user message on the active branch and pairs each with the snapshot whose
142
160
  recorded `entryId` matches, so read-only prompts appear too and the list
@@ -190,7 +208,9 @@ commit. These refs are not branches and are not touched by `git push --all`.
190
208
  | `git.ts` | Single git entry point: rewind plumbing, the shared read helpers rewind consumes, and the production `RunGit`. |
191
209
  | `snapshot.ts` | Create a snapshot (tree from a temporary index → commit → ref). |
192
210
  | `snapshot-run.ts` | Per-run automatic-snapshot state: one snapshot per prompt, with a synchronous guard. |
193
- | `restore.ts` | Plan and apply a working-tree rewind. |
211
+ | `restore.ts` | Plan and apply a working-tree rewind, whole or for a chosen subset of paths. |
212
+ | `paths.ts` | Pure: the changed-path list from `git diff --name-status`, its directory grouping, the change marks, and the restore counts. |
213
+ | `path-picker.ts` | The checklist screen a partial restore opens: grouped paths, toggles, and the selection it returns. |
194
214
  | `store.ts` | Versioned metadata encoding and ref-backed listing/pruning. |
195
215
  | `policy.ts` | Which tool calls are worth a snapshot. |
196
216
  | `temp-index.ts` | Per-process `GIT_INDEX_FILE` paths: create, release, and sweep stale files. |
@@ -198,7 +218,7 @@ commit. These refs are not branches and are not touched by `git push --all`.
198
218
  | `queue.ts` | The serialized git operation queue for staging calls (`GIT_INDEX_FILE`). |
199
219
  | `runtime.ts` | Root/config caches and the snapshot/list/plan/restore operations. |
200
220
  | `timeline.ts` | Pure: prompts on the branch, paired with their snapshots. |
201
- | `rewind.ts` | Scope choice, confirmation, and applying code + conversation rewind. |
221
+ | `rewind.ts` | Scope choice, the path step, confirmation, and applying code + conversation rewind. |
202
222
  | `rewind-tui.ts` | The selectable, width-safe timeline component, drawn from the `lib/tui/` kit (`titleRule`, `dockListFrame`, `listRow`, `emptyState`). |
203
223
  | `commands.ts` | The `/rewind` command. |
204
224
  | `format.ts` | Model-facing and transcript text (pure). |
@@ -61,7 +61,7 @@ async function openRewind(pi: ExtensionAPI, runtime: RewindRuntime, ctx: Extensi
61
61
 
62
62
  const point = await withRailsSuppressed(pi, () => choosePoint(ctx, points));
63
63
  if (!point) return;
64
- await rewindTo(runtime, ctx, root, point);
64
+ await rewindTo(pi, runtime, ctx, root, point);
65
65
  }
66
66
 
67
67
  export function registerCommands(pi: ExtensionAPI, runtime: RewindRuntime): void {
@@ -131,11 +131,11 @@ export function previewDiffText(diff: string, maxLines = DIFF_PREVIEW_LINES): st
131
131
  return [...lines.slice(0, maxLines), `… ${lines.length - maxLines} more lines`].join("\n");
132
132
  }
133
133
 
134
- /** Restore confirmation text. */
134
+ /** Restore confirmation text; a partial restore also names how much it covered. */
135
135
  export function formatRestoreText(summary: RestoreSummary): string {
136
- const parts = [
137
- `Restored snapshot #${summary.id}: ${summary.changed} file${summary.changed === 1 ? "" : "s"} changed`,
138
- ];
136
+ const scope = summary.of === undefined ? "" : ` of ${summary.of}`;
137
+ const files = `${summary.changed}${scope} file${summary.changed === 1 && summary.of === undefined ? "" : "s"}`;
138
+ const parts = [`Restored snapshot #${summary.id}: ${files} changed`];
139
139
  if (summary.removed > 0) parts.push(`${summary.removed} removed`);
140
140
  const line = `${parts.join(", ")}.`;
141
141
  const safety = summary.safety ? ` A safety snapshot #${summary.safety} captures the pre-restore state.` : "";
@@ -22,6 +22,7 @@ import {
22
22
  runGitOrThrow as run,
23
23
  type RunGit,
24
24
  } from "../../lib/git/index.ts";
25
+ import { parseNameStatus, type ChangedPath } from "./paths.ts";
25
26
 
26
27
  // Re-export exactly the shared helpers this extension consumes, so rewind code
27
28
  // has one git import surface instead of reaching into `lib/git` directly.
@@ -128,33 +129,70 @@ export async function readTreeReset(runGit: RunGit, cwd: string, commit: string,
128
129
  await run(runGit, ["read-tree", "--reset", "-u", commit], { cwd, env: { GIT_INDEX_FILE: indexFile } });
129
130
  }
130
131
 
131
- /** Paths added between two trees (present in `to`, absent from `from`). */
132
- export async function addedPaths(runGit: RunGit, cwd: string, from: string, to: string): Promise<string[]> {
133
- const result = await runGit(["diff", "--no-color", "--name-only", "--diff-filter=A", from, to], { cwd });
134
- if (result.code !== 0) return [];
135
- return result.stdout.split("\n").filter((line) => line.trim().length > 0);
132
+ /**
133
+ * A pathspec that matches `path` literally.
134
+ *
135
+ * The paths come from `git diff` output, so a file named `weird[1].txt` is a real
136
+ * possibility; without `:(literal)` git would read it as a glob and restore the
137
+ * wrong thing (or nothing).
138
+ */
139
+ function literalPathspec(path: string): string {
140
+ return `:(literal)${path}`;
136
141
  }
137
142
 
138
- /** `git diff --stat` between two trees. */
139
- export async function diffStat(runGit: RunGit, cwd: string, from: string, to: string): Promise<string> {
140
- const result = await runGit(["diff", "--no-color", "--stat", from, to, "--"], { cwd });
141
- return result.code === 0 ? result.stdout.trim() : "";
143
+ /**
144
+ * The changed paths between two trees, with how each changed.
145
+ *
146
+ * One call replaces the separate added-path and changed-count queries, so the
147
+ * preview, the checklist, and the apply step all read the same list.
148
+ */
149
+ export async function nameStatus(
150
+ runGit: RunGit,
151
+ cwd: string,
152
+ from: string,
153
+ to: string,
154
+ paths?: readonly string[],
155
+ ): Promise<ChangedPath[]> {
156
+ const args = ["diff", "--no-color", "--name-status", from, to, "--"];
157
+ if (paths) args.push(...paths.map(literalPathspec));
158
+ const result = await runGit(args, { cwd });
159
+ return result.code === 0 ? parseNameStatus(result.stdout) : [];
142
160
  }
143
161
 
144
- /** Count of paths changed between two trees, optionally filtered by diff status. */
145
- export async function changedCount(
162
+ /** `git diff --stat` between two trees, optionally limited to `paths`. */
163
+ export async function diffStat(
146
164
  runGit: RunGit,
147
165
  cwd: string,
148
166
  from: string,
149
167
  to: string,
150
- filter?: string,
151
- ): Promise<number> {
152
- const args = ["diff", "--no-color", "--name-only"];
153
- if (filter) args.push(`--diff-filter=${filter}`);
154
- args.push(from, to, "--");
168
+ paths?: readonly string[],
169
+ ): Promise<string> {
170
+ const args = ["diff", "--no-color", "--stat", from, to, "--"];
171
+ if (paths) args.push(...paths.map(literalPathspec));
155
172
  const result = await runGit(args, { cwd });
156
- if (result.code !== 0) return 0;
157
- return result.stdout.split("\n").filter((line) => line.trim().length > 0).length;
173
+ return result.code === 0 ? result.stdout.trim() : "";
174
+ }
175
+
176
+ /**
177
+ * Rewrite the given paths in the working tree from `commit`.
178
+ *
179
+ * `read-tree --reset -u` takes no pathspec, so a partial restore checks the paths
180
+ * out of the snapshot instead. The index it writes is the temporary one, so the
181
+ * real index stays untouched exactly as the whole-tree reset leaves it. Callers
182
+ * pass only paths present in `commit`; an added path is deleted, not checked out.
183
+ */
184
+ export async function restorePaths(
185
+ runGit: RunGit,
186
+ cwd: string,
187
+ commit: string,
188
+ indexFile: string,
189
+ paths: readonly string[],
190
+ ): Promise<void> {
191
+ if (paths.length === 0) return;
192
+ await run(runGit, ["checkout", commit, "--", ...paths.map(literalPathspec)], {
193
+ cwd,
194
+ env: { GIT_INDEX_FILE: indexFile },
195
+ });
158
196
  }
159
197
 
160
198
  /** Label of an in-progress git operation that a rewind must not clobber, if any. */
@@ -36,12 +36,10 @@ export function registerRewind(pi: ExtensionAPI): void {
36
36
  await runtime.setStatus(ctx);
37
37
  });
38
38
 
39
- // A new prompt begins a task: the run resolves its snapshot decision up
40
- // front, so the `tool_call` guard can stay synchronous. A run started by a
41
- // custom message (a wake) has no user prompt — `event.prompt` is undefined
42
- // there despite its type — so `beginRun` accepts a missing prompt.
39
+ // A run started by a custom message (a wake) has no user prompt: only a user
40
+ // run carries one, and a newer host spells that as a union on `messageType`.
43
41
  pi.on("before_agent_start", async (event, ctx) => {
44
- await run.beginRun(event.prompt, ctx);
42
+ await run.beginRun("prompt" in event ? event.prompt : undefined, ctx);
45
43
  });
46
44
 
47
45
  pi.on("session_shutdown", (_event, ctx) => {
@@ -0,0 +1,198 @@
1
+ /**
2
+ * The path checklist a partial rewind opens: every changed path, grouped by
3
+ * directory, each one togglable.
4
+ *
5
+ * The screen is the timeline's sibling — same dock frame, same cursor, same
6
+ * width-safe row grammar — so a partial restore reads as one more step in
7
+ * `/rewind` rather than a second product. Rows are selected by default, which
8
+ * makes `Enter` here reproduce the whole-tree restore exactly.
9
+ *
10
+ * The component owns only the selection: paths in, a chosen subset out through
11
+ * `onClose`, and the restore itself stays behind the confirmation that follows.
12
+ */
13
+
14
+ import type { Theme } from "@earendil-works/pi-coding-agent";
15
+ import { Key, matchesKey, type Component, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
16
+ import { fitRows, formatRange, isCloseKey, ListCursor, navigateList, scrollList } from "../../lib/tui/cursor.ts";
17
+ import { dockListFrame, screenHint, type ViewportRowsSource } from "../../lib/tui/chrome.ts";
18
+ import { clip } from "../../lib/tui/columns.ts";
19
+ import { SEPARATORS } from "../../lib/tui/glyphs.ts";
20
+ import { checkbox, listRow } from "../../lib/tui/rows.ts";
21
+ import { titleRule } from "../../lib/tui/rules.ts";
22
+ import { paint } from "../../lib/tui/tokens.ts";
23
+ import { baseName, expandGroup, groupPaths, SELECTION_MARKS, type ChangedPath, type PathGroup } from "./paths.ts";
24
+
25
+ export interface PathPickerOptions {
26
+ /** The changed paths the snapshot differs on. */
27
+ paths: readonly ChangedPath[];
28
+ theme: Theme;
29
+ /** Called once when the screen closes: the selected paths, or undefined when cancelled. */
30
+ onClose: (paths?: string[]) => void;
31
+ requestRender: () => void;
32
+ viewportRows?: ViewportRowsSource;
33
+ }
34
+
35
+ /** Rows the screen shows when the terminal height is unknown. */
36
+ const SCREEN_DEFAULT_ITEMS = 12;
37
+ /** Header, summary, blanks, and footer rows around the list. */
38
+ const SCREEN_CHROME_ROWS = 6;
39
+
40
+ /** One rendered row: a directory header or a file under one. */
41
+ type Row = { kind: "group"; group: PathGroup } | { kind: "file"; path: ChangedPath; dir: string };
42
+
43
+ /** The checklist of changed paths. */
44
+ export class PathPickerComponent implements Component {
45
+ private readonly cursor: ListCursor;
46
+ private readonly groups: PathGroup[];
47
+ private readonly rows: Row[];
48
+ private readonly chosen = new Set<string>();
49
+
50
+ constructor(private readonly options: PathPickerOptions) {
51
+ this.cursor = new ListCursor(options.requestRender);
52
+ this.groups = groupPaths(options.paths);
53
+ // Every path starts selected, so confirming without touching anything means
54
+ // "restore it all" — the behavior `/rewind` had before the checklist existed.
55
+ for (const group of this.groups) for (const path of expandGroup(group)) this.chosen.add(path);
56
+ this.rows = this.groups.flatMap((group): Row[] => [
57
+ { kind: "group", group },
58
+ ...group.paths.map((path): Row => ({ kind: "file", path, dir: group.dir })),
59
+ ]);
60
+ }
61
+
62
+ /** Whether every file under `group` is selected. */
63
+ private groupOn(group: PathGroup): boolean {
64
+ return group.paths.every((path) => this.chosen.has(path.path));
65
+ }
66
+
67
+ /** Toggle every file under `group` to `on`. */
68
+ private setGroup(group: PathGroup, on: boolean): void {
69
+ for (const path of expandGroup(group)) {
70
+ if (on) this.chosen.add(path);
71
+ else this.chosen.delete(path);
72
+ }
73
+ }
74
+
75
+ /** Apply a key press. Exposed for the tests that drive the screen directly. */
76
+ handleInput(data: string): void {
77
+ if (isCloseKey(data)) {
78
+ this.options.onClose(undefined);
79
+ return;
80
+ }
81
+ if (navigateList(data, this.cursor, this.rows.length)) return;
82
+ if (data === " ") {
83
+ const row = this.rows[this.cursor.selected];
84
+ if (row?.kind === "group") this.setGroup(row.group, !this.groupOn(row.group));
85
+ else if (row) {
86
+ if (this.chosen.has(row.path.path)) this.chosen.delete(row.path.path);
87
+ else this.chosen.add(row.path.path);
88
+ }
89
+ this.options.requestRender();
90
+ return;
91
+ }
92
+ if (data === "a") {
93
+ for (const path of this.options.paths) this.chosen.add(path.path);
94
+ this.options.requestRender();
95
+ return;
96
+ }
97
+ if (data === "n") {
98
+ this.chosen.clear();
99
+ this.options.requestRender();
100
+ return;
101
+ }
102
+ if (matchesKey(data, Key.enter)) this.options.onClose([...this.chosen]);
103
+ }
104
+
105
+ /** Wheel scrolling in fullscreen moves the cursor; regular mode leaves the wheel to the terminal. */
106
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
107
+ return scrollList(event, this.cursor, this.rows.length);
108
+ }
109
+
110
+ dispose(): void {}
111
+
112
+ invalidate(): void {}
113
+
114
+ // The shared dock frame, like every other list screen.
115
+ render(width: number): string[] {
116
+ const w = Math.max(1, width);
117
+ const header = titleRule(this.theme, w, "Restore paths");
118
+ const cursor = this.cursor;
119
+ cursor.visible = fitRows(this.options.viewportRows, this.rows.length, SCREEN_CHROME_ROWS, SCREEN_DEFAULT_ITEMS);
120
+ const { start, end } = cursor.window(this.rows.length);
121
+
122
+ const body: string[] = [];
123
+ for (let i = start; i < end; i++) body.push(this.rowLine(w, this.rows[i], i === cursor.selected));
124
+
125
+ const total = this.options.paths.length;
126
+ const selected = this.chosen.size;
127
+ return dockListFrame({
128
+ header,
129
+ summary: clip(
130
+ ` ${paint(this.theme, "meta", `${selected} of ${total} selected${SEPARATORS.item}Space toggles a file or a directory`)}`,
131
+ w,
132
+ ),
133
+ body,
134
+ range: cursor.clipped(this.rows.length)
135
+ ? formatRange(this.theme, w, { start, end, total: this.rows.length })
136
+ : undefined,
137
+ detail: this.detail(w),
138
+ hint: this.hint(w),
139
+ });
140
+ }
141
+
142
+ private get theme(): Theme {
143
+ return this.options.theme;
144
+ }
145
+
146
+ /** One row: a directory header with its count, or a file with its change mark. */
147
+ private rowLine(width: number, row: Row, selected: boolean): string {
148
+ const tone = selected ? "accent" : row.kind === "group" ? "meta" : "label";
149
+ if (row.kind === "group") {
150
+ const count = row.group.paths.length;
151
+ return listRow(this.theme, width, {
152
+ selected,
153
+ leading: checkbox(this.theme, this.groupOn(row.group)),
154
+ label: `${row.group.dir === "" ? "./" : `${row.group.dir}/`}`,
155
+ labelRole: tone,
156
+ trailing: `${count} file${count === 1 ? "" : "s"}`,
157
+ trailingRole: "meta",
158
+ });
159
+ }
160
+ return listRow(this.theme, width, {
161
+ selected,
162
+ indent: 2,
163
+ // The header already carries the directory's checkbox, so the file rows
164
+ // keep the column with a blank rather than repeating it.
165
+ leading: " ",
166
+ label: baseName(row.path.path),
167
+ labelRole: tone,
168
+ trailing: SELECTION_MARKS[row.path.change],
169
+ trailingRole: row.path.change === "added" ? "warn" : "meta",
170
+ });
171
+ }
172
+
173
+ /** What the focused row will do, in words. */
174
+ private detail(width: number): string[] | undefined {
175
+ const row = this.rows[this.cursor.selected];
176
+ if (!row) return undefined;
177
+ const text =
178
+ row.kind === "group"
179
+ ? `Space selects or clears all ${row.group.paths.length} file${row.group.paths.length === 1 ? "" : "s"} in this directory.`
180
+ : row.path.change === "added"
181
+ ? "A — created after the snapshot; restoring removes it."
182
+ : row.path.change === "deleted"
183
+ ? "D — deleted since the snapshot; restoring brings it back."
184
+ : "M — modified since the snapshot; restoring rewrites it.";
185
+ return [clip(` ${paint(this.theme, "meta", text)}`, width)];
186
+ }
187
+
188
+ /** The key-hint row, with the primary action in the accent role. */
189
+ private hint(width: number): string {
190
+ return screenHint(this.theme, width, [
191
+ "Esc cancel",
192
+ { text: "Enter restore", color: "accent" },
193
+ "Space toggle",
194
+ "a all",
195
+ "n none",
196
+ ]);
197
+ }
198
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * The changed paths a rewind can restore, and how the picker groups them.
3
+ *
4
+ * `git diff --name-status <snapshot> <working tree>` is the one source of truth:
5
+ * the preview, the checklist, and the apply step all read this list, so they
6
+ * cannot disagree about what changed. Pure: parsing, grouping, marks, and the
7
+ * counts `RestoreSummary` reports.
8
+ *
9
+ * A rename arrives as `R100\told\tnew`. It is expanded into two entries — the old
10
+ * path as deleted and the new one as added — because that is exactly what the
11
+ * restore does to each: selecting the old path brings it back, selecting the new
12
+ * one removes it. Both rows appear in the checklist rather than one opaque one.
13
+ */
14
+
15
+ /** What a restore does to one path. */
16
+ export type PathChange = "modified" | "deleted" | "added";
17
+
18
+ /** One path the snapshot differs on, and how. */
19
+ export interface ChangedPath {
20
+ /** Repository-relative path, as git reports it. */
21
+ path: string;
22
+ change: PathChange;
23
+ }
24
+
25
+ /** The trailing mark for each change in the checklist. */
26
+ export const SELECTION_MARKS: Readonly<Record<PathChange, string>> = {
27
+ modified: "M",
28
+ deleted: "D",
29
+ added: "A",
30
+ };
31
+
32
+ /** One directory's files, for the checklist's grouping. */
33
+ export interface PathGroup {
34
+ /** Directory relative to the root; `""` for files at the root. */
35
+ dir: string;
36
+ /** The group's files, sorted by path. */
37
+ paths: ChangedPath[];
38
+ }
39
+
40
+ /** The status letter → change kind, for the single-path statuses. */
41
+ function changeOf(letter: string): PathChange | undefined {
42
+ switch (letter) {
43
+ case "M":
44
+ case "T": // a type change (file ↔ symlink) rewrites the path
45
+ return "modified";
46
+ case "D":
47
+ return "deleted";
48
+ case "A":
49
+ return "added";
50
+ default:
51
+ return undefined;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Parse `git diff --name-status` output. A rename expands to both of its paths
57
+ * (as described in the module comment) and a copy to its destination only — the
58
+ * source is untouched, so it is not a changed path. An unknown status letter is
59
+ * skipped rather than guessed at.
60
+ */
61
+ export function parseNameStatus(stdout: string): ChangedPath[] {
62
+ const changed: ChangedPath[] = [];
63
+ for (const line of stdout.split("\n")) {
64
+ if (line.trim() === "") continue;
65
+ const [status, first, second] = line.split("\t");
66
+ if (!status || !first) continue;
67
+ const letter = status[0] ?? "";
68
+ if (letter === "R" || letter === "C") {
69
+ if (!second) continue;
70
+ if (letter === "R") changed.push({ path: first, change: "deleted" });
71
+ changed.push({ path: second, change: "added" });
72
+ continue;
73
+ }
74
+ const kind = changeOf(letter);
75
+ if (kind) changed.push({ path: first, change: kind });
76
+ }
77
+ return changed;
78
+ }
79
+
80
+ /** The directory part of a path, or `""` for the root. */
81
+ function dirOf(path: string): string {
82
+ const slash = path.lastIndexOf("/");
83
+ return slash === -1 ? "" : path.slice(0, slash);
84
+ }
85
+
86
+ /** The file name within its directory. */
87
+ export function baseName(path: string): string {
88
+ const slash = path.lastIndexOf("/");
89
+ return slash === -1 ? path : path.slice(slash + 1);
90
+ }
91
+
92
+ /**
93
+ * Group changed paths by directory: the root first, then directories in
94
+ * lexicographic order, files sorted inside each group. A stable order is what
95
+ * makes the checklist's cursor predictable and the golden renders testable.
96
+ */
97
+ export function groupPaths(paths: readonly ChangedPath[]): PathGroup[] {
98
+ const groups = new Map<string, ChangedPath[]>();
99
+ for (const entry of paths) {
100
+ const dir = dirOf(entry.path);
101
+ const group = groups.get(dir);
102
+ if (group) group.push(entry);
103
+ else groups.set(dir, [entry]);
104
+ }
105
+ return [...groups.entries()]
106
+ .map(([dir, entries]) => ({ dir, paths: [...entries].sort((a, b) => a.path.localeCompare(b.path)) }))
107
+ .sort((a, b) => (a.dir === "" ? -1 : b.dir === "" ? 1 : a.dir.localeCompare(b.dir)));
108
+ }
109
+
110
+ /** The paths a directory toggle selects. */
111
+ export function expandGroup(group: PathGroup): string[] {
112
+ return group.paths.map((entry) => entry.path);
113
+ }
114
+
115
+ /**
116
+ * The counts the plan and the summary report: `changed` is modified plus deleted
117
+ * (files rewritten from the snapshot), and `removed` is the paths created after
118
+ * it, which the restore deletes.
119
+ */
120
+ export function summarize(paths: readonly ChangedPath[]): { changed: number; removed: number } {
121
+ let changed = 0;
122
+ let removed = 0;
123
+ for (const entry of paths) {
124
+ if (entry.change === "added") removed++;
125
+ else changed++;
126
+ }
127
+ return { changed, removed };
128
+ }