@gavin-hu/my-pi-agent 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -21
- package/README.zh-CN.md +30 -22
- package/extensions/bytefly/README.md +139 -67
- package/extensions/bytefly/chrome.ts +7 -2
- package/extensions/bytefly/doctor/register.ts +6 -0
- package/extensions/bytefly/doctor/runtime.ts +36 -6
- package/extensions/bytefly/footer/collect/snapshot.ts +12 -2
- package/extensions/bytefly/footer/config.ts +7 -1
- package/extensions/bytefly/footer/delegation.ts +100 -0
- package/extensions/bytefly/footer/register.ts +13 -2
- package/extensions/bytefly/footer/render/lines.ts +2 -0
- package/extensions/bytefly/footer/render/segments.ts +27 -0
- package/extensions/bytefly/footer/types.ts +11 -0
- package/extensions/bytefly/index.ts +4 -3
- package/extensions/bytefly/notify/config.ts +2 -0
- package/extensions/bytefly/notify/format.ts +19 -1
- package/extensions/bytefly/notify/register.ts +37 -13
- package/extensions/bytefly/notify/types.ts +2 -0
- package/extensions/bytefly/panel/commands.ts +34 -13
- package/extensions/bytefly/panel/settings.ts +98 -16
- package/extensions/bytefly/update/cache.ts +73 -0
- package/extensions/bytefly/update/format.ts +65 -10
- package/extensions/bytefly/update/register.ts +99 -13
- package/extensions/bytefly/update/state.ts +13 -1
- package/extensions/document/README.md +22 -12
- package/extensions/document/config.ts +4 -0
- package/extensions/document/deadline.ts +61 -0
- package/extensions/document/tool.ts +13 -2
- package/extensions/file-browse/README.md +12 -8
- package/extensions/file-browse/client.ts +6 -7
- package/extensions/file-browse/files.ts +0 -4
- package/extensions/file-browse/git.ts +9 -10
- package/extensions/file-browse/html.ts +17 -7
- package/extensions/file-browse/router.ts +24 -15
- package/extensions/goal/README.md +7 -1
- package/extensions/goal/schema.ts +6 -2
- package/extensions/goal/types.ts +6 -2
- package/extensions/job/README.md +16 -12
- package/extensions/job/runtime.ts +23 -6
- package/extensions/job/status.ts +4 -2
- package/extensions/memory/README.md +55 -13
- package/extensions/memory/commands.ts +20 -2
- package/extensions/memory/doctor.ts +137 -0
- package/extensions/memory/format.ts +24 -7
- package/extensions/memory/runtime.ts +49 -0
- package/extensions/memory/store.ts +31 -0
- package/extensions/memory/tui.ts +136 -12
- package/extensions/plan/README.md +60 -29
- package/extensions/plan/commands.ts +67 -8
- package/extensions/plan/index.ts +35 -11
- package/extensions/plan/plan-index.ts +234 -0
- package/extensions/plan/plan-text.ts +95 -0
- package/extensions/plan/plans.ts +90 -1
- package/extensions/plan/review.ts +12 -9
- package/extensions/plan/runtime.ts +72 -27
- package/extensions/plan/steps.ts +35 -25
- package/extensions/plan/todos.ts +24 -2
- package/extensions/plan/tools.ts +124 -24
- package/extensions/plan/tui.ts +7 -28
- package/extensions/plan/types.ts +9 -0
- package/extensions/plan/web-page.ts +2 -2
- package/extensions/plan/web.ts +12 -11
- package/extensions/rewind/README.md +28 -8
- package/extensions/rewind/commands.ts +1 -1
- package/extensions/rewind/format.ts +4 -4
- package/extensions/rewind/git.ts +56 -18
- package/extensions/rewind/index.ts +3 -5
- package/extensions/rewind/path-picker.ts +198 -0
- package/extensions/rewind/paths.ts +128 -0
- package/extensions/rewind/restore.ts +69 -28
- package/extensions/rewind/rewind.ts +80 -3
- package/extensions/rewind/runtime.ts +13 -5
- package/extensions/rewind/snapshot-run.ts +5 -2
- package/extensions/rewind/types.ts +2 -0
- package/extensions/subagent/README.md +7 -1
- package/extensions/subagent/accounting.ts +34 -0
- package/extensions/subagent/index.ts +23 -1
- package/extensions/subagent/stream.ts +2 -0
- package/extensions/subagent/types.ts +2 -0
- package/extensions/watch/README.md +8 -3
- package/extensions/watch/index.ts +19 -0
- package/extensions/web-access/README.md +43 -13
- package/extensions/web-access/charset.ts +58 -0
- package/extensions/web-access/fetch/find.ts +11 -7
- package/extensions/web-access/fetch/format.ts +64 -18
- package/extensions/web-access/fetch/page.ts +5 -1
- package/extensions/web-access/fetch/tool.ts +19 -18
- package/extensions/web-access/http.ts +49 -8
- package/extensions/web-access/search/facts.ts +38 -0
- package/extensions/web-access/search/tool.ts +13 -0
- package/extensions/web-access/text.ts +50 -11
- package/extensions/worktree/README.md +74 -15
- package/extensions/worktree/config.ts +11 -0
- package/extensions/worktree/exit.ts +35 -3
- package/extensions/worktree/gaps.ts +74 -0
- package/extensions/worktree/guard.ts +50 -4
- package/extensions/worktree/handoff.ts +55 -0
- package/extensions/worktree/index.ts +12 -2
- package/extensions/worktree/runtime.ts +20 -0
- package/extensions/worktree/status.ts +15 -2
- package/extensions/worktree/tools.ts +3 -2
- package/lib/README.md +10 -8
- package/lib/events.ts +86 -1
- package/lib/goal-state.ts +95 -0
- package/lib/requirements.ts +282 -62
- package/lib/tui/README.md +91 -0
- package/lib/tui/glyphs.ts +1 -0
- package/lib/web/layout.ts +12 -2
- package/package.json +5 -3
- package/themes/bytefly-dark.json +1 -1
- package/assets/chrome.png +0 -0
- package/assets/file-browse.png +0 -0
- package/assets/job.png +0 -0
- package/assets/plan-review.png +0 -0
- package/assets/question.png +0 -0
- package/assets/rewind.png +0 -0
- package/assets/todo.png +0 -0
- package/assets/tool-renderings.png +0 -0
- package/assets/tools/ask_user_question-cancelled.png +0 -0
- package/assets/tools/ask_user_question.png +0 -0
- package/assets/tools/bash.png +0 -0
- package/assets/tools/edit.png +0 -0
- package/assets/tools/enter_plan_mode.png +0 -0
- package/assets/tools/enter_worktree.png +0 -0
- package/assets/tools/exit_plan_mode.png +0 -0
- package/assets/tools/exit_worktree.png +0 -0
- package/assets/tools/find.png +0 -0
- package/assets/tools/goal.png +0 -0
- package/assets/tools/grep.png +0 -0
- package/assets/tools/job.png +0 -0
- package/assets/tools/list_worktrees.png +0 -0
- package/assets/tools/ls.png +0 -0
- package/assets/tools/memory-long-subject.png +0 -0
- package/assets/tools/memory.png +0 -0
- package/assets/tools/notice.png +0 -0
- package/assets/tools/open_path.png +0 -0
- package/assets/tools/prune_worktrees.png +0 -0
- package/assets/tools/read.png +0 -0
- package/assets/tools/read_doc-running.png +0 -0
- package/assets/tools/read_doc.png +0 -0
- package/assets/tools/run_doctor.png +0 -0
- package/assets/tools/send_wechat.png +0 -0
- package/assets/tools/set_plan_steps.png +0 -0
- package/assets/tools/subagent.png +0 -0
- package/assets/tools/todo.png +0 -0
- package/assets/tools/turn-rule.png +0 -0
- package/assets/tools/user-message.png +0 -0
- package/assets/tools/watch.png +0 -0
- package/assets/tools/web_fetch-rejected.png +0 -0
- package/assets/tools/web_fetch.png +0 -0
- package/assets/tools/web_search.png +0 -0
- package/assets/tools/write.png +0 -0
- package/assets/tools/write_plan.png +0 -0
package/extensions/plan/tools.ts
CHANGED
|
@@ -19,7 +19,8 @@ import { StringEnum } from "@earendil-works/pi-ai";
|
|
|
19
19
|
import type { ExtensionAPI, ExtensionToolContext } from "@earendil-works/pi-coding-agent";
|
|
20
20
|
import { Type, type Static } from "typebox";
|
|
21
21
|
import { sanitizeLine } from "../../lib/format.ts";
|
|
22
|
-
import {
|
|
22
|
+
import { lastGoal } from "../../lib/goal-state.ts";
|
|
23
|
+
import { GOAL_TOOL, TODO_TOOL } from "../../lib/tool-names.ts";
|
|
23
24
|
import { lastTodos } from "../../lib/todo-state.ts";
|
|
24
25
|
import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
|
|
25
26
|
import { rejectionLine, reuseCallText, toolCallTitle } from "../../lib/tui/tool.ts";
|
|
@@ -34,13 +35,15 @@ import {
|
|
|
34
35
|
PLAN_OUTLINE,
|
|
35
36
|
extractPlanSteps,
|
|
36
37
|
planStepSummary,
|
|
38
|
+
verificationItem,
|
|
37
39
|
type PlanStep,
|
|
38
|
-
type PlanStepSummary,
|
|
39
40
|
type StepUpdate,
|
|
40
41
|
} from "./steps.ts";
|
|
42
|
+
import { basename } from "node:path";
|
|
43
|
+
import { planTitle, type StoredPlan } from "./plans.ts";
|
|
41
44
|
import { DEFAULT_TODO_MAX, mergeTodos, type TodoMerge } from "./todos.ts";
|
|
42
45
|
import { reviewPlan } from "./review.ts";
|
|
43
|
-
import { sanitizePlanText } from "./
|
|
46
|
+
import { sanitizePlanText } from "./plan-text.ts";
|
|
44
47
|
import type { EnterPlanModeDetails, ExitPlanModeDetails, SetPlanStepsDetails, WritePlanDetails } from "./types.ts";
|
|
45
48
|
import { createPlanWebReview, type PlanWebReview } from "./web.ts";
|
|
46
49
|
|
|
@@ -116,20 +119,21 @@ function preview(plan: string): { body: string; more: number } {
|
|
|
116
119
|
}
|
|
117
120
|
|
|
118
121
|
/**
|
|
119
|
-
* One sentence
|
|
120
|
-
*
|
|
122
|
+
* One sentence when a step is longer than the display cap. The text stays in the
|
|
123
|
+
* plan file either way; only the todo list and the approval listing shorten it.
|
|
121
124
|
*/
|
|
122
|
-
function
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
125
|
+
function stepElisionNote(elided: number): string {
|
|
126
|
+
if (elided === 0) return "";
|
|
127
|
+
const plural = elided === 1 ? " is" : "s are";
|
|
128
|
+
return ` Note: ${elided} step${plural} longer than ${MAX_STEP_LENGTH} characters and will be shortened in the todo list.`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The diagnosis both cap refusals share: a step list longer than approval can
|
|
133
|
+
* track. Each caller appends the fix that is available from where it stands.
|
|
134
|
+
*/
|
|
135
|
+
function overCap(total: number): string {
|
|
136
|
+
return `The plan has ${total} top-level steps but approval tracks at most ${MAX_STEPS}.`;
|
|
133
137
|
}
|
|
134
138
|
|
|
135
139
|
/** "Step 2" or "Steps 1, 2" for a set of step numbers. */
|
|
@@ -171,11 +175,12 @@ async function seedTodos(
|
|
|
171
175
|
plan: string,
|
|
172
176
|
): Promise<{ steps: PlanStep[]; merged?: TodoMerge; error?: string }> {
|
|
173
177
|
const steps = extractPlanSteps(plan);
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
//
|
|
178
|
-
|
|
178
|
+
const verification = verificationItem(plan);
|
|
179
|
+
if (steps.length === 0 && !verification) return { steps };
|
|
180
|
+
const merged = mergeTodos(lastTodos(ctx.sessionManager.getBranch()), steps, todoMaxItems(pi), verification);
|
|
181
|
+
// Nothing new to write: every step (and the verification item) is already in
|
|
182
|
+
// the list, or the list is full, so a rewrite would only churn the widget.
|
|
183
|
+
if (merged.added === 0 && merged.trailing !== "added") return { steps, merged };
|
|
179
184
|
const outcome = await ctx.executeTool(TODO_TOOL, { todos: merged.todos });
|
|
180
185
|
if (!outcome.isError) return { steps, merged };
|
|
181
186
|
// The nested call fails both when the tool is unavailable and when it rejects
|
|
@@ -185,6 +190,56 @@ async function seedTodos(
|
|
|
185
190
|
return { steps, merged, error: firstLine ? sanitizeLine(firstLine) : undefined };
|
|
186
191
|
}
|
|
187
192
|
|
|
193
|
+
/** What the goal offer did: `recorded` undefined when there was nothing to offer. */
|
|
194
|
+
interface GoalOffer {
|
|
195
|
+
objective: string;
|
|
196
|
+
recorded?: boolean;
|
|
197
|
+
error?: string;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Offer to record the approved plan as the session goal — only when the session
|
|
202
|
+
* has no goal at all (an active or achieved one is the user's, never replaced) and
|
|
203
|
+
* the session can ask. The objective is the plan's own `# title`, or the file-name
|
|
204
|
+
* slug; recording runs through the goal tool, so validation and the widget stay
|
|
205
|
+
* goal's, and no model turn is spent.
|
|
206
|
+
*/
|
|
207
|
+
async function offerSessionGoal(ctx: ExtensionToolContext, plan: StoredPlan): Promise<GoalOffer> {
|
|
208
|
+
const objective = planObjective(plan);
|
|
209
|
+
if (!ctx.hasUI) return { objective };
|
|
210
|
+
// The branch already carries a goal: leave it alone, and say nothing.
|
|
211
|
+
if (lastGoal(ctx.sessionManager.getBranch()) !== null) return { objective };
|
|
212
|
+
const approved = await ctx.ui.confirm("Record this plan as the session goal?", objective);
|
|
213
|
+
if (!approved) return { objective };
|
|
214
|
+
const outcome = await ctx.executeTool(GOAL_TOOL, { objective });
|
|
215
|
+
if (!outcome.isError) return { objective, recorded: true };
|
|
216
|
+
const part = outcome.result?.content?.find((entry) => entry.type === "text");
|
|
217
|
+
const firstLine = part?.type === "text" ? part.text.split("\n", 1)[0] : undefined;
|
|
218
|
+
return { objective, recorded: false, error: firstLine ? sanitizeLine(firstLine) : undefined };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The plan's own title: its first `# heading`, else the file-name slug. */
|
|
222
|
+
function planObjective(plan: StoredPlan): string {
|
|
223
|
+
const heading = /^ {0,3}#\s+(.+)$/m.exec(plan.content)?.[1];
|
|
224
|
+
const title = heading === undefined ? "" : sanitizeLine(heading).trim();
|
|
225
|
+
return title || planTitle(basename(plan.path));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* One sentence about the plan's verification item, or "" when the plan has none
|
|
230
|
+
* (or it was already in the list). The item is a todo, not a plan step, so it is
|
|
231
|
+
* ticked in the list rather than in the file.
|
|
232
|
+
*/
|
|
233
|
+
function verificationNote(merged: TodoMerge | undefined): string {
|
|
234
|
+
if (merged?.trailing === "added") {
|
|
235
|
+
return "\nThe plan's verification section is the last todo item; tick it in the todo list, not in the file.";
|
|
236
|
+
}
|
|
237
|
+
if (merged?.trailing === "deferred") {
|
|
238
|
+
return "\nThe plan's verification section did not fit in the full todo list; keep it in mind.";
|
|
239
|
+
}
|
|
240
|
+
return "";
|
|
241
|
+
}
|
|
242
|
+
|
|
188
243
|
/** What the merge did, for the approval message; `steps` is the plan's step count. */
|
|
189
244
|
function mergeSummary(merged: TodoMerge, steps: PlanStep[]): string {
|
|
190
245
|
const total = steps.length;
|
|
@@ -287,7 +342,8 @@ export function registerTools(
|
|
|
287
342
|
"Save the plan you designed as a markdown file under .pi/plans (project root, or the agent directory outside a " +
|
|
288
343
|
"repository). Write the complete plan, not a summary, in this outline so it is consistent and scannable:\n\n" +
|
|
289
344
|
`${PLAN_OUTLINE}\n\n` +
|
|
290
|
-
"Returns the path to pass to exit_plan_mode; plan_path overwrites an existing file when refining."
|
|
345
|
+
"Returns the path to pass to exit_plan_mode; plan_path overwrites an existing file when refining. " +
|
|
346
|
+
`A plan with more than ${MAX_STEPS} top-level steps in its Steps section is refused, so approval never drops steps it cannot track.`,
|
|
291
347
|
promptSnippet: "Save the designed plan as a markdown file under .pi/plans.",
|
|
292
348
|
promptGuidelines: [
|
|
293
349
|
"While plan mode is active, write the full plan with write_plan, then call exit_plan_mode with the returned path.",
|
|
@@ -308,6 +364,21 @@ export function registerTools(
|
|
|
308
364
|
};
|
|
309
365
|
}
|
|
310
366
|
const args = params as WritePlanArgs;
|
|
367
|
+
const summary = planStepSummary(args.content);
|
|
368
|
+
if (summary.total > MAX_STEPS) {
|
|
369
|
+
// Refuse before writing: a note after the fact arrives once the file exists and
|
|
370
|
+
// the review has listed every step, so the overflow is already lost.
|
|
371
|
+
return {
|
|
372
|
+
content: [
|
|
373
|
+
{
|
|
374
|
+
type: "text",
|
|
375
|
+
text: `${overCap(summary.total)} Nothing was written; group the steps (one outcome each, sub-bullets for detail) and call write_plan again.`,
|
|
376
|
+
},
|
|
377
|
+
],
|
|
378
|
+
details: undefined,
|
|
379
|
+
isError: true,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
311
382
|
try {
|
|
312
383
|
const file = await runtime.plans.write(ctx.cwd, {
|
|
313
384
|
title: args.title,
|
|
@@ -319,7 +390,7 @@ export function registerTools(
|
|
|
319
390
|
content: [
|
|
320
391
|
{
|
|
321
392
|
type: "text",
|
|
322
|
-
text: `Wrote the plan to ${file.relativePath}. Call exit_plan_mode with plan_path: ${file.path}${
|
|
393
|
+
text: `Wrote the plan to ${file.relativePath}. Call exit_plan_mode with plan_path: ${file.path}${stepElisionNote(summary.elided)}`,
|
|
323
394
|
},
|
|
324
395
|
],
|
|
325
396
|
details: { path: file.path, relativePath: file.relativePath, bytes: file.bytes } satisfies WritePlanDetails,
|
|
@@ -394,6 +465,27 @@ export function registerTools(
|
|
|
394
465
|
};
|
|
395
466
|
}
|
|
396
467
|
|
|
468
|
+
const total = planStepSummary(file.content).total;
|
|
469
|
+
if (total > MAX_STEPS) {
|
|
470
|
+
// A hand-edited or older plan file can still be over the cap; refuse it here
|
|
471
|
+
// too, so approval never seeds a list it cannot hold in full.
|
|
472
|
+
return {
|
|
473
|
+
content: [
|
|
474
|
+
{
|
|
475
|
+
type: "text",
|
|
476
|
+
text: `${overCap(total)} It was not presented for approval; call write_plan with plan_path to group the steps, then exit_plan_mode again.`,
|
|
477
|
+
},
|
|
478
|
+
],
|
|
479
|
+
details: {
|
|
480
|
+
approved: false,
|
|
481
|
+
plan: file.content,
|
|
482
|
+
planPath: file.path,
|
|
483
|
+
relativePath: file.relativePath,
|
|
484
|
+
} satisfies ExitPlanModeDetails,
|
|
485
|
+
isError: true,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
397
489
|
if (!ctx.hasUI) {
|
|
398
490
|
return {
|
|
399
491
|
content: [
|
|
@@ -464,6 +556,7 @@ export function registerTools(
|
|
|
464
556
|
|
|
465
557
|
runtime.disable(ctx);
|
|
466
558
|
const { steps, merged, error } = await seedTodos(pi, ctx, decision.plan.content);
|
|
559
|
+
const goal = await offerSessionGoal(ctx, decision.plan);
|
|
467
560
|
const listing =
|
|
468
561
|
steps.length > 0
|
|
469
562
|
? `\n\n${steps
|
|
@@ -478,6 +571,13 @@ export function registerTools(
|
|
|
478
571
|
: merged
|
|
479
572
|
? mergeSummary(merged, steps)
|
|
480
573
|
: ` Could not read the todo list. Record these steps with the todo tool before you start, and tick them off with ${SET_PLAN_STEPS_TOOL}.`;
|
|
574
|
+
const verification = verificationNote(merged);
|
|
575
|
+
const goalNote =
|
|
576
|
+
goal.recorded === undefined
|
|
577
|
+
? ""
|
|
578
|
+
: goal.recorded
|
|
579
|
+
? ` Recorded the session goal: ${goal.objective}.`
|
|
580
|
+
: ` Could not record the session goal: ${goal.error ?? "unknown error"}. Use the goal tool to set it.`;
|
|
481
581
|
const browserNote = decision.comments.length
|
|
482
582
|
? `\n\nThe user approved from the browser review with these comments:\n${feedbackSummary(decision.comments)}`
|
|
483
583
|
: "";
|
|
@@ -485,7 +585,7 @@ export function registerTools(
|
|
|
485
585
|
content: [
|
|
486
586
|
{
|
|
487
587
|
type: "text",
|
|
488
|
-
text: `Plan approved. Plan mode is off and write access is restored. The plan file ${decision.plan.relativePath} is the source of truth; follow its steps.${browserNote}${tail}${listing}`,
|
|
588
|
+
text: `Plan approved. Plan mode is off and write access is restored. The plan file ${decision.plan.relativePath} is the source of truth; follow its steps.${browserNote}${tail}${verification}${goalNote}${listing}`,
|
|
489
589
|
},
|
|
490
590
|
],
|
|
491
591
|
details: {
|
package/extensions/plan/tui.ts
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Terminal rendering for the plan review screen.
|
|
3
3
|
*
|
|
4
|
-
* `sanitizePlanText` is exported so the transcript renderer (`tools.ts`) can
|
|
5
|
-
* strip control characters from plan text with the same rules the review screen
|
|
6
|
-
* uses.
|
|
7
|
-
*
|
|
8
4
|
* `PlanViewComponent` is the scrollable review screen `exit_plan_mode` opens
|
|
9
5
|
* for approval. The plan body is rendered by Pi's own `Markdown` component with
|
|
10
6
|
* the active Markdown theme, so headings, lists, code, links, and quotes match
|
|
@@ -30,7 +26,7 @@ import {
|
|
|
30
26
|
type TuiMouseEvent,
|
|
31
27
|
type TuiMouseEventResult,
|
|
32
28
|
} from "@earendil-works/pi-tui";
|
|
33
|
-
import { sanitizeLine
|
|
29
|
+
import { sanitizeLine } from "../../lib/format.ts";
|
|
34
30
|
import {
|
|
35
31
|
editorTheme,
|
|
36
32
|
screenHint,
|
|
@@ -45,8 +41,8 @@ import { titleRule } from "../../lib/tui/rules.ts";
|
|
|
45
41
|
import { emptyState, checkbox } from "../../lib/tui/rows.ts";
|
|
46
42
|
import { paint } from "../../lib/tui/tokens.ts";
|
|
47
43
|
import { planTitle, type StoredPlan } from "./plans.ts";
|
|
48
|
-
import { changedSections,
|
|
49
|
-
import {
|
|
44
|
+
import { changedSections, sanitizePlanText, type SectionDigest } from "./plan-text.ts";
|
|
45
|
+
import { extractPlanSections } from "./steps.ts";
|
|
50
46
|
|
|
51
47
|
/** What the user chose from the read/review screen. */
|
|
52
48
|
export type PlanViewAction = "approve" | "refine" | "keep" | "browser";
|
|
@@ -71,10 +67,10 @@ interface BodyCache {
|
|
|
71
67
|
export interface PlanViewOptions {
|
|
72
68
|
plan: StoredPlan;
|
|
73
69
|
/**
|
|
74
|
-
* The
|
|
75
|
-
* Used only to mark the sections that changed since then.
|
|
70
|
+
* The section digests the previous review of this plan showed, when there was
|
|
71
|
+
* one. Used only to mark the sections that changed since then.
|
|
76
72
|
*/
|
|
77
|
-
|
|
73
|
+
baseline?: SectionDigest[];
|
|
78
74
|
theme: Theme;
|
|
79
75
|
/**
|
|
80
76
|
* Called once when the screen closes. `refinement` carries the typed text
|
|
@@ -110,17 +106,6 @@ const PRIMARY_HINTS: ScreenHint[] = [
|
|
|
110
106
|
const SCROLL_HINTS: ScreenHint[] = ["↑/↓ or k/j scroll", "space/b page", "g/G ends"];
|
|
111
107
|
/** Section-jump hint, shown when the plan has more than one heading section. */
|
|
112
108
|
const SECTION_HINTS: ScreenHint[] = ["n/p section"];
|
|
113
|
-
/** Spaces a tab expands to; the terminal's own tab stops are not width-modelled. */
|
|
114
|
-
const TAB_WIDTH = 4;
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Replace control characters (including ESC and the C1 block) and expand tabs
|
|
118
|
-
* so model-authored plan text cannot drive the terminal. Newlines are kept, so
|
|
119
|
-
* callers can still render or split the text line by line.
|
|
120
|
-
*/
|
|
121
|
-
export function sanitizePlanText(text: string): string {
|
|
122
|
-
return stripControlChars(text.replace(/\t/g, " ".repeat(TAB_WIDTH)));
|
|
123
|
-
}
|
|
124
109
|
|
|
125
110
|
/** A task checkbox in a list marker: `- [x] `, `1. [ ] `. */
|
|
126
111
|
const TASK_MARKER = /^([-*+]|\d+[.)])(\s+)(\[([ xX])\])(\s*)$/;
|
|
@@ -210,13 +195,7 @@ export class PlanViewComponent implements Component {
|
|
|
210
195
|
const base = this.options.markdownTheme ?? getMarkdownTheme();
|
|
211
196
|
const theme = planMarkdownTheme(this.theme, base);
|
|
212
197
|
const current = sanitizePlanText(this.options.plan.content);
|
|
213
|
-
|
|
214
|
-
// raw, so sanitizing only `current` would report a tab as a change on every
|
|
215
|
-
// review.
|
|
216
|
-
const changed =
|
|
217
|
-
this.options.previous === undefined
|
|
218
|
-
? []
|
|
219
|
-
: changedSections(stripFeedback(sanitizePlanText(this.options.previous)), stripFeedback(current));
|
|
198
|
+
const changed = changedSections(this.options.baseline, current);
|
|
220
199
|
const chunks = extractPlanSections(current);
|
|
221
200
|
const lines: string[] = [];
|
|
222
201
|
const sections: SectionOffset[] = [];
|
package/extensions/plan/types.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { PlanAnnotation } from "./feedback.ts";
|
|
6
|
+
import type { SectionDigest } from "./plan-text.ts";
|
|
6
7
|
import type { StepUpdate } from "./steps.ts";
|
|
7
8
|
|
|
8
9
|
/** Persisted (custom entry) plan-mode state. */
|
|
@@ -15,6 +16,14 @@ export interface PlanModeEntry {
|
|
|
15
16
|
removed?: string[];
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
/** Persisted (custom entry) review baseline for one plan. */
|
|
20
|
+
export interface ReviewBaselineEntry {
|
|
21
|
+
/** Resolved path of the plan file the review showed. */
|
|
22
|
+
path: string;
|
|
23
|
+
/** One digest per heading section, in document order. */
|
|
24
|
+
sections: SectionDigest[];
|
|
25
|
+
}
|
|
26
|
+
|
|
18
27
|
/** Structured result carried in `enter_plan_mode`'s `details`. */
|
|
19
28
|
export interface EnterPlanModeDetails {
|
|
20
29
|
/** Whether the user agreed to enter plan mode. */
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import {
|
|
17
17
|
brandLockup,
|
|
18
18
|
docHead,
|
|
19
|
+
SIDEBAR_COLLAPSE_MAX,
|
|
19
20
|
themeToggle,
|
|
20
21
|
WEB_BASE_CSS,
|
|
21
22
|
WEB_COMPONENTS_CSS,
|
|
@@ -90,7 +91,6 @@ ${WEB_COMPONENTS_CSS}
|
|
|
90
91
|
.review-progress__fill{display:block;height:100%;width:0;background:var(--accent);transition:width var(--motion-fast) linear}
|
|
91
92
|
/* Review panel: the plan's review facts, the section outline, then the keys. */
|
|
92
93
|
.bf-app__body--single{grid-template-columns:minmax(0,1fr)}
|
|
93
|
-
.bf-app__sidebar .bf-panel{flex:1 1 auto;min-height:0;min-width:0}
|
|
94
94
|
.review-panel{display:flex;flex-direction:column;gap:var(--space-3);padding:var(--space-4) var(--space-4) var(--space-2)}
|
|
95
95
|
.review-facts{display:flex;flex-direction:column;gap:var(--space-2);padding-bottom:var(--space-3);border-bottom:1px solid var(--border)}
|
|
96
96
|
.review-fact{display:flex;align-items:center;justify-content:space-between;gap:var(--space-3);font-size:var(--fs-sm);color:var(--muted)}
|
|
@@ -172,7 +172,7 @@ ${WEB_COMPONENTS_CSS}
|
|
|
172
172
|
.review-nav .lvl-2 a,.review-nav .lvl-3 a,.review-nav .lvl-4 a,.review-nav .lvl-5 a,.review-nav .lvl-6 a{padding-left:8px}
|
|
173
173
|
.review-hints{display:none}
|
|
174
174
|
}
|
|
175
|
-
@media (max-width
|
|
175
|
+
@media (max-width:${SIDEBAR_COLLAPSE_MAX}px){
|
|
176
176
|
.bf-review{padding:var(--space-4) 18px 80px}
|
|
177
177
|
.review-nav__title{display:none}
|
|
178
178
|
}
|
package/extensions/plan/web.ts
CHANGED
|
@@ -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,
|
|
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
|
|
63
|
-
|
|
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
|
|
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 `
|
|
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
|
|
181
|
+
const refreshPageText = async (): Promise<StoredPlan> => {
|
|
181
182
|
const current = (await store.read(cwd, plan.path)) ?? plan;
|
|
182
|
-
|
|
183
|
+
pageText = stripFeedback(current.content);
|
|
183
184
|
return current;
|
|
184
185
|
};
|
|
185
186
|
|
|
186
187
|
const page = async (): Promise<ReviewPageData> => {
|
|
187
|
-
const stored = await
|
|
188
|
-
const current =
|
|
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 =
|
|
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) !==
|
|
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
|
|
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` (
|
|
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
|
|
137
|
-
|
|
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.` : "";
|
package/extensions/rewind/git.ts
CHANGED
|
@@ -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
|
-
/**
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
/**
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
/**
|
|
145
|
-
export async function
|
|
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
|
-
|
|
151
|
-
): Promise<
|
|
152
|
-
const args = ["diff", "--no-color", "--
|
|
153
|
-
if (
|
|
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
|
-
|
|
157
|
-
|
|
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. */
|