pi-bro 0.23.1 → 0.24.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/src/review-ui.ts CHANGED
@@ -1,7 +1,5 @@
1
1
  import {
2
2
  matchesKey,
3
- truncateToWidth,
4
- visibleWidth,
5
3
  wrapTextWithAnsi,
6
4
  } from "@earendil-works/pi-tui";
7
5
  import {
@@ -10,8 +8,8 @@ import {
10
8
  type ExtensionCommandContext,
11
9
  } from "@earendil-works/pi-coding-agent";
12
10
  import { join } from "node:path";
13
- import { execute, reviewBackendError } from "./backend.ts";
14
- import { readSettings, selectionForCapability } from "./settings.ts";
11
+ import { execute, reviewBackendNotice } from "./backend.ts";
12
+ import { capabilityBackend, readSettings, selectionForCapability } from "./settings.ts";
15
13
  import {
16
14
  replaceGuide,
17
15
  guideEvidence,
@@ -24,19 +22,22 @@ import {
24
22
  type ReviewRecord,
25
23
  } from "./review.ts";
26
24
  import { captureReview, readEvidence, resolveReviewTarget } from "./review-source.ts";
27
- import { ReviewModal, newReviewView, safeReviewText } from "./review-modal.ts";
25
+ import { REVIEW_OVERLAY, ReviewModal, newReviewView, safeReviewText } from "./review-modal.ts";
26
+ import { formatNotice, modalHeight, openBroOverlay, renderFrame } from "./modal-kit.ts";
28
27
  import { consumeWheel } from "./ui-capabilities.ts";
29
28
 
29
+ export const REVIEW_ACQUIRE_OVERLAY = {
30
+ anchor: "center",
31
+ width: "70%",
32
+ minWidth: 44,
33
+ maxHeight: "60%",
34
+ margin: { top: 1, bottom: 1 },
35
+ } as const;
36
+
30
37
  const activeReviews = new Map<string, AbortController | undefined>();
31
38
  export function stopGuidedReviews(): void {
32
39
  for (const controller of activeReviews.values()) controller?.abort();
33
40
  }
34
- const overlayOptions = {
35
- anchor: "center" as const,
36
- width: "100%" as const,
37
- maxHeight: "100%" as const,
38
- margin: { top: 1, bottom: 1 },
39
- };
40
41
 
41
42
  export async function openGuidedReview(
42
43
  ctx: ExtensionCommandContext,
@@ -74,10 +75,12 @@ export async function openGuidedReview(
74
75
  if (!picked) return;
75
76
  record = records[labels.indexOf(picked)];
76
77
  } else {
77
- record = await ctx.ui.custom<ReviewRecord | undefined>(
78
+ record = await openBroOverlay<ReviewRecord | undefined>(
79
+ ctx,
78
80
  (tui, theme, _keys, done) => {
79
81
  let controller = new AbortController();
80
82
  let message = "Resolving PR identity…";
83
+ let advisory: string | undefined;
81
84
  let failed = false;
82
85
  let retryable = true;
83
86
  let startedAt = Date.now();
@@ -95,11 +98,15 @@ export async function openGuidedReview(
95
98
  const acquire = async () => {
96
99
  failed = false;
97
100
  retryable = true;
101
+ advisory = undefined;
98
102
  startedAt = Date.now();
99
103
  controller = new AbortController();
100
104
  message = "Resolving PR identity…";
101
105
  tui.requestRender();
102
106
  try {
107
+ // Resolved before PR identity: the advisory is about the backend that will run the
108
+ // review, so it belongs on a resolution failure too, not only a capture failure.
109
+ advisory = reviewBackendNotice(capabilityBackend(await readSettings(), "review"));
103
110
  const target = await resolveReviewTarget(value, ctx.cwd, controller.signal);
104
111
  if (disposed) return;
105
112
  const id = reviewKey(target);
@@ -125,12 +132,6 @@ export async function openGuidedReview(
125
132
  if (!disposed) done(existing);
126
133
  return;
127
134
  }
128
- const selection = selectionForCapability(await readSettings(), "review");
129
- const backendError = reviewBackendError(selection.backend ?? "agy");
130
- if (backendError) {
131
- retryable = false;
132
- throw Error(backendError);
133
- }
134
135
  message = `${target.repository} #${target.number} · ${target.head.slice(0, 8)}\nFetching captured source…`;
135
136
  tui.requestRender();
136
137
  const snapshot = await captureReview(target, root, controller.signal);
@@ -153,29 +154,22 @@ export async function openGuidedReview(
153
154
  void acquire();
154
155
  return {
155
156
  render(width: number) {
156
- const inner = Math.max(1, width - 2);
157
- const height = Math.max(1, tui.terminal.rows - 2);
158
- const border = "─".repeat(inner);
159
- const row = (text: string) => {
160
- const line = truncateToWidth(text, inner, "…");
161
- return `│${line}${" ".repeat(Math.max(0, inner - visibleWidth(line)))}│`;
162
- };
163
- const body = wrapTextWithAnsi(safeReviewText(message), inner).slice(
164
- 0,
165
- Math.max(1, height - 4),
166
- );
167
- const lines = [
168
- `╭${border}╮`,
169
- row(
170
- `Bro · Review${failed ? "" : ` · ${Math.floor((Date.now() - startedAt) / 1000)}s`}`,
171
- ),
172
- ...body.map(row),
173
- row(failed ? (retryable ? "Esc close · Enter retry" : "Esc close") : "Esc cancel"),
174
- `╰${border}╯`,
175
- ];
176
- return lines
177
- .slice(0, height)
178
- .map((line) => theme.fg("accent", truncateToWidth(line, width, "")));
157
+ const height = modalHeight(tui, REVIEW_ACQUIRE_OVERLAY);
158
+ const hints = failed ? (retryable ? "Enter retry · Esc close" : "Esc close") : "Esc cancel";
159
+ return renderFrame(theme, width, height, {
160
+ header: () =>
161
+ theme.fg("accent", theme.bold("Bro · Review")) +
162
+ (failed ? "" : theme.fg("dim", ` · ${Math.floor((Date.now() - startedAt) / 1000)}s`)),
163
+ body: (_rows, innerWidth) =>
164
+ wrapTextWithAnsi(safeReviewText(message), innerWidth).map((line) =>
165
+ failed ? theme.fg("error", line) : theme.fg("dim", line),
166
+ ),
167
+ // An advisory is information, not a failure: keep it visible even when the fetch fails.
168
+ notice: advisory ? formatNotice(theme, advisory, "warning") : undefined,
169
+ hints,
170
+ fill: false,
171
+ minBody: 1,
172
+ });
179
173
  },
180
174
  invalidate() {},
181
175
  handleMouse: consumeWheel,
@@ -195,7 +189,7 @@ export async function openGuidedReview(
195
189
  },
196
190
  };
197
191
  },
198
- { overlay: true, overlayOptions },
192
+ { overlay: REVIEW_ACQUIRE_OVERLAY },
199
193
  );
200
194
  }
201
195
  if (!record) return;
@@ -207,7 +201,8 @@ export async function openGuidedReview(
207
201
  activeReviews.set(state.id, undefined);
208
202
  let overlayHandle: { setHidden(hidden: boolean): void } | undefined;
209
203
  try {
210
- await ctx.ui.custom<void>(
204
+ await openBroOverlay<void>(
205
+ ctx,
211
206
  (tui, theme, _keys, done) => {
212
207
  let controller: AbortController | undefined;
213
208
  let running: Promise<void> | undefined;
@@ -309,7 +304,9 @@ export async function openGuidedReview(
309
304
  await persist();
310
305
  const settings = await readSettings();
311
306
  const selection = selectionForCapability(settings, "review");
312
- panel.notice = `${question ? "Answering" : regenerate ? "Regenerating guide" : "Preparing guide"} · ${selection.backend ?? "agy"}`;
307
+ const backend = capabilityBackend(settings, "review");
308
+ panel.advisory = reviewBackendNotice(backend) ?? "";
309
+ panel.notice = `${question ? "Answering" : regenerate ? "Regenerating guide" : "Preparing guide"} · ${backend}`;
313
310
  panel.stage = {
314
311
  step: "investigate",
315
312
  detail: question ? "Considering your question" : "Inspecting captured changes",
@@ -474,6 +471,14 @@ export async function openGuidedReview(
474
471
  changed,
475
472
  });
476
473
  void hydrateEvidence();
474
+ // Also on reopen: the advisory is about the selected backend, not about a new turn.
475
+ void readSettings().then(
476
+ (settings) => {
477
+ panel.advisory = reviewBackendNotice(capabilityBackend(settings, "review")) ?? "";
478
+ if (!closed) tui.requestRender();
479
+ },
480
+ () => {},
481
+ );
477
482
  if (fresh)
478
483
  queueMicrotask(() => {
479
484
  if (!closed && !closing) running = run("whole review");
@@ -509,8 +514,9 @@ export async function openGuidedReview(
509
514
  };
510
515
  },
511
516
  {
512
- overlay: true,
513
- overlayOptions,
517
+ overlay: REVIEW_OVERLAY,
518
+ // Regular-mode wheel scrolling; released while hidden behind the regenerate confirm.
519
+ mouse: true,
514
520
  onHandle: (handle) => {
515
521
  overlayHandle = handle;
516
522
  },
@@ -34,16 +34,23 @@ export async function insertBroDesktopText(ctx: UiContext, text: string): Promis
34
34
  /** Only terminal mode uses synchronous insertion; Desktop uses insertBroDesktopText instead. */
35
35
  export function canBroInsertIntoEditor(ctx: UiContext): boolean { return ctx.mode === "tui"; }
36
36
 
37
+ /** Any raw SGR mouse report; regular-mode reporting delivers these to handleInput. */
38
+ export const SGR_MOUSE = /^\x1b\[<(\d+);(\d+);(\d+)[Mm]$/;
39
+
40
+ /** A raw SGR wheel report (Pi 0.84 and regular mode): ±3 lines at a 1-based terminal cell. */
41
+ export function rawWheel(data: string): { delta: number; column: number; row: number } | undefined {
42
+ const match = SGR_MOUSE.exec(data);
43
+ const button = match ? Number.parseInt(match[1]!, 10) : 0;
44
+ if ((button & 64) === 0 || (button & 3) > 1) return undefined;
45
+ return { delta: button & 1 ? 3 : -3, column: Number(match![2]), row: Number(match![3]) };
46
+ }
47
+
37
48
  export function wheelDelta(data: string): number {
38
- const match = /^\x1b\[<(\d+);\d+;\d+[Mm]$/.exec(data);
39
- if (!match) return 0;
40
- const button = Number.parseInt(match[1], 10);
41
- if ((button & 64) === 0) return 0;
42
- return (button & 3) === 0 ? -3 : (button & 3) === 1 ? 3 : 0;
49
+ return rawWheel(data)?.delta ?? 0;
43
50
  }
44
51
 
45
52
  /** Structural subset of pi-tui's mouse event; Pi 0.84 declares no mouse types. */
46
- export type BroMouseEvent = { type: string; wheelDelta?: number };
53
+ export type BroMouseEvent = { type: string; x?: number; y?: number; wheelDelta?: number };
47
54
  export type BroMouseResult = { handled: boolean; render: boolean } | undefined;
48
55
 
49
56
  /** Pi 1.2+ scrolls the chat behind an overlay whose handleMouse leaves a wheel unhandled. */