pi-bro 0.23.0 → 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.
@@ -0,0 +1,258 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { stripVTControlCharacters } from "node:util";
3
+ import { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component, type OverlayHandle, type OverlayOptions, type TUI } from "@earendil-works/pi-tui";
4
+ import { broModalRows, consumeWheel, rawWheel, setRegularMouseReporting, SGR_MOUSE, type BroMouseEvent, type BroMouseResult } from "./ui-capabilities.ts";
5
+
6
+ // Shared building blocks for Bro's overlays; see docs/plans/modal-consistency.md.
7
+
8
+ type Mounted = Component & { dispose?(): void };
9
+ export type Area = { top: number; left: number; width: number; height: number };
10
+ export type Theme = ExtensionCommandContext["ui"]["theme"];
11
+ export type TuiLike = {
12
+ readonly mode: "regular" | "fullscreen";
13
+ readonly terminal?: { rows?: number; columns?: number; write?: (data: string) => void };
14
+ requestRender(): void;
15
+ };
16
+
17
+ export type NoticeTone = "info" | "success" | "warning" | "error";
18
+
19
+ /**
20
+ * Format a notice text with the semantic tone.
21
+ * Strips raw terminal control characters from the message to prevent escape sequence injection.
22
+ */
23
+ export function formatNotice(theme: Theme, text: string, tone: NoticeTone = "info"): string {
24
+ const sanitized = stripVTControlCharacters(text)
25
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
26
+ .trim();
27
+ if (!sanitized) return "";
28
+ const color = tone === "error" ? "error" : tone === "warning" ? "warning" : tone === "success" ? "success" : "accent";
29
+ return theme.fg(color, sanitized);
30
+ }
31
+
32
+ /** One overlay preset for Bro's windows. Drawn heights come from the same numbers via modalHeight. */
33
+ export const BRO_OVERLAY = {
34
+ width: "78%",
35
+ minWidth: 48,
36
+ maxHeight: "78%",
37
+ anchor: "top-center",
38
+ margin: { top: 1, left: 2, right: 2 },
39
+ } as const;
40
+
41
+ type HeightOptions = { readonly maxHeight: `${number}%`; readonly margin: { readonly top?: number; readonly bottom?: number } };
42
+
43
+ /** Rows the host lets the overlay show; mirrors pi-tui's resolveOverlayLayout (floor, clamp to margins, min 1). */
44
+ export function modalHeight(tui: unknown, overlay: HeightOptions = BRO_OVERLAY, cap = Infinity): number {
45
+ const rows = broModalRows(tui, process.stdout.rows);
46
+ const percent = Math.floor((rows * Number.parseFloat(overlay.maxHeight)) / 100);
47
+ const available = Math.max(1, rows - (overlay.margin.top ?? 0) - (overlay.margin.bottom ?? 0));
48
+ return Math.max(1, Math.min(percent, available, cap));
49
+ }
50
+
51
+ /** Terminal-cell bounds for the rendered top-centred Bro overlay. */
52
+ export function broOverlayArea(tui: TuiLike, width: number, height: number): Area {
53
+ const columns = tui.terminal?.columns;
54
+ const centered = typeof columns === "number" ? Math.floor((columns - width) / 2) : 0;
55
+ return {
56
+ top: BRO_OVERLAY.margin.top,
57
+ left: Math.max(BRO_OVERLAY.margin.left, centered),
58
+ width,
59
+ height,
60
+ };
61
+ }
62
+
63
+ export type FrameParts = {
64
+ /** Header line, already styled; called after `body` so it can show the scroll position. */
65
+ header: () => string;
66
+ /** Called with the rows left for the body; return at most that many lines. */
67
+ body: (rows: number, innerWidth: number) => string[];
68
+ /** Lines under the body, e.g. a composer; truncated to the inner width, so wrap long text first. */
69
+ footer?: string[];
70
+ /** Already styled; wrapped to at most three lines, above the hints. Put the action first. */
71
+ notice?: string;
72
+ /** Omit when the body prints its own hints (config's SettingsList). */
73
+ hints?: string;
74
+ /** false: size to the content instead of padding the body to the full height. */
75
+ fill?: boolean;
76
+ /** Body rows the notice must leave free; it shrinks to one line before taking them. */
77
+ minBody?: number;
78
+ };
79
+
80
+ /**
81
+ * Square frame that never renders more than `height` lines (exactly `height` unless `fill` is false).
82
+ * Pi clips overlays from the bottom, so when space runs out the notice shrinks to `minBody`, the
83
+ * body shrinks, then lines go from the top of the frame's middle (header, rules, notice), keeping
84
+ * the footer (e.g. a composer), the hints and both borders.
85
+ */
86
+ export function renderFrame(theme: Theme, width: number, height: number, parts: FrameParts): string[] {
87
+ const inner = Math.max(1, width - 2);
88
+ const border = (text: string) => theme.fg("border", text);
89
+ const row = (content: string) => {
90
+ const line = truncateToWidth(content, inner, "");
91
+ return `${border("│")}${line}${" ".repeat(Math.max(0, inner - visibleWidth(line)))}${border("│")}`;
92
+ };
93
+ const rule = border(`├${"─".repeat(inner)}┤`);
94
+ const footer = parts.footer ?? [];
95
+ // Wrapped, not truncated: Esc comes last in every hint line and must not be cut off.
96
+ const hints = parts.hints ? wrapTextWithAnsi(theme.fg("dim", parts.hints), inner) : [];
97
+ const notice = parts.notice ? wrapTextWithAnsi(parts.notice, inner).slice(0, 3) : [];
98
+ while (notice.length > 1 && height - 5 - footer.length - hints.length - notice.length < (parts.minBody ?? 0)) notice.pop();
99
+ // The notice sits above the footer so it is dropped before a composer when space runs out.
100
+ const below = [...notice, ...footer, ...hints];
101
+ const bodyRows = Math.max(0, height - 5 - below.length);
102
+ const body = bodyRows ? parts.body(bodyRows, inner).slice(0, bodyRows) : [];
103
+ if (parts.fill !== false) while (body.length < bodyRows) body.push("");
104
+ const middle = [row(parts.header()), rule, ...(bodyRows ? [...body.map(row), rule] : []), ...below.map(row)];
105
+ return [border(`┌${"─".repeat(inner)}┐`), ...middle.slice(-Math.max(0, height - 2)), border(`└${"─".repeat(inner)}┘`)].slice(0, height);
106
+ }
107
+
108
+ /** Line-based scrolling for a body: keys, both wheel paths and an ↑/↓ indicator. */
109
+ export class Scroller {
110
+ private offset = 0;
111
+ private max = 0;
112
+ private rows = 1;
113
+ private follow = false;
114
+
115
+ /** `followEnd`: scrolling to the end pins the view to new text until the reader scrolls up. */
116
+ constructor(
117
+ private readonly onScroll: () => void,
118
+ private readonly options: { followEnd?: boolean } = {},
119
+ ) {}
120
+
121
+ view(lines: string[], rows: number): string[] {
122
+ this.rows = Math.max(1, rows);
123
+ this.max = Math.max(0, lines.length - this.rows);
124
+ this.offset = this.follow ? this.max : Math.max(0, Math.min(this.offset, this.max));
125
+ return lines.slice(this.offset, this.offset + this.rows);
126
+ }
127
+
128
+ by(delta: number): void {
129
+ this.offset = Math.max(0, Math.min(this.offset + delta, this.max));
130
+ this.follow = Boolean(this.options.followEnd) && this.offset >= this.max;
131
+ this.onScroll();
132
+ }
133
+
134
+ toStart(): void {
135
+ this.offset = 0;
136
+ this.follow = false;
137
+ }
138
+
139
+ toEnd(): void {
140
+ this.follow = true;
141
+ }
142
+
143
+ /** ↑/↓ one line, PgUp/PgDn one page, and raw SGR mouse input (Pi 0.84, regular mode). */
144
+ handleKey(data: string, area?: Area): boolean {
145
+ if (SGR_MOUSE.test(data)) {
146
+ const wheel = rawWheel(data);
147
+ const x = (wheel?.column ?? 0) - 1;
148
+ const y = (wheel?.row ?? 0) - 1;
149
+ if (
150
+ wheel &&
151
+ (!area || (x >= area.left && x < area.left + area.width && y >= area.top && y < area.top + area.height))
152
+ ) this.by(wheel.delta);
153
+ return true;
154
+ }
155
+ const page = Math.max(1, this.rows - 1);
156
+ const delta =
157
+ (matchesKey(data, "up") ? -1 : matchesKey(data, "down") ? 1 : 0) ||
158
+ (matchesKey(data, "pageUp") ? -page : matchesKey(data, "pageDown") ? page : 0);
159
+ if (!delta) return false;
160
+ this.by(delta);
161
+ return true;
162
+ }
163
+
164
+ /** Pi 1.x delivers the wheel here; it is always consumed so the chat behind never scrolls. */
165
+ handleMouse(event: BroMouseEvent): BroMouseResult {
166
+ if (event.type !== "wheel") return;
167
+ const delta = Math.trunc(event.wheelDelta ?? 0);
168
+ if (delta) this.by(delta);
169
+ return { handled: true, render: false };
170
+ }
171
+
172
+ indicator(): string {
173
+ return this.max > 0 ? `↑${this.offset} ↓${this.max - this.offset}` : "";
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Forward a pointer event to a pi-tui child drawn at `area` inside the overlay, in the child's own
179
+ * coordinates (without `area`, the event already is). Pi 0.84 components have no handleMouse, hence
180
+ * the structural call. A wheel the child leaves unhandled is consumed; presses and drags the child
181
+ * ignores (Editor does) stay unhandled so host text selection keeps working there.
182
+ */
183
+ export function delegateMouse(child: object, event: BroMouseEvent, area?: Area): BroMouseResult {
184
+ const { x = -1, y = -1 } = event;
185
+ const inside = !area || (y >= area.top && y < area.top + area.height && x >= area.left && x < area.left + area.width);
186
+ const local = area ? { ...event, x: x - area.left, y: y - area.top, width: area.width, height: area.height } : event;
187
+ const handle = (child as { handleMouse?: (event: BroMouseEvent) => BroMouseResult }).handleMouse;
188
+ return (inside ? handle?.call(child, local) : undefined) ?? consumeWheel(event);
189
+ }
190
+
191
+ /** Regular-mode mouse reporting held by one overlay; release is idempotent and also runs on exit. */
192
+ export function mouseLease(tui: TuiLike): { acquire(): void; release(): void } {
193
+ let held = false;
194
+ const release = () => {
195
+ if (!held) return;
196
+ held = false;
197
+ process.off("exit", release);
198
+ setRegularMouseReporting(tui, false);
199
+ };
200
+ return {
201
+ acquire() {
202
+ if (held) return;
203
+ held = true;
204
+ // Pi's regular-mode TUI never resets 1000/1006 itself, so a quit with a modal open would leave it on.
205
+ process.once("exit", release);
206
+ setRegularMouseReporting(tui, true);
207
+ },
208
+ release,
209
+ };
210
+ }
211
+
212
+ /**
213
+ * Mount a Bro overlay. `mouse` holds regular-mode reporting until the overlay closes or is disposed,
214
+ * and releases it while the overlay is hidden through the handle passed to `onHandle`.
215
+ */
216
+ export async function openBroOverlay<T>(
217
+ ctx: ExtensionCommandContext,
218
+ factory: (tui: TUI, theme: Theme, keybindings: unknown, done: (result: T) => void) => Mounted,
219
+ options: { mouse?: boolean; overlay?: OverlayOptions; onHandle?: (handle: OverlayHandle) => void } = {},
220
+ ): Promise<T> {
221
+ let lease: ReturnType<typeof mouseLease> | undefined;
222
+ // Once closed or disposed, un-hiding must not turn reporting back on.
223
+ let ended = false;
224
+ try {
225
+ return await ctx.ui.custom<T>(
226
+ (tui, theme, keybindings, done) => {
227
+ const component = factory(tui, theme, keybindings, done);
228
+ if (!options.mouse) return component;
229
+ const held = (lease = mouseLease(tui));
230
+ held.acquire();
231
+ const dispose = component.dispose?.bind(component);
232
+ component.dispose = () => {
233
+ ended = true;
234
+ held.release();
235
+ dispose?.();
236
+ };
237
+ return component;
238
+ },
239
+ {
240
+ overlay: true,
241
+ overlayOptions: options.overlay ?? BRO_OVERLAY,
242
+ onHandle: (handle) =>
243
+ options.onHandle?.({
244
+ ...handle,
245
+ setHidden: (hidden: boolean) => {
246
+ if (hidden) lease?.release();
247
+ else if (!ended) lease?.acquire();
248
+ handle.setHidden(hidden);
249
+ },
250
+ }),
251
+ },
252
+ );
253
+ } finally {
254
+ // Also covers a close before mount, when the host never calls dispose.
255
+ ended = true;
256
+ lease?.release();
257
+ }
258
+ }
@@ -14,6 +14,8 @@ import {
14
14
  type Finding,
15
15
  type ReviewRecord,
16
16
  } from "./review.ts";
17
+ import { modalHeight } from "./modal-kit.ts";
18
+ import { rawWheel, SGR_MOUSE } from "./ui-capabilities.ts";
17
19
 
18
20
  export type ReviewView = {
19
21
  selected: string;
@@ -53,10 +55,18 @@ export type ReviewControls = {
53
55
  changed(): void;
54
56
  copy(text: string): void;
55
57
  };
58
+ // Narrower than the kit's TuiLike/Theme on purpose: Review always reads terminal rows and needs no mode.
56
59
  type Tui = { terminal: { rows: number; columns?: number }; requestRender(): void };
57
60
  // Structural subset keeps normalized mouse support compatible with the pinned pre-mouse SDK.
58
61
  type ReviewMouseEvent = { type: string; x: number; y: number; wheelDelta?: number };
59
62
  type Theme = { fg: (name: any, text: string) => string; bold: (text: string) => string };
63
+ /** Guided Review's full-width overlay; the drawn height comes from it via modalHeight. */
64
+ export const REVIEW_OVERLAY = {
65
+ anchor: "center",
66
+ width: "100%",
67
+ maxHeight: "100%",
68
+ margin: { top: 1, bottom: 1 },
69
+ } as const;
60
70
  export const safeReviewText = (text: string): string =>
61
71
  text.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, "");
62
72
  const label = (text: string) => safeReviewText(text).replace(/[\n\r\t]/g, " ");
@@ -72,6 +82,7 @@ export class ReviewModal {
72
82
  operation: "guide" | "answer" | "regenerate" = "guide";
73
83
  activeTurn: ReviewRecord["turns"][number] | undefined;
74
84
  notice = "";
85
+ advisory = "";
75
86
  saveError = "";
76
87
  evidence = new Map<string, string>();
77
88
  evidenceErrors = new Map<string, string>();
@@ -89,6 +100,7 @@ export class ReviewModal {
89
100
  private layout = {
90
101
  height: 0,
91
102
  bodyRows: 0,
103
+ navRows: 0,
92
104
  leftWidth: 0,
93
105
  navigationOnly: false,
94
106
  contentsStart: 0,
@@ -204,6 +216,13 @@ export class ReviewModal {
204
216
  syncGuideView(): void {
205
217
  if (!this.record.guide?.topics.some((topic) => topic.id === this.view.scope))
206
218
  delete this.view.scope;
219
+ // A regenerated guide can drop the selected topic or finding; fall back rather than leaving
220
+ // the selection pointing at an entry that no longer exists (no marker, stale target).
221
+ if (!this.entries().some((entry) => entry.id === this.view.selected)) {
222
+ this.view.selected = "overview";
223
+ this.view.screen = "reading";
224
+ delete this.view.returnTo;
225
+ }
207
226
  const origin = this.view.returnTo;
208
227
  if (
209
228
  origin &&
@@ -416,9 +435,9 @@ export class ReviewModal {
416
435
  event.y >= this.layout.height
417
436
  )
418
437
  return { handled: true, render: false };
419
- const { bodyRows, leftWidth, navigationOnly, contentsStart, bodyStart } = this.layout;
438
+ const { navRows, leftWidth, navigationOnly, contentsStart, bodyStart } = this.layout;
420
439
  const start = 4;
421
- const rows = bodyRows + bodyStart - 4;
440
+ const rows = this.layout.bodyRows + bodyStart - 4;
422
441
  if (event.y < start || event.y >= start + rows || event.x === 0 || event.x === this.width - 1)
423
442
  return { handled: true, render: false };
424
443
  const delta = Math.trunc(event.wheelDelta ?? 0);
@@ -427,7 +446,7 @@ export class ReviewModal {
427
446
  this.contentsOffset = Math.max(
428
447
  0,
429
448
  Math.min(
430
- Math.max(0, this.entries().length - bodyRows),
449
+ Math.max(0, this.entries().length - navRows),
431
450
  (this.contentsOffset ?? contentsStart) + delta,
432
451
  ),
433
452
  );
@@ -454,20 +473,18 @@ export class ReviewModal {
454
473
  offset === this.maxOffset;
455
474
  }
456
475
  handleInput(data: string): void {
457
- // Pi 0.84 forwards raw wheel input to focused overlays; newer hosts use handleMouse.
458
- const mouse = /^\u001b\[<(\d+);(\d+);(\d+)([Mm])$/.exec(data);
459
- if (mouse) {
460
- const button = Number(mouse[1]);
461
- if (button & 64 && (button & 3) < 2 && mouse[4] === "M") {
462
- const columns = this.tui.terminal.columns;
463
- if (columns !== undefined)
464
- this.handleMouse({
465
- type: "wheel",
466
- x: Number(mouse[2]) - 1 - Math.floor((columns - this.width) / 2),
467
- y: Number(mouse[3]) - 1 - Math.floor((this.tui.terminal.rows - this.layout.height) / 2),
468
- wheelDelta: button & 1 ? 3 : -3,
469
- });
470
- }
476
+ // Pi 0.84 and regular mode deliver raw mouse reports here; Pi 1.x fullscreen uses handleMouse.
477
+ // Every raw report is swallowed so none reaches the composer as text.
478
+ if (SGR_MOUSE.test(data)) {
479
+ const wheel = rawWheel(data);
480
+ const columns = this.tui.terminal.columns;
481
+ if (wheel && columns !== undefined)
482
+ this.handleMouse({
483
+ type: "wheel",
484
+ x: wheel.column - 1 - Math.floor((columns - this.width) / 2),
485
+ y: wheel.row - 1 - Math.floor((this.tui.terminal.rows - this.layout.height) / 2),
486
+ wheelDelta: wheel.delta,
487
+ });
471
488
  return;
472
489
  }
473
490
  if (this.tiny) {
@@ -497,7 +514,9 @@ export class ReviewModal {
497
514
  ]!;
498
515
  } else if (matchesKey(data, "pageDown") || matchesKey(data, "pageUp")) {
499
516
  this.render(this.width);
500
- const delta = (matchesKey(data, "pageDown") ? 1 : -1) * Math.max(1, this.layout.bodyRows - 1);
517
+ // Page by the pane being paged: Contents shows navRows, the reading area shows bodyRows.
518
+ const page = this.focus === "contents" ? this.layout.navRows : this.layout.bodyRows;
519
+ const delta = (matchesKey(data, "pageDown") ? 1 : -1) * Math.max(1, page - 1);
501
520
  if (this.focus === "contents") {
502
521
  const entries = this.entries(),
503
522
  index = entries.findIndex((entry) => entry.id === this.view.selected);
@@ -1022,7 +1041,7 @@ export class ReviewModal {
1022
1041
  }
1023
1042
  render(width: number): string[] {
1024
1043
  this.width = width;
1025
- const height = Math.max(1, Math.min(36, this.tui.terminal.rows - 2));
1044
+ const height = modalHeight(this.tui, REVIEW_OVERLAY, 36);
1026
1045
  const inner = Math.max(1, width - 2);
1027
1046
  const fit = (text: string, size: number) => {
1028
1047
  const line = truncateToWidth(text, size, "…");
@@ -1038,7 +1057,7 @@ export class ReviewModal {
1038
1057
  return [
1039
1058
  border("╭", "╮"),
1040
1059
  row("Resize to at least 36×18"),
1041
- row(this.busy ? "Esc: stop and close" : "Esc: close"),
1060
+ row(this.busy ? "Esc stop and close" : "Esc close"),
1042
1061
  border("╰", "╯"),
1043
1062
  ]
1044
1063
  .slice(0, height)
@@ -1074,21 +1093,49 @@ export class ReviewModal {
1074
1093
  ? wrapTextWithAnsi(buttons.join(" "), detailWidth)
1075
1094
  : buttons.map((button) => " " + button);
1076
1095
  const availableRows = height - (narrow ? 7 : 8) - actionRows.length - (composer ? 1 : 0);
1077
- const sticky =
1078
- !navigationOnly &&
1079
- this.view.screen === "finding" &&
1080
- this.finding &&
1081
- availableRows >= 4 &&
1082
- (this.view.offsets[this.key] ?? 0) > 0
1096
+ const actionGap = actionRows.length && availableRows >= 8 ? 1 : 0;
1097
+ // Middle rows are one budget: the pinned advisory and finding title take detail rows from the
1098
+ // top of it, and the reading area keeps everything else. Navigation renders on every middle
1099
+ // row in the wide layout, so pinned chrome never removes a Contents entry; in the narrow
1100
+ // Contents-only layout the pinned rows print first and navigation fills the remaining rows.
1101
+ const budget = Math.max(0, availableRows - actionGap);
1102
+ const resizeScreen = () =>
1103
+ [
1104
+ border("╭", "╮"),
1105
+ row("Enlarge the terminal to read this review"),
1106
+ row(this.busy ? "Esc stop and close" : "Esc close"),
1107
+ border("╰", "╯"),
1108
+ ]
1109
+ .slice(0, height)
1110
+ .map((line) => truncateToWidth(line, width, ""));
1111
+ const stickyRow =
1112
+ !navigationOnly && budget >= 4 && this.view.screen === "finding" && this.finding && (this.view.offsets[this.key] ?? 0) > 0
1083
1113
  ? truncateToWidth(
1084
1114
  `${this.findingBadge(this.finding)} ${this.theme.bold(label(this.finding.title))}`,
1085
1115
  detailWidth,
1086
1116
  "…",
1087
1117
  )
1088
1118
  : undefined;
1089
- const actionGap = actionRows.length && availableRows >= 8 ? 1 : 0;
1090
- const bodyRows = availableRows - (sticky ? 1 : 0) - actionGap;
1091
- const bodyStart = 4 + (sticky ? 1 : 0);
1119
+ const advisoryLines = this.advisory ? wrapTextWithAnsi(label(this.advisory), detailWidth) : [];
1120
+ const advisoryCount = Math.min(advisoryLines.length, Math.max(0, budget - 1 - (stickyRow ? 1 : 0)));
1121
+ // A viewport that cannot show the advisory plus one reading row (or a pinned title plus one)
1122
+ // shows the resize screen instead of a frame with controls and no review text.
1123
+ if ((advisoryLines.length && advisoryCount === 0) || budget - advisoryCount - (stickyRow ? 1 : 0) < 1)
1124
+ return resizeScreen();
1125
+ const advisoryRows = advisoryLines.slice(0, advisoryCount).map((line, index) =>
1126
+ this.theme.fg(
1127
+ "warning",
1128
+ index === advisoryCount - 1 && advisoryCount < advisoryLines.length
1129
+ ? truncateToWidth(line, Math.max(1, detailWidth - 1), "") + "…"
1130
+ : line,
1131
+ ),
1132
+ );
1133
+ const pinned = [...advisoryRows, ...(stickyRow ? [stickyRow] : [])];
1134
+ const bodyRows = budget - pinned.length;
1135
+ // Wide: every middle row carries a nav entry. Narrow: the pinned rows carry their own text and
1136
+ // navigation starts right after them.
1137
+ const navRows = navigationOnly ? bodyRows : budget;
1138
+ const bodyStart = 4 + pinned.length;
1092
1139
  if (this.isList)
1093
1140
  this.view.row = Math.max(0, Math.min(Math.max(0, this.listLength - 1), this.view.row));
1094
1141
  const body = [
@@ -1102,7 +1149,7 @@ export class ReviewModal {
1102
1149
  ...(this.notice && this.notice !== "Saved" && !this.busy
1103
1150
  ? [
1104
1151
  ...wrapTextWithAnsi(label(this.notice), detailWidth).map((line) =>
1105
- this.theme.fg("accent", line),
1152
+ this.theme.fg(/failed|error|cannot/i.test(this.notice) ? "error" : "accent", line),
1106
1153
  ),
1107
1154
  "",
1108
1155
  ]
@@ -1123,11 +1170,11 @@ export class ReviewModal {
1123
1170
  const start = Math.max(
1124
1171
  0,
1125
1172
  Math.min(
1126
- this.contentsOffset ?? selected - Math.floor(bodyRows / 2),
1127
- Math.max(0, entries.length - bodyRows),
1173
+ this.contentsOffset ?? selected - Math.floor(navRows / 2),
1174
+ Math.max(0, entries.length - navRows),
1128
1175
  ),
1129
1176
  );
1130
- this.layout = { height, bodyRows, leftWidth, navigationOnly, contentsStart: start, bodyStart };
1177
+ this.layout = { height, bodyRows, navRows, leftWidth, navigationOnly, contentsStart: start, bodyStart };
1131
1178
  const target = this.record.snapshot.target;
1132
1179
  const paneRule = (title: string, size: number, active: boolean) =>
1133
1180
  this.theme.fg(
@@ -1184,31 +1231,31 @@ export class ReviewModal {
1184
1231
  ),
1185
1232
  divider,
1186
1233
  ];
1187
- for (let i = 0; i < bodyRows + (sticky ? 1 : 0); i++) {
1188
- const entry = entries[start + i];
1234
+ for (let i = 0; i < budget; i++) {
1235
+ const isPinned = i < pinned.length;
1236
+ // Wide: nav index is the row index, so a pinned row still shows its Contents entry.
1237
+ // Narrow: pinned rows print their own text, so nav starts at the first non-pinned row.
1238
+ const navIndex = navigationOnly ? i - pinned.length : i;
1239
+ const entry = navIndex >= 0 ? entries[start + navIndex] : undefined;
1189
1240
  let nav = entry ? `${entry.id === this.view.selected ? "›" : " "} ${label(entry.title)}` : "";
1190
1241
  if (entry?.id === this.view.selected)
1191
1242
  nav =
1192
1243
  this.focus === "contents"
1193
1244
  ? this.theme.bold(this.theme.fg("accent", nav))
1194
1245
  : this.theme.fg("dim", nav);
1195
- if (i === 0 && start > 0) nav = "▲ " + nav.slice(2);
1196
- if (i === bodyRows + (sticky ? 1 : 0) - 1 && start + i + 1 < entries.length)
1246
+ if (navIndex === 0 && start > 0) nav = "▲ " + nav.slice(2);
1247
+ if (navIndex === navRows - 1 && start + navIndex + 1 < entries.length)
1197
1248
  nav = "▼ " + nav.slice(2);
1249
+ const detail = isPinned ? pinned[i]! : (body[offset + i - pinned.length] ?? "");
1198
1250
  lines.push(
1199
1251
  row(
1200
1252
  navigationOnly
1201
- ? nav
1253
+ ? isPinned
1254
+ ? pinned[i]!
1255
+ : nav
1202
1256
  : leftWidth
1203
- ? fit(nav, leftWidth) +
1204
- this.theme.fg("border", " │ ") +
1205
- fit(
1206
- sticky && i === 0 ? sticky : (body[offset + i - (sticky ? 1 : 0)] ?? ""),
1207
- detailWidth,
1208
- )
1209
- : sticky && i === 0
1210
- ? sticky
1211
- : (body[offset + i - (sticky ? 1 : 0)] ?? ""),
1257
+ ? fit(nav, leftWidth) + this.theme.fg("border", " │ ") + fit(detail, detailWidth)
1258
+ : detail,
1212
1259
  ),
1213
1260
  );
1214
1261
  }
@@ -1239,7 +1286,7 @@ export class ReviewModal {
1239
1286
  ? "Enter actions"
1240
1287
  : "↑↓ read";
1241
1288
  const hint = `${exit} · ${primary} · Tab${narrow ? "" : this.focus === "input" ? " actions" : " focus"}`;
1242
- const more = navigationOnly ? start + bodyRows < entries.length : offset < this.maxOffset;
1289
+ const more = navigationOnly ? start + navRows < entries.length : offset < this.maxOffset;
1243
1290
  const paused =
1244
1291
  this.busy &&
1245
1292
  this.view.screen === "discussion" &&