@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.
- package/README.md +66 -41
- package/README.zh-CN.md +53 -36
- package/extensions/bytefly/README.md +321 -186
- package/extensions/bytefly/block/component.ts +7 -15
- package/extensions/bytefly/block/register.ts +6 -1
- package/extensions/bytefly/doctor/config.ts +4 -1
- package/extensions/bytefly/doctor/register.ts +24 -5
- package/extensions/bytefly/doctor/tools.ts +4 -0
- package/extensions/bytefly/footer/collect/snapshot.ts +6 -1
- package/extensions/bytefly/footer/config.ts +10 -6
- package/extensions/bytefly/footer/price/config.ts +3 -2
- package/extensions/bytefly/footer/price/price.ts +5 -3
- package/extensions/bytefly/footer/price/rates.ts +63 -26
- package/extensions/bytefly/footer/render/status.ts +3 -3
- package/extensions/bytefly/header/component.ts +33 -10
- package/extensions/bytefly/header/config.ts +22 -0
- package/extensions/bytefly/header/register.ts +2 -1
- package/extensions/bytefly/index.ts +6 -6
- package/extensions/bytefly/notice/component.ts +8 -3
- package/extensions/bytefly/notify/config.ts +7 -12
- package/extensions/bytefly/notify/dispatch.ts +13 -5
- package/extensions/bytefly/panel/commands.ts +22 -2
- package/extensions/bytefly/panel/format.ts +17 -21
- package/extensions/bytefly/panel/settings-runtime.ts +86 -22
- package/extensions/bytefly/panel/settings.ts +279 -46
- package/extensions/bytefly/panel/themes.ts +32 -0
- package/extensions/bytefly/panel/tui/editing.ts +77 -0
- package/extensions/bytefly/panel/tui/entries.ts +24 -0
- package/extensions/bytefly/panel/tui/extensions-view.ts +13 -8
- package/extensions/bytefly/panel/tui/frame.ts +121 -4
- package/extensions/bytefly/panel/tui/settings-view.ts +98 -13
- package/extensions/bytefly/panel/tui.ts +176 -166
- package/extensions/bytefly/picker/discovery.ts +1 -0
- package/extensions/bytefly/picker/types.ts +2 -0
- package/extensions/bytefly/separator/component.ts +17 -6
- package/extensions/bytefly/separator/config.ts +15 -0
- package/extensions/bytefly/separator/format.ts +60 -6
- package/extensions/bytefly/separator/register.ts +68 -9
- package/extensions/bytefly/separator/types.ts +8 -0
- package/extensions/bytefly/update/cache.ts +58 -19
- package/extensions/bytefly/update/check.ts +35 -0
- package/extensions/bytefly/update/format.ts +76 -35
- package/extensions/bytefly/update/register.ts +144 -55
- package/extensions/bytefly/update/state.ts +4 -2
- package/extensions/bytefly/update/suppress.ts +66 -0
- package/extensions/bytefly/update/types.ts +15 -0
- package/extensions/bytefly/working/config.ts +4 -5
- package/extensions/bytefly/working/format.ts +24 -7
- package/extensions/bytefly/working/register.ts +34 -3
- package/extensions/check/README.md +253 -0
- package/extensions/check/commands.ts +128 -0
- package/extensions/check/config.ts +39 -0
- package/extensions/check/detect.ts +269 -0
- package/extensions/check/failures.ts +230 -0
- package/extensions/check/format.ts +135 -0
- package/extensions/check/index.ts +26 -0
- package/extensions/check/render.ts +185 -0
- package/extensions/check/runner.ts +176 -0
- package/extensions/check/runtime.ts +244 -0
- package/extensions/check/source.ts +80 -0
- package/extensions/check/tools.ts +173 -0
- package/extensions/check/tui.ts +195 -0
- package/extensions/check/types.ts +99 -0
- package/extensions/document/README.md +1 -1
- package/extensions/document/extract/pdf.ts +8 -16
- package/extensions/document/formats.ts +2 -2
- package/extensions/file-browse/README.md +66 -32
- package/extensions/file-browse/client.ts +95 -32
- package/extensions/file-browse/config.ts +9 -9
- package/extensions/file-browse/git.ts +37 -47
- package/extensions/file-browse/html.ts +39 -58
- package/extensions/file-browse/markdown.ts +110 -92
- package/extensions/file-browse/raw-html.ts +478 -0
- package/extensions/file-browse/router.ts +32 -70
- package/extensions/file-browse/server.ts +18 -56
- package/extensions/file-browse/urls.ts +80 -0
- package/extensions/goal/README.md +103 -47
- package/extensions/goal/clarify-parse.ts +4 -9
- package/extensions/goal/clarify.ts +27 -4
- package/extensions/goal/commands.ts +24 -11
- package/extensions/goal/format.ts +41 -10
- package/extensions/goal/index.ts +39 -16
- package/extensions/goal/plan-route.ts +51 -0
- package/extensions/goal/schema.ts +6 -23
- package/extensions/goal/state.ts +39 -57
- package/extensions/goal/tools.ts +26 -20
- package/extensions/goal/types.ts +11 -1
- package/extensions/job/README.md +20 -6
- package/extensions/job/format.ts +18 -68
- package/extensions/job/index.ts +26 -41
- package/extensions/job/log-writer.ts +208 -0
- package/extensions/job/logs.ts +3 -2
- package/extensions/job/process.ts +6 -99
- package/extensions/job/runtime.ts +42 -181
- package/extensions/job/schema.ts +1 -4
- package/extensions/job/tools.ts +1 -15
- package/extensions/job/tui.ts +117 -307
- package/extensions/job/ui.ts +7 -33
- package/extensions/memory/README.md +3 -1
- package/extensions/memory/errors.ts +5 -6
- package/extensions/memory/tui.ts +14 -24
- package/extensions/plan/README.md +106 -18
- package/extensions/plan/commands.ts +9 -5
- package/extensions/plan/format.ts +87 -0
- package/extensions/plan/index.ts +31 -15
- package/extensions/plan/plans.ts +2 -3
- package/extensions/plan/policy.ts +0 -6
- package/extensions/plan/runtime.ts +4 -2
- package/extensions/plan/steps.ts +37 -8
- package/extensions/plan/todos.ts +44 -3
- package/extensions/plan/tools/enter.ts +94 -0
- package/extensions/plan/tools/exit.ts +296 -0
- package/extensions/plan/tools/nested.ts +21 -0
- package/extensions/plan/tools/steps.ts +136 -0
- package/extensions/plan/tools/todo-seed.ts +76 -0
- package/extensions/plan/tools/write.ts +127 -0
- package/extensions/plan/tools.ts +18 -703
- package/extensions/plan/web-client.ts +6 -22
- package/extensions/plan/web-page.ts +19 -30
- package/extensions/plan/web-router.ts +34 -71
- package/extensions/plan/web.ts +13 -40
- package/extensions/question/answers.ts +3 -8
- package/extensions/question/tui.ts +6 -6
- package/extensions/rewind/README.md +95 -22
- package/extensions/rewind/checklist.ts +185 -0
- package/extensions/rewind/commands.ts +8 -0
- package/extensions/rewind/config.ts +3 -11
- package/extensions/rewind/format.ts +220 -2
- package/extensions/rewind/git.ts +74 -7
- package/extensions/rewind/hunk-picker.ts +114 -0
- package/extensions/rewind/patch.ts +88 -0
- package/extensions/rewind/path-picker.ts +67 -100
- package/extensions/rewind/paths.ts +93 -19
- package/extensions/rewind/restore.ts +53 -10
- package/extensions/rewind/review-tui.ts +320 -0
- package/extensions/rewind/review.ts +397 -0
- package/extensions/rewind/rewind-tui.ts +4 -3
- package/extensions/rewind/runtime.ts +94 -17
- package/extensions/subagent/README.md +118 -8
- package/extensions/subagent/background-tui.ts +208 -0
- package/extensions/subagent/background.ts +470 -0
- package/extensions/subagent/commands.ts +80 -0
- package/extensions/subagent/config.ts +62 -0
- package/extensions/subagent/format.ts +24 -1
- package/extensions/subagent/index.ts +116 -1
- package/extensions/subagent/orchestrate.ts +16 -3
- package/extensions/subagent/render.ts +25 -0
- package/extensions/subagent/run.ts +113 -49
- package/extensions/subagent/schema.ts +12 -0
- package/extensions/subagent/transcripts.ts +202 -0
- package/extensions/subagent/types.ts +23 -0
- package/extensions/subagent/ui.ts +50 -0
- package/extensions/todo/README.md +18 -13
- package/extensions/todo/format.ts +3 -2
- package/extensions/todo/runtime.ts +3 -2
- package/extensions/todo/schema.ts +8 -84
- package/extensions/todo/state.ts +5 -46
- package/extensions/todo/tools.ts +2 -1
- package/extensions/todo/types.ts +6 -15
- package/extensions/watch/README.md +16 -6
- package/extensions/watch/child.ts +150 -0
- package/extensions/watch/format.ts +8 -48
- package/extensions/watch/index.ts +42 -58
- package/extensions/watch/runtime.ts +49 -128
- package/extensions/watch/schema.ts +1 -4
- package/extensions/watch/tools.ts +1 -11
- package/extensions/watch/tui.ts +103 -273
- package/extensions/watch/ui.ts +5 -32
- package/extensions/web-access/README.md +1 -1
- package/extensions/web-access/fetch/page.ts +1 -1
- package/extensions/web-access/fetch/tool.ts +6 -2
- package/extensions/web-access/search/search.ts +2 -5
- package/extensions/wechat/README.md +3 -1
- package/extensions/wechat/client.ts +23 -0
- package/extensions/wechat/commands.ts +57 -42
- package/extensions/wechat/deliver.ts +5 -9
- package/extensions/wechat/errors.ts +3 -6
- package/extensions/wechat/inbound.ts +2 -5
- package/extensions/wechat/index.ts +2 -8
- package/extensions/wechat/outbound.ts +2 -4
- package/extensions/wechat/peer-worker.ts +3 -8
- package/extensions/wechat/poll.ts +10 -9
- package/extensions/wechat/presence.ts +35 -62
- package/extensions/wechat/reply-pipeline.ts +3 -1
- package/extensions/wechat/status-screen.ts +1 -1
- package/extensions/wechat/timer.ts +57 -0
- package/extensions/worktree/config.ts +8 -20
- package/extensions/worktree/enter.ts +7 -12
- package/extensions/worktree/git.ts +38 -69
- package/extensions/worktree/index.ts +5 -4
- package/extensions/worktree/registry.ts +7 -7
- package/extensions/worktree/root-tools.ts +2 -2
- package/extensions/worktree/status.ts +3 -2
- package/lib/README.md +22 -8
- package/lib/config.ts +83 -12
- package/lib/errors.ts +12 -0
- package/lib/format.ts +56 -0
- package/lib/git/read.ts +8 -0
- package/lib/goal-state.ts +23 -8
- package/lib/http/headers.ts +54 -0
- package/lib/http/index.ts +13 -0
- package/lib/http/response.ts +46 -0
- package/lib/http/router.ts +52 -0
- package/lib/http/server.ts +91 -0
- package/lib/lines.ts +77 -0
- package/lib/pdf.ts +82 -0
- package/lib/plan-entry.ts +71 -0
- package/lib/plan-state.ts +34 -0
- package/lib/report.ts +99 -0
- package/lib/requirements.ts +25 -19
- package/lib/spawn.ts +170 -0
- package/lib/todo-state.ts +110 -23
- package/lib/tool-names.ts +7 -0
- package/lib/tool-result.ts +33 -0
- package/lib/tui/README.md +68 -3
- package/lib/tui/chrome.ts +28 -4
- package/lib/tui/columns.ts +6 -1
- package/lib/tui/confirm.ts +65 -0
- package/lib/tui/dock-screen.ts +410 -0
- package/lib/tui/glyphs.ts +2 -1
- package/lib/tui/indent.ts +1 -1
- package/lib/tui/index.ts +2 -0
- package/lib/tui/rows.ts +21 -0
- package/lib/tui/status-controller.ts +35 -1
- package/lib/tui/tool.ts +59 -1
- package/lib/web/components.ts +20 -1
- package/lib/web/markup.ts +32 -0
- package/lib/web/runtime.ts +50 -0
- package/package.json +23 -5
- package/skills/init/SKILL.md +165 -0
- package/skills/init/references/agents-md-quality.md +103 -0
- package/skills/init/references/interview-questions.md +81 -0
- package/skills/refactor/SKILL.md +141 -0
- package/themes/bytefly-dark.json +2 -3
- package/themes/bytefly-light.json +2 -3
- package/extensions/watch/process.ts +0 -63
- 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
|
+
}
|
package/extensions/rewind/git.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
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
|
+
}
|