@gavin-hu/my-pi-agent 0.15.0 → 0.16.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 (106) hide show
  1. package/README.md +12 -11
  2. package/README.zh-CN.md +9 -7
  3. package/extensions/bytefly/README.md +98 -116
  4. package/extensions/bytefly/notice/component.ts +6 -4
  5. package/extensions/bytefly/notice/config.ts +16 -2
  6. package/extensions/bytefly/panel/format.ts +3 -8
  7. package/extensions/bytefly/panel/join.ts +1 -6
  8. package/extensions/bytefly/panel/settings.ts +74 -377
  9. package/extensions/bytefly/panel/tui/entries.ts +16 -14
  10. package/extensions/bytefly/panel/tui/extensions-view.ts +3 -0
  11. package/extensions/bytefly/panel/tui/frame.ts +2 -2
  12. package/extensions/bytefly/panel/tui/settings-view.ts +8 -12
  13. package/extensions/bytefly/panel/tui.ts +4 -6
  14. package/extensions/bytefly/picker/resources.ts +7 -3
  15. package/extensions/bytefly/working/register.ts +2 -8
  16. package/extensions/check/tools.ts +2 -11
  17. package/extensions/context/README.md +162 -0
  18. package/extensions/context/collect.ts +53 -0
  19. package/extensions/context/commands.ts +100 -0
  20. package/extensions/context/format.ts +82 -0
  21. package/extensions/context/index.ts +16 -0
  22. package/extensions/context/report.ts +319 -0
  23. package/extensions/context/tui.ts +268 -0
  24. package/extensions/context/types.ts +122 -0
  25. package/extensions/file-browse/README.md +83 -8
  26. package/extensions/file-browse/client.ts +3 -1
  27. package/extensions/file-browse/config.ts +23 -0
  28. package/extensions/file-browse/files.ts +183 -48
  29. package/extensions/file-browse/fuzzy.ts +65 -11
  30. package/extensions/file-browse/html.ts +78 -133
  31. package/extensions/file-browse/index.ts +17 -2
  32. package/extensions/file-browse/live-client.ts +158 -0
  33. package/extensions/file-browse/live.ts +256 -0
  34. package/extensions/file-browse/markdown.ts +3 -10
  35. package/extensions/file-browse/raw-html.ts +3 -10
  36. package/extensions/file-browse/router.ts +33 -8
  37. package/extensions/file-browse/server.ts +78 -13
  38. package/extensions/file-browse/styles.ts +124 -0
  39. package/extensions/file-open/README.md +1 -2
  40. package/extensions/{document → file-read}/README.md +72 -56
  41. package/extensions/{document → file-read}/config.ts +12 -9
  42. package/extensions/{document → file-read}/extract/doc.ts +2 -2
  43. package/extensions/{document → file-read}/extract/docx.ts +3 -3
  44. package/extensions/{document → file-read}/extract/file.ts +9 -9
  45. package/extensions/{document → file-read}/extract/odt.ts +3 -3
  46. package/extensions/{document → file-read}/extract/pdf.ts +3 -3
  47. package/extensions/{document → file-read}/extract/rtf.ts +3 -3
  48. package/extensions/{document → file-read}/extract/xlsx.ts +4 -4
  49. package/extensions/{document → file-read}/formats.ts +11 -11
  50. package/extensions/file-read/index.ts +17 -0
  51. package/extensions/{document → file-read}/paging.ts +15 -13
  52. package/extensions/{document → file-read}/render.ts +17 -13
  53. package/extensions/{document → file-read}/schema.ts +7 -7
  54. package/extensions/{document → file-read}/tool.ts +36 -28
  55. package/extensions/goal/tools.ts +10 -14
  56. package/extensions/job/commands.ts +32 -46
  57. package/extensions/job/runtime.ts +6 -8
  58. package/extensions/job/tools.ts +7 -26
  59. package/extensions/memory/commands.ts +30 -44
  60. package/extensions/memory/tools.ts +6 -14
  61. package/extensions/plan/tools/enter.ts +6 -7
  62. package/extensions/plan/tools/exit.ts +6 -7
  63. package/extensions/plan/tools/steps.ts +6 -7
  64. package/extensions/plan/tools/write.ts +8 -9
  65. package/extensions/plan/web-router.ts +2 -12
  66. package/extensions/plan/web.ts +30 -15
  67. package/extensions/question/tools.ts +5 -4
  68. package/extensions/rewind/checklist.ts +10 -3
  69. package/extensions/rewind/git.ts +27 -14
  70. package/extensions/rewind/hunk-picker.ts +1 -1
  71. package/extensions/rewind/path-picker.ts +1 -1
  72. package/extensions/rewind/review-tui.ts +3 -3
  73. package/extensions/rewind/snapshot.ts +13 -4
  74. package/extensions/subagent/background-tui.ts +1 -1
  75. package/extensions/subagent/background.ts +1 -7
  76. package/extensions/todo/tools.ts +10 -14
  77. package/extensions/watch/README.md +5 -5
  78. package/extensions/watch/commands.ts +33 -47
  79. package/extensions/watch/runtime.ts +5 -7
  80. package/extensions/watch/tools.ts +7 -18
  81. package/extensions/web-access/README.md +3 -3
  82. package/extensions/web-access/search/search.ts +9 -18
  83. package/extensions/wechat/timer.ts +9 -30
  84. package/extensions/wechat/tools.ts +4 -7
  85. package/extensions/worktree/status.ts +14 -9
  86. package/lib/README.md +12 -8
  87. package/lib/escape.ts +15 -2
  88. package/lib/git/read.ts +20 -0
  89. package/lib/http/headers.ts +20 -0
  90. package/lib/http/server.ts +22 -4
  91. package/lib/pdf.ts +2 -2
  92. package/lib/pool.ts +62 -0
  93. package/lib/requirements.ts +2 -2
  94. package/lib/screen-session.ts +85 -0
  95. package/lib/timer.ts +61 -0
  96. package/lib/tui/README.md +3 -3
  97. package/lib/tui/columns.ts +33 -8
  98. package/lib/tui/dock-screen.ts +12 -4
  99. package/lib/tui/rows.ts +15 -6
  100. package/lib/tui/rules.ts +4 -8
  101. package/lib/tui/tool.ts +55 -2
  102. package/package.json +8 -4
  103. package/skills/changelog/SKILL.md +136 -0
  104. package/extensions/document/index.ts +0 -17
  105. /package/extensions/{document → file-read}/deadline.ts +0 -0
  106. /package/extensions/{document → file-read}/extract/vendor.d.ts +0 -0
@@ -9,17 +9,9 @@
9
9
  */
10
10
 
11
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
- import { sanitizeLine } from "../../lib/format.ts";
13
12
  import { errorResult } from "../../lib/tool-result.ts";
14
13
  import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
15
- import {
16
- elapsedSuffix,
17
- errorLine,
18
- rejectionLine,
19
- reuseCallText,
20
- syncElapsedTimer,
21
- toolCallTitle,
22
- } from "../../lib/tui/tool.ts";
14
+ import { callLine, detailsOrLine, elapsedSuffix, rejectionLine, syncElapsedTimer } from "../../lib/tui/tool.ts";
23
15
  import { SEPARATORS } from "../../lib/tui/glyphs.ts";
24
16
  import { paint } from "../../lib/tui/tokens.ts";
25
17
  import {
@@ -229,30 +221,19 @@ export function registerTools(pi: ExtensionAPI, runtime: JobsRuntime): void {
229
221
 
230
222
  renderCall(args, theme, context) {
231
223
  const action = typeof args.action === "string" ? args.action : "job";
232
- const text = reuseCallText(context.lastComponent, "", "middle");
233
- text.setText(
234
- toolCallTitle(theme, `${TOOL_NAME} `) +
235
- paint(
236
- theme,
237
- "meta",
238
- sanitizeLine(formatCallText(action, args as Record<string, unknown>, context.argsComplete)),
239
- ),
240
- );
241
- return text;
224
+ const subject = formatCallText(action, args as Record<string, unknown>, context.argsComplete);
225
+ return callLine(theme, context.lastComponent, TOOL_NAME, subject, "middle");
242
226
  },
243
227
 
244
228
  renderResult(result, options, theme, context) {
245
229
  const text = reuseText(context.lastComponent);
246
230
  const startedAt = syncElapsedTimer(options.isPartial, context);
247
- const details = toolDetails<JobDetails>(result.details);
248
- if (!details) {
249
- text.setText(rejectionLine(theme, result, options.expanded));
250
- return text;
251
- }
252
- if (details.error) {
253
- text.setText(errorLine(theme, details.error));
231
+ const outcome = detailsOrLine(theme, result, options.expanded, toolDetails<JobDetails>(result.details));
232
+ if ("line" in outcome) {
233
+ text.setText(outcome.line);
254
234
  return text;
255
235
  }
236
+ const details = outcome.details;
256
237
 
257
238
  if (options.isPartial) {
258
239
  const id = details.job?.id;
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
13
- import { withRailsSuppressed } from "../../lib/rails.ts";
13
+ import { withPolledScreen } from "../../lib/screen-session.ts";
14
14
  import { messageOf } from "./errors.ts";
15
15
  import { formatHealth, memoryHealth } from "./doctor.ts";
16
16
  import { formatNotice, scopeLabel } from "./format.ts";
@@ -55,49 +55,35 @@ async function openPicker(pi: ExtensionAPI, runtime: MemoryRuntime, ctx: Extensi
55
55
  return;
56
56
  }
57
57
 
58
- let timer: ReturnType<typeof setInterval> | undefined;
59
- const stopTimer = (): void => {
60
- if (timer) clearInterval(timer);
61
- timer = undefined;
62
- };
63
-
64
- // The dock screen owns the editor slot; hide the rails for its lifetime.
65
- try {
66
- await withRailsSuppressed(pi, () =>
67
- ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
68
- const component = new MemoryPickerComponent(
69
- tui,
70
- theme,
71
- {
72
- entries: () => runtime.entries(),
73
- trusted: () => runtime.trusted(),
74
- refresh: () => runtime.refresh().changed.length > 0,
75
- add: (scope, text) => runtime.add(scope, text),
76
- update: (scope, from, to) => runtime.update(scope, from, to),
77
- forget: (scope, entry) => runtime.forget(scope, entry),
78
- merge: (scope, first, second, merged) => runtime.merge(scope, first, second, merged),
79
- move: (from, to, text) => runtime.move(from, to, text),
80
- },
81
- () => {
82
- stopTimer();
83
- done();
84
- },
85
- () => tui.requestRender(),
86
- () => tui.terminal?.rows,
87
- );
88
-
89
- // Pick up an external edit while the screen is open, repainting only on a change.
90
- timer = setInterval(() => {
91
- if (runtime.refresh().changed.length > 0) tui.requestRender();
92
- }, PICKER_POLL_MS);
93
- timer.unref?.();
94
-
95
- return component;
96
- }),
97
- );
98
- } finally {
99
- stopTimer();
100
- }
58
+ await withPolledScreen(
59
+ pi,
60
+ ctx,
61
+ (screen) =>
62
+ new MemoryPickerComponent(
63
+ screen.tui,
64
+ screen.theme,
65
+ {
66
+ entries: () => runtime.entries(),
67
+ trusted: () => runtime.trusted(),
68
+ refresh: () => runtime.refresh().changed.length > 0,
69
+ add: (scope, text) => runtime.add(scope, text),
70
+ update: (scope, from, to) => runtime.update(scope, from, to),
71
+ forget: (scope, entry) => runtime.forget(scope, entry),
72
+ merge: (scope, first, second, merged) => runtime.merge(scope, first, second, merged),
73
+ move: (from, to, text) => runtime.move(from, to, text),
74
+ },
75
+ screen.close,
76
+ screen.requestRender,
77
+ screen.viewportRows,
78
+ ),
79
+ {
80
+ pollMs: PICKER_POLL_MS,
81
+ // Pick up an external edit while the screen is open, repainting only on a change.
82
+ poll: (_component, handles) => {
83
+ if (runtime.refresh().changed.length > 0) handles.requestRender();
84
+ },
85
+ },
86
+ );
101
87
  }
102
88
 
103
89
  export function registerCommands(pi: ExtensionAPI, runtime: MemoryRuntime): void {
@@ -10,10 +10,8 @@
10
10
 
11
11
  import type { JsonValue } from "@earendil-works/pi-ai";
12
12
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
- import { sanitizeLine } from "../../lib/format.ts";
13
+ import { callLine, detailsOrLine } from "../../lib/tui/tool.ts";
14
14
  import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
15
- import { rejectionLine, reuseCallText, toolCallTitle } from "../../lib/tui/tool.ts";
16
- import { paint } from "../../lib/tui/tokens.ts";
17
15
  import { messageOf } from "./errors.ts";
18
16
  import { formatCallText, formatResultText, scopeLabel } from "./format.ts";
19
17
  import type { MemoryRuntime } from "./runtime.ts";
@@ -139,27 +137,21 @@ export function registerTools(pi: ExtensionAPI, runtime: MemoryRuntime): void {
139
137
  },
140
138
 
141
139
  renderCall(args, theme, context) {
142
- const text = reuseCallText(context.lastComponent);
143
140
  const body = formatCallText(
144
141
  args as { action?: unknown; text?: unknown; to?: unknown; scope?: unknown },
145
142
  context.argsComplete,
146
143
  );
147
- text.setText(toolCallTitle(theme, `${TOOL_NAME} `) + paint(theme, "meta", body));
148
- return text;
144
+ return callLine(theme, context.lastComponent, TOOL_NAME, body);
149
145
  },
150
146
 
151
147
  renderResult(result, { expanded }, theme, context) {
152
148
  const text = reuseText(context.lastComponent);
153
- const details = toolDetails<MemoryDetails>(result.details);
154
- if (!details) {
155
- text.setText(rejectionLine(theme, result, expanded));
156
- return text;
157
- }
158
- if (details.error) {
159
- text.setText(paint(theme, "bad", `Error: ${sanitizeLine(details.error)}`));
149
+ const outcome = detailsOrLine(theme, result, expanded, toolDetails<MemoryDetails>(result.details));
150
+ if ("line" in outcome) {
151
+ text.setText(outcome.line);
160
152
  return text;
161
153
  }
162
- text.setText(formatResultText(details, expanded));
154
+ text.setText(formatResultText(outcome.details, expanded));
163
155
  return text;
164
156
  },
165
157
  });
@@ -10,7 +10,7 @@ import { Type } from "typebox";
10
10
  import { reuseText, toolDetails } from "../../../lib/tui/chrome.ts";
11
11
  import { GLYPHS } from "../../../lib/tui/glyphs.ts";
12
12
  import { paint } from "../../../lib/tui/tokens.ts";
13
- import { rejectionLine, reuseCallText, toolCallTitle } from "../../../lib/tui/tool.ts";
13
+ import { callLine, detailsOrLine } from "../../../lib/tui/tool.ts";
14
14
  import { PLAN_CONSENT, READ_ONLY_SUMMARY } from "../../../lib/plan-entry.ts";
15
15
  import { ENTER_TOOL, type PlanRuntime } from "../runtime.ts";
16
16
  import type { EnterPlanModeDetails } from "../types.ts";
@@ -72,18 +72,17 @@ export function registerEnterTool(pi: ExtensionAPI, runtime: PlanRuntime): void
72
72
  },
73
73
 
74
74
  renderCall(_args, theme, context) {
75
- const text = reuseCallText(context?.lastComponent);
76
- text.setText(toolCallTitle(theme, `${ENTER_TOOL} `) + paint(theme, "meta", "requested plan mode"));
77
- return text;
75
+ return callLine(theme, context?.lastComponent, ENTER_TOOL, "requested plan mode");
78
76
  },
79
77
 
80
78
  renderResult(result, options, theme, context) {
81
79
  const text = reuseText(context?.lastComponent);
82
- const details = toolDetails<EnterPlanModeDetails>(result.details);
83
- if (!details) {
84
- text.setText(rejectionLine(theme, result, options.expanded));
80
+ const outcome = detailsOrLine(theme, result, options.expanded, toolDetails<EnterPlanModeDetails>(result.details));
81
+ if ("line" in outcome) {
82
+ text.setText(outcome.line);
85
83
  return text;
86
84
  }
85
+ const details = outcome.details;
87
86
  if (details.unavailable) text.setText(paint(theme, "warn", "No UI to confirm plan mode"));
88
87
  else if (details.already) text.setText(paint(theme, "meta", "Already in plan mode"));
89
88
  else if (details.entered) text.setText(paint(theme, "ok", `${GLYPHS.check} Plan mode enabled`));
@@ -17,7 +17,7 @@ import { GOAL_TOOL } from "../../../lib/tool-names.ts";
17
17
  import { reuseText, toolDetails } from "../../../lib/tui/chrome.ts";
18
18
  import { GLYPHS } from "../../../lib/tui/glyphs.ts";
19
19
  import { paint } from "../../../lib/tui/tokens.ts";
20
- import { moreHint, rejectionLine, reuseCallText, toolCallTitle } from "../../../lib/tui/tool.ts";
20
+ import { callLine, detailsOrLine, moreHint } from "../../../lib/tui/tool.ts";
21
21
  import { feedbackSummary } from "../feedback.ts";
22
22
  import { mergeSummary, overCap, preview, verificationNote } from "../format.ts";
23
23
  import { sanitizePlanText } from "../plan-text.ts";
@@ -256,18 +256,17 @@ export function registerExitTool(pi: ExtensionAPI, runtime: PlanRuntime, web: Pl
256
256
  },
257
257
 
258
258
  renderCall(_args, theme, context) {
259
- const text = reuseCallText(context?.lastComponent);
260
- text.setText(toolCallTitle(theme, `${EXIT_TOOL} `) + paint(theme, "meta", "submitted a plan"));
261
- return text;
259
+ return callLine(theme, context?.lastComponent, EXIT_TOOL, "submitted a plan");
262
260
  },
263
261
 
264
262
  renderResult(result, { expanded }, theme, context) {
265
263
  const text = reuseText(context?.lastComponent);
266
- const details = toolDetails<ExitPlanModeDetails>(result.details);
267
- if (!details) {
268
- text.setText(rejectionLine(theme, result, expanded));
264
+ const outcome = detailsOrLine(theme, result, expanded, toolDetails<ExitPlanModeDetails>(result.details));
265
+ if ("line" in outcome) {
266
+ text.setText(outcome.line);
269
267
  return text;
270
268
  }
269
+ const details = outcome.details;
271
270
  const heading = details.unavailable
272
271
  ? paint(theme, "warn", "No UI to approve the plan")
273
272
  : details.approved
@@ -12,7 +12,7 @@ import { Type, type Static } from "typebox";
12
12
  import { reuseText, toolDetails } from "../../../lib/tui/chrome.ts";
13
13
  import { GLYPHS } from "../../../lib/tui/glyphs.ts";
14
14
  import { paint } from "../../../lib/tui/tokens.ts";
15
- import { rejectionLine, reuseCallText, toolCallTitle } from "../../../lib/tui/tool.ts";
15
+ import { callLine, detailsOrLine } from "../../../lib/tui/tool.ts";
16
16
  import { describeUpdates, stepLabel } from "../format.ts";
17
17
  import { SET_PLAN_STEPS_TOOL, type PlanRuntime } from "../runtime.ts";
18
18
  import { extractPlanSteps } from "../steps.ts";
@@ -103,7 +103,6 @@ export function registerSetPlanStepsTool(pi: ExtensionAPI, runtime: PlanRuntime)
103
103
  },
104
104
 
105
105
  renderCall(args, theme, context) {
106
- const text = reuseCallText(context?.lastComponent);
107
106
  const updates = (args as Partial<SetPlanStepsArgs>).updates;
108
107
  const label =
109
108
  updates && updates.length === 1 && updates[0]?.step !== undefined
@@ -111,17 +110,17 @@ export function registerSetPlanStepsTool(pi: ExtensionAPI, runtime: PlanRuntime)
111
110
  : updates && updates.length > 1
112
111
  ? `${updates.length} steps`
113
112
  : "steps";
114
- text.setText(toolCallTitle(theme, `${SET_PLAN_STEPS_TOOL} `) + paint(theme, "meta", label));
115
- return text;
113
+ return callLine(theme, context?.lastComponent, SET_PLAN_STEPS_TOOL, label);
116
114
  },
117
115
 
118
116
  renderResult(result, options, theme, context) {
119
117
  const text = reuseText(context?.lastComponent);
120
- const details = toolDetails<SetPlanStepsDetails>(result.details);
121
- if (!details) {
122
- text.setText(rejectionLine(theme, result, options.expanded));
118
+ const outcome = detailsOrLine(theme, result, options.expanded, toolDetails<SetPlanStepsDetails>(result.details));
119
+ if ("line" in outcome) {
120
+ text.setText(outcome.line);
123
121
  return text;
124
122
  }
123
+ const details = outcome.details;
125
124
  const completed = details.updates.filter((update) => update.status === "completed").map((u) => u.step);
126
125
  const pending = details.updates.filter((update) => update.status === "pending").map((u) => u.step);
127
126
  const heading =
@@ -11,7 +11,7 @@ import { sanitizeLine } from "../../../lib/format.ts";
11
11
  import { reuseText, toolDetails } from "../../../lib/tui/chrome.ts";
12
12
  import { GLYPHS } from "../../../lib/tui/glyphs.ts";
13
13
  import { paint } from "../../../lib/tui/tokens.ts";
14
- import { rejectionLine, reuseCallText, toolCallTitle } from "../../../lib/tui/tool.ts";
14
+ import { callLine, detailsOrLine } from "../../../lib/tui/tool.ts";
15
15
  import { overCap, stepElisionNote } from "../format.ts";
16
16
  import { WRITE_PLAN_TOOL, type PlanRuntime } from "../runtime.ts";
17
17
  import { MAX_STEPS, PLAN_OUTLINE, planStepSummary } from "../steps.ts";
@@ -106,21 +106,20 @@ export function registerWriteTool(pi: ExtensionAPI, runtime: PlanRuntime): void
106
106
  },
107
107
 
108
108
  renderCall(args, theme, context) {
109
- const text = reuseCallText(context?.lastComponent);
110
109
  const title = (args as Partial<WritePlanArgs>).title;
111
- const suffix = title ? paint(theme, "meta", sanitizeLine(title)) : paint(theme, "meta", "plan");
112
- text.setText(toolCallTitle(theme, `${WRITE_PLAN_TOOL} `) + suffix);
113
- return text;
110
+ return callLine(theme, context?.lastComponent, WRITE_PLAN_TOOL, title ? sanitizeLine(title) : "plan");
114
111
  },
115
112
 
116
113
  renderResult(result, options, theme, context) {
117
114
  const text = reuseText(context?.lastComponent);
118
- const details = toolDetails<WritePlanDetails>(result.details);
119
- if (!details) {
120
- text.setText(rejectionLine(theme, result, options.expanded));
115
+ const outcome = detailsOrLine(theme, result, options.expanded, toolDetails<WritePlanDetails>(result.details));
116
+ if ("line" in outcome) {
117
+ text.setText(outcome.line);
121
118
  return text;
122
119
  }
123
- text.setText(paint(theme, "ok", `${GLYPHS.check} Saved plan `) + paint(theme, "meta", details.relativePath));
120
+ text.setText(
121
+ paint(theme, "ok", `${GLYPHS.check} Saved plan `) + paint(theme, "meta", outcome.details.relativePath),
122
+ );
124
123
  return text;
125
124
  },
126
125
  });
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import {
16
+ eventHeaders,
16
17
  isLocalHostHeader,
17
18
  jsonError,
18
19
  jsonHeaders,
@@ -48,17 +49,6 @@ export interface ReviewContext {
48
49
  onCancel: () => void;
49
50
  }
50
51
 
51
- /**
52
- * The event stream's headers: the one response that is not the page policy, because
53
- * it is a long-lived `text/event-stream` rather than a document.
54
- */
55
- const EVENT_HEADERS: Record<string, string> = {
56
- "Content-Type": "text/event-stream; charset=utf-8",
57
- "Cache-Control": "no-store",
58
- Connection: "keep-alive",
59
- "X-Accel-Buffering": "no",
60
- };
61
-
62
52
  /**
63
53
  * Parse and validate an `{ annotations }` request body. Returns the annotations,
64
54
  * or an error response to send instead.
@@ -127,7 +117,7 @@ export async function handleReviewRequest(
127
117
  if (pathname === "/" || pathname === "/index.html") {
128
118
  return { status: 200, headers: pageHeaders(), body: renderReviewPage(await ctx.page()) };
129
119
  }
130
- if (pathname === "/events") return { status: 200, headers: { ...EVENT_HEADERS }, stream: true };
120
+ if (pathname === "/events") return { status: 200, headers: eventHeaders(), stream: true };
131
121
  if (pathname === "/api/cancel") {
132
122
  if (method.toUpperCase() !== "POST") return jsonError(405, "Use POST to cancel.");
133
123
  ctx.onCancel();
@@ -14,8 +14,16 @@ import { randomBytes } from "node:crypto";
14
14
  import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
15
15
  import { basename } from "node:path";
16
16
  import { openInBrowser } from "../../lib/browser.ts";
17
- import { bindLoopback, createCloser, jsonHeaders, respond, type ServeResponse } from "../../lib/http/index.ts";
17
+ import {
18
+ bindLoopback,
19
+ createCloser,
20
+ jsonHeaders,
21
+ openEventStream,
22
+ respond,
23
+ type ServeResponse,
24
+ } from "../../lib/http/index.ts";
18
25
  import { fenceLanguage, highlightLines } from "../../lib/highlight.ts";
26
+ import { type ArmTimer, defaultTimer } from "../../lib/timer.ts";
19
27
  import { codeBody, fenceInfo, planBlocks } from "./blocks.ts";
20
28
  import {
21
29
  MAX_ANNOTATIONS,
@@ -75,6 +83,14 @@ export interface PlanWebReviewDeps {
75
83
  port?: number;
76
84
  /** Override the closed-tab grace; the default leaves a reload time to reconnect. */
77
85
  dropGraceMs?: number;
86
+ /**
87
+ * Override the timer that arms the closed-tab grace.
88
+ *
89
+ * The grace is a race against the browser's reload, so a test drives it by
90
+ * hand: a few tens of milliseconds of deadline makes the test fail when the
91
+ * machine is busy, which is a fact about the runner and not about the code.
92
+ */
93
+ schedule?: ArmTimer;
78
94
  /** Override the accepted body size; a test keeps it small. */
79
95
  maxBodyBytes?: number;
80
96
  }
@@ -138,12 +154,15 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
138
154
  const done = new Promise<PlanReviewResult>((resolve) => {
139
155
  resolveDone = resolve;
140
156
  });
141
- let dropTimer: NodeJS.Timeout | undefined;
157
+ let cancelDrop: (() => void) | undefined;
158
+ const clearDrop = (): void => {
159
+ cancelDrop?.();
160
+ cancelDrop = undefined;
161
+ };
142
162
  const finish = (result: PlanReviewResult): void => {
143
163
  if (settled) return;
144
164
  settled = true;
145
- clearTimeout(dropTimer);
146
- dropTimer = undefined;
165
+ clearDrop();
147
166
  resolveDone(result);
148
167
  };
149
168
 
@@ -251,13 +270,13 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
251
270
  const clients = new Set<ServerResponse>();
252
271
  let hadClient = false;
253
272
  const dropGrace = deps.dropGraceMs ?? DROP_GRACE_MS;
273
+ const schedule = deps.schedule ?? defaultTimer;
254
274
  const scheduleDrop = (): void => {
255
- if (clients.size > 0 || !hadClient || settled || dropTimer) return;
256
- dropTimer = setTimeout(() => {
257
- dropTimer = undefined;
275
+ if (clients.size > 0 || !hadClient || settled || cancelDrop) return;
276
+ cancelDrop = schedule(dropGrace, () => {
277
+ cancelDrop = undefined;
258
278
  if (clients.size === 0) finish({ status: "cancelled" });
259
- }, dropGrace);
260
- (dropTimer as { unref?: () => void }).unref?.();
279
+ });
261
280
  };
262
281
 
263
282
  const server = createServer((request, response) => {
@@ -282,14 +301,10 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
282
301
  };
283
302
  }
284
303
  if (result.stream) {
285
- response.writeHead(result.status, result.headers);
286
- response.write("retry: 2000\n\n");
304
+ openEventStream(response, result);
287
305
  hadClient = true;
288
306
  clients.add(response);
289
- if (dropTimer) {
290
- clearTimeout(dropTimer);
291
- dropTimer = undefined;
292
- }
307
+ clearDrop();
293
308
  request.on("close", () => {
294
309
  clients.delete(response);
295
310
  scheduleDrop();
@@ -7,7 +7,7 @@ import { askHuman } from "../../lib/interaction.ts";
7
7
  import { sanitizeLine } from "../../lib/format.ts";
8
8
  import { withRailsSuppressed } from "../../lib/rails.ts";
9
9
  import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
10
- import { rejectionLine, reuseCallText, toolCallTitle } from "../../lib/tui/tool.ts";
10
+ import { detailsOrLine, reuseCallText, toolCallTitle } from "../../lib/tui/tool.ts";
11
11
  import { paint } from "../../lib/tui/tokens.ts";
12
12
  import { GLYPHS } from "../../lib/tui/glyphs.ts";
13
13
  import { ASK_USER_QUESTION_TOOL } from "../../lib/tool-names.ts";
@@ -94,11 +94,12 @@ export function registerTools(pi: ExtensionAPI): void {
94
94
 
95
95
  renderResult(result, options, theme, context) {
96
96
  const component = reuseText(context?.lastComponent);
97
- const details = toolDetails<AskResult>(result.details);
98
- if (!details) {
99
- component.setText(rejectionLine(theme, result, options.expanded));
97
+ const outcome = detailsOrLine(theme, result, options.expanded, toolDetails<AskResult>(result.details));
98
+ if ("line" in outcome) {
99
+ component.setText(outcome.line);
100
100
  return component;
101
101
  }
102
+ const details = outcome.details;
102
103
  if (details.unavailable) {
103
104
  component.setText(paint(theme, "bad", UNAVAILABLE_TEXT));
104
105
  return component;
@@ -82,8 +82,15 @@ export abstract class ChecklistScreen<T> implements Component {
82
82
  /** A verb other than navigation, toggle, all, none, and enter. */
83
83
  protected verb?(data: string): void;
84
84
 
85
- /** The row under the cursor. */
86
- protected get focused(): T | undefined {
85
+ /**
86
+ * The row under the cursor.
87
+ *
88
+ * Not named `focused`: Pi's `Focusable` check is `"focused" in component`, and a
89
+ * component that answers it is handed the focus flag by assignment, which a
90
+ * read-only accessor turns into a process-ending `TypeError`. The checklist
91
+ * tracks its own cursor and needs no hardware cursor.
92
+ */
93
+ protected get focusedRow(): T | undefined {
87
94
  return this.rows()[this.cursor.selected];
88
95
  }
89
96
 
@@ -107,7 +114,7 @@ export abstract class ChecklistScreen<T> implements Component {
107
114
  }
108
115
  if (navigateList(data, this.cursor, this.rows().length)) return;
109
116
  if (data === " ") {
110
- const row = this.focused;
117
+ const row = this.focusedRow;
111
118
  if (row !== undefined) this.toggle(row);
112
119
  this.options.requestRender();
113
120
  return;
@@ -20,6 +20,7 @@ import {
20
20
  gitDir,
21
21
  repoRoot,
22
22
  revParse,
23
+ revParseMany,
23
24
  runGitOrThrow as run,
24
25
  type RunGit,
25
26
  } from "../../lib/git/index.ts";
@@ -27,7 +28,7 @@ import { parseNameStatus, parseNumStat, type ChangedPath, type PathStat } from "
27
28
 
28
29
  // Re-export exactly the shared helpers this extension consumes, so rewind code
29
30
  // has one git import surface instead of reaching into `lib/git` directly.
30
- export { GitError, currentBranch, gitDir, repoRoot, revParse, type RunGit };
31
+ export { GitError, currentBranch, gitDir, repoRoot, revParse, revParseMany, type RunGit };
31
32
 
32
33
  /** Timeout budget for one git command issued by this extension. */
33
34
  const SNAPSHOT_TIMEOUT_MS = 30_000;
@@ -262,20 +263,32 @@ export async function applyPatchReverse(runGit: RunGit, cwd: string, patch: stri
262
263
  }
263
264
  }
264
265
 
265
- /** Label of an in-progress git operation that a rewind must not clobber, if any. */
266
+ /**
267
+ * The markers an interrupted git operation leaves, in precedence order: the
268
+ * first one present names the operation a rewind would clobber.
269
+ */
270
+ const BUSY_MARKERS: ReadonlyArray<readonly [string, string]> = [
271
+ ["MERGE_HEAD", "merge"],
272
+ ["rebase-merge", "rebase"],
273
+ ["rebase-apply", "rebase"],
274
+ ["CHERRY_PICK_HEAD", "cherry-pick"],
275
+ ["REVERT_HEAD", "revert"],
276
+ ["BISECT_LOG", "bisect"],
277
+ ];
278
+
279
+ /**
280
+ * Label of an in-progress git operation that a rewind must not clobber, if any.
281
+ *
282
+ * `rev-parse --git-path` prints one path per argument, so all six markers are one
283
+ * git call: a clean repository — the common case, where every one of them is
284
+ * missing — would otherwise pay six spawns to learn nothing is in progress.
285
+ */
266
286
  export async function busyGitState(runGit: RunGit, cwd: string): Promise<string | undefined> {
267
- const markers: Array<[string, string]> = [
268
- ["MERGE_HEAD", "merge"],
269
- ["rebase-merge", "rebase"],
270
- ["rebase-apply", "rebase"],
271
- ["CHERRY_PICK_HEAD", "cherry-pick"],
272
- ["REVERT_HEAD", "revert"],
273
- ["BISECT_LOG", "bisect"],
274
- ];
275
- for (const [name, label] of markers) {
276
- const result = await runGit(["rev-parse", "--git-path", name], { cwd });
277
- if (result.code !== 0) continue;
278
- const path = result.stdout.trim();
287
+ const result = await runGit(["rev-parse", ...BUSY_MARKERS.flatMap(([name]) => ["--git-path", name])], { cwd });
288
+ if (result.code !== 0) return undefined;
289
+ const paths = result.stdout.split("\n");
290
+ for (const [index, [, label]] of BUSY_MARKERS.entries()) {
291
+ const path = paths[index]?.trim();
279
292
  if (path && existsSync(resolve(cwd, path))) return label;
280
293
  }
281
294
  return undefined;
@@ -94,7 +94,7 @@ export class HunkPickerComponent extends ChecklistScreen<HunkRow> {
94
94
 
95
95
  /** The focused hunk's first removed and added line. */
96
96
  protected detailLines(width: number): string[] {
97
- const focused = this.focused;
97
+ const focused = this.focusedRow;
98
98
  if (!focused) return [];
99
99
  return previewOf(focused.hunk).map((line) => {
100
100
  const role = line.startsWith("+") ? "ok" : line.startsWith("-") ? "bad" : "meta";
@@ -137,7 +137,7 @@ export class PathPickerComponent extends ChecklistScreen<Row> {
137
137
 
138
138
  /** What the focused row will do, in words. */
139
139
  protected detailLines(width: number): string[] {
140
- const row = this.focused;
140
+ const row = this.focusedRow;
141
141
  if (!row) return [];
142
142
  const text =
143
143
  row.kind === "group"
@@ -133,7 +133,7 @@ export class ReviewListComponent extends ChecklistScreen<ReviewEntry> {
133
133
  }
134
134
 
135
135
  protected onEnter(): void {
136
- const entry = this.focused;
136
+ const entry = this.focusedRow;
137
137
  if (entry) this.close({ kind: "diff", path: entry.path });
138
138
  }
139
139
 
@@ -142,7 +142,7 @@ export class ReviewListComponent extends ChecklistScreen<ReviewEntry> {
142
142
  }
143
143
 
144
144
  protected verb(data: string): void {
145
- const entry = this.focused;
145
+ const entry = this.focusedRow;
146
146
  if (!entry) return;
147
147
  if (data === "r") {
148
148
  const paths = this.chosen.size > 0 ? [...this.chosen] : [entry.path];
@@ -174,7 +174,7 @@ export class ReviewListComponent extends ChecklistScreen<ReviewEntry> {
174
174
 
175
175
  protected detailLines(width: number): string[] {
176
176
  const detail: string[] = [];
177
- const focused = this.focused;
177
+ const focused = this.focusedRow;
178
178
  if (focused) detail.push(clip(` ${paint(this.theme, "meta", formatReviewDetail(focused))}`, width));
179
179
  const selectionLine =
180
180
  this.chosen.size > 0