@gavin-hu/my-pi-agent 0.13.1 → 0.15.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.
Files changed (237) hide show
  1. package/README.md +66 -41
  2. package/README.zh-CN.md +53 -36
  3. package/extensions/bytefly/README.md +321 -186
  4. package/extensions/bytefly/block/component.ts +7 -15
  5. package/extensions/bytefly/block/register.ts +6 -1
  6. package/extensions/bytefly/doctor/config.ts +4 -1
  7. package/extensions/bytefly/doctor/register.ts +24 -5
  8. package/extensions/bytefly/doctor/tools.ts +4 -0
  9. package/extensions/bytefly/footer/collect/snapshot.ts +6 -1
  10. package/extensions/bytefly/footer/config.ts +10 -6
  11. package/extensions/bytefly/footer/price/config.ts +3 -2
  12. package/extensions/bytefly/footer/price/price.ts +5 -3
  13. package/extensions/bytefly/footer/price/rates.ts +63 -26
  14. package/extensions/bytefly/footer/render/status.ts +3 -3
  15. package/extensions/bytefly/header/component.ts +33 -10
  16. package/extensions/bytefly/header/config.ts +22 -0
  17. package/extensions/bytefly/header/register.ts +2 -1
  18. package/extensions/bytefly/index.ts +6 -6
  19. package/extensions/bytefly/notice/component.ts +8 -3
  20. package/extensions/bytefly/notify/config.ts +7 -12
  21. package/extensions/bytefly/notify/dispatch.ts +13 -5
  22. package/extensions/bytefly/panel/commands.ts +22 -2
  23. package/extensions/bytefly/panel/format.ts +17 -21
  24. package/extensions/bytefly/panel/settings-runtime.ts +86 -22
  25. package/extensions/bytefly/panel/settings.ts +279 -46
  26. package/extensions/bytefly/panel/themes.ts +32 -0
  27. package/extensions/bytefly/panel/tui/editing.ts +77 -0
  28. package/extensions/bytefly/panel/tui/entries.ts +24 -0
  29. package/extensions/bytefly/panel/tui/extensions-view.ts +13 -8
  30. package/extensions/bytefly/panel/tui/frame.ts +121 -4
  31. package/extensions/bytefly/panel/tui/settings-view.ts +98 -13
  32. package/extensions/bytefly/panel/tui.ts +176 -166
  33. package/extensions/bytefly/picker/discovery.ts +1 -0
  34. package/extensions/bytefly/picker/types.ts +2 -0
  35. package/extensions/bytefly/separator/component.ts +17 -6
  36. package/extensions/bytefly/separator/config.ts +15 -0
  37. package/extensions/bytefly/separator/format.ts +60 -6
  38. package/extensions/bytefly/separator/register.ts +68 -9
  39. package/extensions/bytefly/separator/types.ts +8 -0
  40. package/extensions/bytefly/update/cache.ts +58 -19
  41. package/extensions/bytefly/update/check.ts +35 -0
  42. package/extensions/bytefly/update/format.ts +76 -35
  43. package/extensions/bytefly/update/register.ts +144 -55
  44. package/extensions/bytefly/update/state.ts +4 -2
  45. package/extensions/bytefly/update/suppress.ts +66 -0
  46. package/extensions/bytefly/update/types.ts +15 -0
  47. package/extensions/bytefly/working/config.ts +4 -5
  48. package/extensions/bytefly/working/format.ts +24 -7
  49. package/extensions/bytefly/working/register.ts +34 -3
  50. package/extensions/check/README.md +253 -0
  51. package/extensions/check/commands.ts +128 -0
  52. package/extensions/check/config.ts +39 -0
  53. package/extensions/check/detect.ts +269 -0
  54. package/extensions/check/failures.ts +230 -0
  55. package/extensions/check/format.ts +135 -0
  56. package/extensions/check/index.ts +26 -0
  57. package/extensions/check/render.ts +185 -0
  58. package/extensions/check/runner.ts +176 -0
  59. package/extensions/check/runtime.ts +244 -0
  60. package/extensions/check/source.ts +80 -0
  61. package/extensions/check/tools.ts +173 -0
  62. package/extensions/check/tui.ts +195 -0
  63. package/extensions/check/types.ts +99 -0
  64. package/extensions/document/README.md +1 -1
  65. package/extensions/document/extract/pdf.ts +8 -16
  66. package/extensions/document/formats.ts +2 -2
  67. package/extensions/file-browse/README.md +66 -32
  68. package/extensions/file-browse/client.ts +95 -32
  69. package/extensions/file-browse/config.ts +9 -9
  70. package/extensions/file-browse/git.ts +37 -47
  71. package/extensions/file-browse/html.ts +39 -58
  72. package/extensions/file-browse/markdown.ts +110 -92
  73. package/extensions/file-browse/raw-html.ts +478 -0
  74. package/extensions/file-browse/router.ts +32 -70
  75. package/extensions/file-browse/server.ts +18 -56
  76. package/extensions/file-browse/urls.ts +80 -0
  77. package/extensions/goal/README.md +103 -47
  78. package/extensions/goal/clarify-parse.ts +4 -9
  79. package/extensions/goal/clarify.ts +27 -4
  80. package/extensions/goal/commands.ts +24 -11
  81. package/extensions/goal/format.ts +41 -10
  82. package/extensions/goal/index.ts +39 -16
  83. package/extensions/goal/plan-route.ts +51 -0
  84. package/extensions/goal/schema.ts +6 -23
  85. package/extensions/goal/state.ts +39 -57
  86. package/extensions/goal/tools.ts +26 -20
  87. package/extensions/goal/types.ts +11 -1
  88. package/extensions/job/README.md +20 -6
  89. package/extensions/job/format.ts +18 -68
  90. package/extensions/job/index.ts +26 -41
  91. package/extensions/job/log-writer.ts +208 -0
  92. package/extensions/job/logs.ts +3 -2
  93. package/extensions/job/process.ts +6 -99
  94. package/extensions/job/runtime.ts +42 -181
  95. package/extensions/job/schema.ts +1 -4
  96. package/extensions/job/tools.ts +1 -15
  97. package/extensions/job/tui.ts +117 -307
  98. package/extensions/job/ui.ts +7 -33
  99. package/extensions/memory/README.md +3 -1
  100. package/extensions/memory/errors.ts +5 -6
  101. package/extensions/memory/tui.ts +14 -24
  102. package/extensions/plan/README.md +106 -18
  103. package/extensions/plan/commands.ts +9 -5
  104. package/extensions/plan/format.ts +87 -0
  105. package/extensions/plan/index.ts +31 -15
  106. package/extensions/plan/plans.ts +2 -3
  107. package/extensions/plan/policy.ts +0 -6
  108. package/extensions/plan/runtime.ts +4 -2
  109. package/extensions/plan/steps.ts +37 -8
  110. package/extensions/plan/todos.ts +44 -3
  111. package/extensions/plan/tools/enter.ts +94 -0
  112. package/extensions/plan/tools/exit.ts +296 -0
  113. package/extensions/plan/tools/nested.ts +21 -0
  114. package/extensions/plan/tools/steps.ts +136 -0
  115. package/extensions/plan/tools/todo-seed.ts +76 -0
  116. package/extensions/plan/tools/write.ts +127 -0
  117. package/extensions/plan/tools.ts +18 -703
  118. package/extensions/plan/web-client.ts +6 -22
  119. package/extensions/plan/web-page.ts +19 -30
  120. package/extensions/plan/web-router.ts +34 -71
  121. package/extensions/plan/web.ts +13 -40
  122. package/extensions/question/answers.ts +3 -8
  123. package/extensions/question/tui.ts +6 -6
  124. package/extensions/rewind/README.md +95 -22
  125. package/extensions/rewind/checklist.ts +185 -0
  126. package/extensions/rewind/commands.ts +8 -0
  127. package/extensions/rewind/config.ts +3 -11
  128. package/extensions/rewind/format.ts +220 -2
  129. package/extensions/rewind/git.ts +74 -7
  130. package/extensions/rewind/hunk-picker.ts +114 -0
  131. package/extensions/rewind/patch.ts +88 -0
  132. package/extensions/rewind/path-picker.ts +67 -100
  133. package/extensions/rewind/paths.ts +93 -19
  134. package/extensions/rewind/restore.ts +53 -10
  135. package/extensions/rewind/review-tui.ts +320 -0
  136. package/extensions/rewind/review.ts +397 -0
  137. package/extensions/rewind/rewind-tui.ts +4 -3
  138. package/extensions/rewind/runtime.ts +94 -17
  139. package/extensions/subagent/README.md +118 -8
  140. package/extensions/subagent/background-tui.ts +208 -0
  141. package/extensions/subagent/background.ts +470 -0
  142. package/extensions/subagent/commands.ts +80 -0
  143. package/extensions/subagent/config.ts +62 -0
  144. package/extensions/subagent/format.ts +24 -1
  145. package/extensions/subagent/index.ts +116 -1
  146. package/extensions/subagent/orchestrate.ts +16 -3
  147. package/extensions/subagent/render.ts +25 -0
  148. package/extensions/subagent/run.ts +113 -49
  149. package/extensions/subagent/schema.ts +12 -0
  150. package/extensions/subagent/transcripts.ts +202 -0
  151. package/extensions/subagent/types.ts +23 -0
  152. package/extensions/subagent/ui.ts +50 -0
  153. package/extensions/todo/README.md +18 -13
  154. package/extensions/todo/format.ts +3 -2
  155. package/extensions/todo/runtime.ts +3 -2
  156. package/extensions/todo/schema.ts +8 -84
  157. package/extensions/todo/state.ts +5 -46
  158. package/extensions/todo/tools.ts +2 -1
  159. package/extensions/todo/types.ts +6 -15
  160. package/extensions/watch/README.md +16 -6
  161. package/extensions/watch/child.ts +150 -0
  162. package/extensions/watch/format.ts +8 -48
  163. package/extensions/watch/index.ts +42 -58
  164. package/extensions/watch/runtime.ts +49 -128
  165. package/extensions/watch/schema.ts +1 -4
  166. package/extensions/watch/tools.ts +1 -11
  167. package/extensions/watch/tui.ts +103 -273
  168. package/extensions/watch/ui.ts +5 -32
  169. package/extensions/web-access/README.md +1 -1
  170. package/extensions/web-access/fetch/page.ts +1 -1
  171. package/extensions/web-access/fetch/tool.ts +6 -2
  172. package/extensions/web-access/search/search.ts +2 -5
  173. package/extensions/wechat/README.md +3 -1
  174. package/extensions/wechat/client.ts +23 -0
  175. package/extensions/wechat/commands.ts +57 -42
  176. package/extensions/wechat/deliver.ts +5 -9
  177. package/extensions/wechat/errors.ts +3 -6
  178. package/extensions/wechat/inbound.ts +2 -5
  179. package/extensions/wechat/index.ts +2 -8
  180. package/extensions/wechat/outbound.ts +2 -4
  181. package/extensions/wechat/peer-worker.ts +3 -8
  182. package/extensions/wechat/poll.ts +10 -9
  183. package/extensions/wechat/presence.ts +35 -62
  184. package/extensions/wechat/reply-pipeline.ts +3 -1
  185. package/extensions/wechat/status-screen.ts +1 -1
  186. package/extensions/wechat/timer.ts +57 -0
  187. package/extensions/worktree/config.ts +8 -20
  188. package/extensions/worktree/enter.ts +7 -12
  189. package/extensions/worktree/git.ts +38 -69
  190. package/extensions/worktree/index.ts +5 -4
  191. package/extensions/worktree/registry.ts +7 -7
  192. package/extensions/worktree/root-tools.ts +2 -2
  193. package/extensions/worktree/status.ts +3 -2
  194. package/lib/README.md +22 -8
  195. package/lib/config.ts +83 -12
  196. package/lib/errors.ts +12 -0
  197. package/lib/format.ts +56 -0
  198. package/lib/git/read.ts +8 -0
  199. package/lib/goal-state.ts +23 -8
  200. package/lib/http/headers.ts +54 -0
  201. package/lib/http/index.ts +13 -0
  202. package/lib/http/response.ts +46 -0
  203. package/lib/http/router.ts +52 -0
  204. package/lib/http/server.ts +91 -0
  205. package/lib/lines.ts +77 -0
  206. package/lib/pdf.ts +82 -0
  207. package/lib/plan-entry.ts +71 -0
  208. package/lib/plan-state.ts +34 -0
  209. package/lib/report.ts +99 -0
  210. package/lib/requirements.ts +25 -19
  211. package/lib/spawn.ts +170 -0
  212. package/lib/todo-state.ts +110 -23
  213. package/lib/tool-names.ts +7 -0
  214. package/lib/tool-result.ts +33 -0
  215. package/lib/tui/README.md +68 -3
  216. package/lib/tui/chrome.ts +28 -4
  217. package/lib/tui/columns.ts +6 -1
  218. package/lib/tui/confirm.ts +65 -0
  219. package/lib/tui/dock-screen.ts +410 -0
  220. package/lib/tui/glyphs.ts +2 -1
  221. package/lib/tui/indent.ts +1 -1
  222. package/lib/tui/index.ts +2 -0
  223. package/lib/tui/rows.ts +21 -0
  224. package/lib/tui/status-controller.ts +35 -1
  225. package/lib/tui/tool.ts +59 -1
  226. package/lib/web/components.ts +20 -1
  227. package/lib/web/markup.ts +32 -0
  228. package/lib/web/runtime.ts +50 -0
  229. package/package.json +23 -5
  230. package/skills/init/SKILL.md +165 -0
  231. package/skills/init/references/agents-md-quality.md +103 -0
  232. package/skills/init/references/interview-questions.md +81 -0
  233. package/skills/refactor/SKILL.md +141 -0
  234. package/themes/bytefly-dark.json +2 -3
  235. package/themes/bytefly-light.json +2 -3
  236. package/extensions/watch/process.ts +0 -63
  237. package/extensions/web-access/fetch/pdf.ts +0 -45
@@ -7,8 +7,9 @@
7
7
  */
8
8
 
9
9
  import { sliceByColumn, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
10
- import { sanitizeLine } from "../../lib/format.ts";
11
- import { GLYPHS } from "../../lib/tui/glyphs.ts";
10
+ import { sanitizeLine, stripControlChars } from "../../lib/format.ts";
11
+ import { GLYPHS, SEPARATORS } from "../../lib/tui/glyphs.ts";
12
+ import { SELECTION_MARKS, type ReviewEntry } from "./paths.ts";
12
13
  import type { RestoreSummary } from "./types.ts";
13
14
 
14
15
  /** Longest prompt summary shown before truncation. */
@@ -141,3 +142,220 @@ export function formatRestoreText(summary: RestoreSummary): string {
141
142
  const safety = summary.safety ? ` A safety snapshot #${summary.safety} captures the pre-restore state.` : "";
142
143
  return `${line}${safety}`;
143
144
  }
145
+
146
+ /** The trailing `+n −n`, `bin`, or `0` for one review row. */
147
+ export function formatReviewEntryCounts(entry: Pick<ReviewEntry, "added" | "removed">): string {
148
+ if (entry.added === null && entry.removed === null) return "bin";
149
+ const parts: string[] = [];
150
+ if (entry.added !== null && entry.added > 0) parts.push(`+${entry.added}`);
151
+ if (entry.removed !== null && entry.removed > 0) parts.push(`−${entry.removed}`);
152
+ return parts.length > 0 ? parts.join(" ") : "0";
153
+ }
154
+
155
+ /** The review list's summary: `4 files · +128 −19`, plus `N selected` and `bin` when needed. */
156
+ export function formatReviewSummary(entries: readonly ReviewEntry[], selected = 0): string {
157
+ const files = `${entries.length} file${entries.length === 1 ? "" : "s"}`;
158
+ let added = 0;
159
+ let removed = 0;
160
+ let binary = false;
161
+ for (const entry of entries) {
162
+ if (entry.added === null && entry.removed === null) binary = true;
163
+ else {
164
+ added += entry.added ?? 0;
165
+ removed += entry.removed ?? 0;
166
+ }
167
+ }
168
+ const parts = [files];
169
+ if (added > 0 || removed > 0) parts.push(`+${added} −${removed}`);
170
+ if (binary) parts.push("bin");
171
+ if (selected > 0) parts.push(`${selected} selected`);
172
+ return parts.join(SEPARATORS.item);
173
+ }
174
+
175
+ /** The sanitized snapshot-side name of a rename, or undefined for any other row. */
176
+ function renameFrom(entry: ReviewEntry): string | undefined {
177
+ return entry.change === "renamed" ? sanitizeLine(entry.from) : undefined;
178
+ }
179
+
180
+ /** How a review change kind reads in a detail line or a confirmation. */
181
+ function changeDescription(entry: ReviewEntry): string {
182
+ if (entry.change === "added") return "created after the snapshot; reverting deletes it";
183
+ if (entry.change === "deleted") return "deleted since the snapshot; reverting brings it back";
184
+ const from = renameFrom(entry);
185
+ return from === undefined
186
+ ? "modified since the snapshot; reverting rewrites it"
187
+ : `renamed from ${from}; reverting moves it back`;
188
+ }
189
+
190
+ /**
191
+ * What the focused review row will do, in words.
192
+ *
193
+ * The keys are the hint row's job: a detail line long enough to repeat them is
194
+ * clipped mid-sentence at the widths the screen runs at.
195
+ */
196
+ export function formatReviewDetail(entry: ReviewEntry): string {
197
+ return `${SELECTION_MARKS[entry.change]} — ${changeDescription(entry)}${SEPARATORS.item}${formatReviewEntryCounts(entry)}.`;
198
+ }
199
+
200
+ /** Headless `/review` text for modes without a custom screen. */
201
+ export function formatReviewListText(
202
+ entries: readonly ReviewEntry[],
203
+ prompt: string | undefined,
204
+ committed = false,
205
+ ): string {
206
+ const label = summarizePrompt(prompt) ?? "the last prompt";
207
+ const rows = entries.map((entry) => {
208
+ const counts = formatReviewEntryCounts(entry);
209
+ const from = renameFrom(entry);
210
+ const name = from === undefined ? sanitizeLine(entry.path) : `${sanitizeLine(entry.path)} (from ${from})`;
211
+ return ` ${SELECTION_MARKS[entry.change]} ${name}${counts ? ` ${counts}` : ""}`;
212
+ });
213
+ const files = `${entries.length} file${entries.length === 1 ? "" : "s"}`;
214
+ const note = committed ? ["These changes are already committed; reverting rewrites the working tree only."] : [];
215
+ return [`Reviewing after "${label}" (${files}):`, ...rows, ...note].join("\n");
216
+ }
217
+
218
+ /** What one review revert did, and what is left to review. */
219
+ export function formatReviewRevertText(summary: RestoreSummary, remaining: number): string {
220
+ const count = summary.changed + summary.removed;
221
+ const files = `${count} file${count === 1 ? "" : "s"}`;
222
+ const safety = summary.safety ? ` Safety snapshot #${summary.safety}.` : "";
223
+ const left =
224
+ remaining === 0 ? " No changes remain." : ` ${remaining} file${remaining === 1 ? "" : "s"} still changed.`;
225
+ return `Reverted ${files}.${safety}${left}`;
226
+ }
227
+
228
+ /** What a revert does to HEAD and the index, with the committed case named. */
229
+ function headNote(committed: boolean): string {
230
+ return committed
231
+ ? "These changes are already committed; reverting rewrites the working tree and leaves the commits in place. HEAD and the index are untouched."
232
+ : "HEAD and the index are untouched.";
233
+ }
234
+
235
+ /** The confirmation body for reverting one file. */
236
+ export function formatRevertOneBody(entry: ReviewEntry, prompt: string, safety: boolean, committed = false): string {
237
+ const label = summarizePrompt(prompt) ?? "this prompt";
238
+ const counts =
239
+ entry.added === null && entry.removed === null
240
+ ? "Git reports a binary change; the whole file is restored."
241
+ : `Diff: ${formatReviewEntryCounts(entry)}.`;
242
+ const from = renameFrom(entry);
243
+ const target =
244
+ from === undefined
245
+ ? `Returns ${sanitizeLine(entry.path)} to the snapshot taken before "${label}".`
246
+ : `Moves ${sanitizeLine(entry.path)} back to ${from} as it was before "${label}".`;
247
+ return [
248
+ `${SELECTION_MARKS[entry.change]} — ${changeDescription(entry)}.`,
249
+ counts,
250
+ "",
251
+ target,
252
+ headNote(committed),
253
+ safety
254
+ ? "A safety snapshot first makes the revert undoable with /rewind."
255
+ : "Safety snapshots are off; this cannot be undone.",
256
+ ].join("\n");
257
+ }
258
+
259
+ /** The confirmation body for reverting every changed path in the review. */
260
+ export function formatRevertAllBody(
261
+ entries: readonly ReviewEntry[],
262
+ prompt: string,
263
+ safety: boolean,
264
+ committed = false,
265
+ ): string {
266
+ const label = summarizePrompt(prompt) ?? "this prompt";
267
+ const files = `${entries.length} file${entries.length === 1 ? "" : "s"}`;
268
+ return [
269
+ `Restores ${files} to the snapshot taken before "${label}".`,
270
+ formatReviewSummary(entries),
271
+ headNote(committed),
272
+ safety
273
+ ? "A safety snapshot first makes the revert undoable with /rewind."
274
+ : "Safety snapshots are off; this cannot be undone.",
275
+ ].join("\n");
276
+ }
277
+
278
+ /** The confirmation body for reverting a chosen subset. */
279
+ export function formatRevertSelectedBody(
280
+ entries: readonly ReviewEntry[],
281
+ prompt: string,
282
+ safety: boolean,
283
+ committed = false,
284
+ ): string {
285
+ const label = summarizePrompt(prompt) ?? "this prompt";
286
+ const selected = `${entries.length} selected file${entries.length === 1 ? "" : "s"}`;
287
+ return [
288
+ `Restores ${selected} to the snapshot taken before "${label}".`,
289
+ "Other changed files stay as they are.",
290
+ formatReviewSummary(entries),
291
+ headNote(committed),
292
+ safety
293
+ ? "A safety snapshot first makes the revert undoable with /rewind."
294
+ : "Safety snapshots are off; this cannot be undone.",
295
+ ].join("\n");
296
+ }
297
+
298
+ /** The confirmation body for reverting chosen hunks of one file. */
299
+ export function formatHunkRevertBody(
300
+ entry: ReviewEntry,
301
+ selected: number,
302
+ total: number,
303
+ prompt: string,
304
+ safety: boolean,
305
+ committed = false,
306
+ ): string {
307
+ const label = summarizePrompt(prompt) ?? "this prompt";
308
+ const rest = total - selected;
309
+ return [
310
+ `Reverts ${selected} of ${total} hunk${total === 1 ? "" : "s"} in ${sanitizeLine(entry.path)} to the snapshot taken before "${label}".`,
311
+ rest > 0
312
+ ? `${rest} hunk${rest === 1 ? "" : "s"} in the file stay${rest === 1 ? "s" : ""} as ${rest === 1 ? "it is" : "they are"}.`
313
+ : "Every hunk in the file is reverted.",
314
+ headNote(committed),
315
+ safety
316
+ ? "A safety snapshot first makes the revert undoable with /rewind."
317
+ : "Safety snapshots are off; this cannot be undone.",
318
+ ].join("\n");
319
+ }
320
+
321
+ /** What a hunk revert did, and what is left to review. */
322
+ export function formatHunkRevertText(
323
+ path: string,
324
+ hunks: number,
325
+ safety: string | undefined,
326
+ remaining: number,
327
+ ): string {
328
+ const files = `${hunks} hunk${hunks === 1 ? "" : "s"} in ${sanitizeLine(path)}`;
329
+ const safetyText = safety ? ` Safety snapshot #${safety}.` : "";
330
+ const left =
331
+ remaining === 0 ? " No changes remain." : ` ${remaining} file${remaining === 1 ? "" : "s"} still changed.`;
332
+ return `Reverted ${files}.${safetyText}${left}`;
333
+ }
334
+
335
+ /** Lines of one review diff shown at most. */
336
+ export const REVIEW_DIFF_MAX_LINES = 2_000;
337
+
338
+ /** Characters of one review diff kept before the line cap applies. */
339
+ export const REVIEW_DIFF_MAX_CHARS = 400_000;
340
+
341
+ /**
342
+ * A review patch as display lines: sanitized, tab-expanded, and bounded.
343
+ *
344
+ * The cap is applied before the pane ever holds the buffer, so a generated or
345
+ * minified file cannot make the screen allocate its whole diff. A truncated
346
+ * buffer ends with a note, and an empty patch (a binary change git would not
347
+ * render) gets a one-line placeholder instead of an empty screen.
348
+ */
349
+ export function boundDiff(text: string, maxLines = REVIEW_DIFF_MAX_LINES, maxChars = REVIEW_DIFF_MAX_CHARS): string[] {
350
+ if (text.trim() === "") return ["No textual diff."];
351
+ const truncatedByChars = text.length > maxChars;
352
+ const raw = (truncatedByChars ? text.slice(0, maxChars) : text).split(/\r?\n/);
353
+ if (raw.at(-1) === "") raw.pop();
354
+ // A diff keeps its indentation, so this uses the multi-line boundary and
355
+ // expands tabs itself; `sanitizeLine` would collapse both.
356
+ const lines = raw.map((line) => stripControlChars(line).replaceAll("\t", " "));
357
+ if (!truncatedByChars && lines.length <= maxLines) return lines;
358
+ const kept = lines.slice(0, maxLines);
359
+ kept.push(truncatedByChars ? "… diff truncated" : `… diff truncated at ${maxLines} lines`);
360
+ return kept;
361
+ }
@@ -9,8 +9,9 @@
9
9
  * (`GIT_INDEX_FILE`) so the user's real index and HEAD are never modified.
10
10
  */
11
11
 
12
- import { existsSync } from "node:fs";
13
- import { resolve } from "node:path";
12
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join, resolve } from "node:path";
14
15
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
16
  import {
16
17
  createExecRunner,
@@ -22,7 +23,7 @@ import {
22
23
  runGitOrThrow as run,
23
24
  type RunGit,
24
25
  } from "../../lib/git/index.ts";
25
- import { parseNameStatus, type ChangedPath } from "./paths.ts";
26
+ import { parseNameStatus, parseNumStat, type ChangedPath, type PathStat } from "./paths.ts";
26
27
 
27
28
  // Re-export exactly the shared helpers this extension consumes, so rewind code
28
29
  // has one git import surface instead of reaching into `lib/git` directly.
@@ -144,7 +145,9 @@ function literalPathspec(path: string): string {
144
145
  * The changed paths between two trees, with how each changed.
145
146
  *
146
147
  * 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
+ * preview, the checklist, and the apply step all read the same list. Rename
149
+ * detection is explicit (`-M`) rather than inherited from `diff.renames`, so a
150
+ * user's git config cannot change what a selection covers.
148
151
  */
149
152
  export async function nameStatus(
150
153
  runGit: RunGit,
@@ -153,13 +156,18 @@ export async function nameStatus(
153
156
  to: string,
154
157
  paths?: readonly string[],
155
158
  ): Promise<ChangedPath[]> {
156
- const args = ["diff", "--no-color", "--name-status", from, to, "--"];
159
+ const args = ["diff", "--no-color", "-M", "--name-status", from, to, "--"];
157
160
  if (paths) args.push(...paths.map(literalPathspec));
158
161
  const result = await runGit(args, { cwd });
159
162
  return result.code === 0 ? parseNameStatus(result.stdout) : [];
160
163
  }
161
164
 
162
- /** `git diff --stat` between two trees, optionally limited to `paths`. */
165
+ /**
166
+ * `git diff --stat` between two trees, optionally limited to `paths`.
167
+ *
168
+ * Rename detection matches `nameStatus`, so the confirm preview names the move
169
+ * even though the per-file patch below stays a delete plus a create.
170
+ */
163
171
  export async function diffStat(
164
172
  runGit: RunGit,
165
173
  cwd: string,
@@ -167,12 +175,51 @@ export async function diffStat(
167
175
  to: string,
168
176
  paths?: readonly string[],
169
177
  ): Promise<string> {
170
- const args = ["diff", "--no-color", "--stat", from, to, "--"];
178
+ const args = ["diff", "--no-color", "-M", "--stat", from, to, "--"];
171
179
  if (paths) args.push(...paths.map(literalPathspec));
172
180
  const result = await runGit(args, { cwd });
173
181
  return result.code === 0 ? result.stdout.trim() : "";
174
182
  }
175
183
 
184
+ /**
185
+ * A unified patch between two trees, optionally limited to `paths`.
186
+ *
187
+ * `--no-renames` is deliberate: `nameStatus` pairs a rename into one row, while
188
+ * its patch stays a delete plus a create, which describes exactly the two working
189
+ * tree operations a revert of that row performs.
190
+ */
191
+ export async function diffPatch(
192
+ runGit: RunGit,
193
+ cwd: string,
194
+ from: string,
195
+ to: string,
196
+ paths?: readonly string[],
197
+ ): Promise<string> {
198
+ const args = ["diff", "--no-color", "--no-ext-diff", "--no-textconv", "--no-renames", "--unified=3", from, to, "--"];
199
+ if (paths) args.push(...paths.map(literalPathspec));
200
+ const result = await runGit(args, { cwd });
201
+ return result.code === 0 ? result.stdout : "";
202
+ }
203
+
204
+ /**
205
+ * Per-path added/removed line counts between two trees, with binary as nulls.
206
+ *
207
+ * `--no-renames` keeps a rename's removed lines on its source and its added lines
208
+ * on its destination; `toReviewEntries` joins the two onto the paired entry.
209
+ */
210
+ export async function diffNumStat(
211
+ runGit: RunGit,
212
+ cwd: string,
213
+ from: string,
214
+ to: string,
215
+ paths?: readonly string[],
216
+ ): Promise<Map<string, PathStat>> {
217
+ const args = ["diff", "--no-color", "--numstat", "--no-renames", from, to, "--"];
218
+ if (paths) args.push(...paths.map(literalPathspec));
219
+ const result = await runGit(args, { cwd });
220
+ return result.code === 0 ? parseNumStat(result.stdout) : new Map();
221
+ }
222
+
176
223
  /**
177
224
  * Rewrite the given paths in the working tree from `commit`.
178
225
  *
@@ -195,6 +242,26 @@ export async function restorePaths(
195
242
  });
196
243
  }
197
244
 
245
+ /**
246
+ * Reverse-apply a prepared patch to the working tree.
247
+ *
248
+ * `pi.exec` has no stdin, so the patch goes through a private temporary
249
+ * directory and a path argument. Without `--index` or `--cached`, `git apply`
250
+ * writes only the working tree, and it verifies every hunk before writing, so a
251
+ * file edited after its diff was read fails atomically instead of applying stale
252
+ * context. The caller builds the patch from already-fetched diff output.
253
+ */
254
+ export async function applyPatchReverse(runGit: RunGit, cwd: string, patch: string): Promise<void> {
255
+ const dir = mkdtempSync(join(tmpdir(), "pi-rewind-patch-"));
256
+ try {
257
+ const file = join(dir, "patch.diff");
258
+ writeFileSync(file, patch);
259
+ await run(runGit, ["apply", "--reverse", "--whitespace=nowarn", file], { cwd });
260
+ } finally {
261
+ rmSync(dir, { recursive: true, force: true });
262
+ }
263
+ }
264
+
198
265
  /** Label of an in-progress git operation that a rewind must not clobber, if any. */
199
266
  export async function busyGitState(runGit: RunGit, cwd: string): Promise<string | undefined> {
200
267
  const markers: Array<[string, string]> = [
@@ -0,0 +1,114 @@
1
+ /**
2
+ * The hunk checklist `/review` opens from a modified file's diff.
3
+ *
4
+ * One row per textual hunk, all selected by default, so `Enter` twice reproduces
5
+ * the whole-file revert. The screen owns only the selection: hunks in, chosen
6
+ * indices out through `onClose`, and the reverse apply stays behind the
7
+ * confirmation that follows. The frame, the cursor, and the toggle keys come
8
+ * from {@link ChecklistScreen}.
9
+ *
10
+ * Pure UI: no git, no filesystem.
11
+ */
12
+
13
+ import type { Theme } from "@earendil-works/pi-coding-agent";
14
+ import { sanitizeLine } from "../../lib/format.ts";
15
+ import { screenHint, type ViewportRowsSource } from "../../lib/tui/chrome.ts";
16
+ import { clip } from "../../lib/tui/columns.ts";
17
+ import { SEPARATORS } from "../../lib/tui/glyphs.ts";
18
+ import { checkbox, listRow } from "../../lib/tui/rows.ts";
19
+ import { paint } from "../../lib/tui/tokens.ts";
20
+ import { ChecklistScreen } from "./checklist.ts";
21
+ import { formatReviewEntryCounts } from "./format.ts";
22
+ import type { Hunk } from "./patch.ts";
23
+
24
+ export interface HunkPickerOptions {
25
+ /** The file the hunks belong to. */
26
+ path: string;
27
+ hunks: readonly Hunk[];
28
+ theme: Theme;
29
+ /** Called once when the screen closes: chosen hunk indices, or undefined when cancelled. */
30
+ onClose: (selection?: number[]) => void;
31
+ requestRender: () => void;
32
+ viewportRows?: ViewportRowsSource;
33
+ }
34
+
35
+ /** One rendered row: a hunk and the index the screen returns. */
36
+ interface HunkRow {
37
+ index: number;
38
+ hunk: Hunk;
39
+ }
40
+
41
+ /** The first removed and first added line, for the focused hunk's detail. */
42
+ function previewOf(hunk: Hunk): string[] {
43
+ const removed = hunk.lines.find((line) => line.startsWith("-"));
44
+ const added = hunk.lines.find((line) => line.startsWith("+"));
45
+ return [removed, added].filter((line): line is string => line !== undefined).slice(0, 2);
46
+ }
47
+
48
+ /** The checklist of a file's hunks. */
49
+ export class HunkPickerComponent extends ChecklistScreen<HunkRow> {
50
+ private readonly hunkRows: readonly HunkRow[];
51
+
52
+ constructor(private readonly view: HunkPickerOptions) {
53
+ super({
54
+ title: "Revert hunks",
55
+ theme: view.theme,
56
+ requestRender: view.requestRender,
57
+ viewportRows: view.viewportRows,
58
+ emptyLabel: "No hunks to select.",
59
+ });
60
+ this.hunkRows = view.hunks.map((hunk, index) => ({ index, hunk }));
61
+ }
62
+
63
+ protected rows(): readonly HunkRow[] {
64
+ return this.hunkRows;
65
+ }
66
+
67
+ protected keyOf(row: HunkRow): string {
68
+ return String(row.index);
69
+ }
70
+
71
+ protected onEnter(selection: string[]): void {
72
+ this.view.onClose(selection.map(Number).sort((a, b) => a - b));
73
+ }
74
+
75
+ protected onCancel(): void {
76
+ this.view.onClose(undefined);
77
+ }
78
+
79
+ protected rowLine(row: HunkRow, width: number, selected: boolean): string {
80
+ return listRow(this.theme, width, {
81
+ selected,
82
+ leading: checkbox(this.theme, this.chosen.has(String(row.index))),
83
+ label: sanitizeLine(row.hunk.header),
84
+ labelRole: selected ? "accent" : "label",
85
+ trailing: formatReviewEntryCounts(row.hunk),
86
+ trailingRole: "meta",
87
+ });
88
+ }
89
+
90
+ protected summaryText(): string {
91
+ const selected = `${this.chosen.size} of ${this.view.hunks.length} hunks selected`;
92
+ return `${selected}${SEPARATORS.item}${sanitizeLine(this.view.path)}`;
93
+ }
94
+
95
+ /** The focused hunk's first removed and added line. */
96
+ protected detailLines(width: number): string[] {
97
+ const focused = this.focused;
98
+ if (!focused) return [];
99
+ return previewOf(focused.hunk).map((line) => {
100
+ const role = line.startsWith("+") ? "ok" : line.startsWith("-") ? "bad" : "meta";
101
+ return clip(` ${paint(this.theme, role, sanitizeLine(line))}`, width);
102
+ });
103
+ }
104
+
105
+ protected hint(width: number): string {
106
+ return screenHint(this.theme, width, [
107
+ "Esc cancel",
108
+ { text: "Enter revert", color: "accent" },
109
+ "Space toggle",
110
+ "a all",
111
+ "n none",
112
+ ]);
113
+ }
114
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * A single-file unified diff split into its header and hunks.
3
+ *
4
+ * `/review` shows one file's patch and can revert a chosen subset of its hunks.
5
+ * The raw patch is what `git apply` consumes, so the parser separates the header
6
+ * and each hunk without rewriting them: line bodies keep their exact bytes,
7
+ * including a trailing `\r` or a `` marker.
8
+ *
9
+ * Only a textual, single-file patch is accepted. A binary patch, a mode-only
10
+ * change, and a multi-file patch all return `undefined`, so a caller cannot
11
+ * reverse-apply something the builder cannot reproduce faithfully.
12
+ *
13
+ * Pure: no git, no terminal, no filesystem.
14
+ */
15
+
16
+ /** One unified-diff hunk, header included. */
17
+ export interface Hunk {
18
+ /** The `@@ -a,b +c,d @@` line. */
19
+ header: string;
20
+ /** Body lines, each starting with ` `, `+`, `-`, or `\`. */
21
+ lines: string[];
22
+ /** Lines the hunk adds. */
23
+ added: number;
24
+ /** Lines the hunk removes. */
25
+ removed: number;
26
+ }
27
+
28
+ /** A single-file patch: the file header and its hunks. */
29
+ export interface FilePatch {
30
+ /** Lines before the first hunk: `diff --git`, `index`, `---`, `+++`. */
31
+ header: string[];
32
+ hunks: Hunk[];
33
+ }
34
+
35
+ /** Prefix that starts a hunk. */
36
+ const HUNK_PREFIX = "@@ ";
37
+
38
+ /**
39
+ * Parse a one-file unified diff, or `undefined` when it has no textual hunks or
40
+ * spans more than one file.
41
+ */
42
+ export function parseFilePatch(text: string): FilePatch | undefined {
43
+ const lines = text.split("\n");
44
+ if (lines.at(-1) === "") lines.pop();
45
+
46
+ const header: string[] = [];
47
+ const hunks: Hunk[] = [];
48
+ let current: Hunk | undefined;
49
+
50
+ for (const line of lines) {
51
+ if (line.startsWith(HUNK_PREFIX)) {
52
+ if (current) hunks.push(current);
53
+ current = { header: line, lines: [], added: 0, removed: 0 };
54
+ continue;
55
+ }
56
+ if (!current) {
57
+ // A second file starts with `diff --git` after the first header.
58
+ if (line.startsWith("diff --git ") && header.length > 0) return undefined;
59
+ header.push(line);
60
+ continue;
61
+ }
62
+ if (line.startsWith("diff --git ")) return undefined;
63
+ current.lines.push(line);
64
+ if (line.startsWith("+")) current.added++;
65
+ else if (line.startsWith("-")) current.removed++;
66
+ }
67
+
68
+ if (current) hunks.push(current);
69
+ if (hunks.length === 0) return undefined;
70
+ if (!header.some((line) => line.startsWith("diff --git "))) return undefined;
71
+ if (!header.some((line) => line.startsWith("+++ "))) return undefined;
72
+ return { header, hunks };
73
+ }
74
+
75
+ /**
76
+ * Rebuild the patch from the chosen hunks, in source order. Out-of-range and
77
+ * duplicate indices are dropped, so a stale selection cannot duplicate context.
78
+ */
79
+ export function buildHunkPatch(patch: FilePatch, indices: readonly number[]): string {
80
+ const chosen = [...new Set(indices)]
81
+ .filter((index) => index >= 0 && index < patch.hunks.length)
82
+ .sort((a, b) => a - b);
83
+ const selected = chosen.flatMap((index) => {
84
+ const hunk = patch.hunks[index];
85
+ return hunk === undefined ? [] : [hunk.header, ...hunk.lines];
86
+ });
87
+ return [...patch.header, ...selected, ""].join("\n");
88
+ }