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.
- package/CHANGELOG.md +36 -0
- package/README.md +10 -9
- package/docs/guided-review.md +8 -12
- package/docs/user-guide.md +38 -19
- package/package.json +8 -2
- package/src/advisor.ts +573 -0
- package/src/backend.ts +100 -42
- package/src/bro.ts +37 -2623
- package/src/btw.ts +556 -0
- package/src/config-ui.ts +76 -39
- package/src/doctor.ts +538 -0
- package/src/explain-modal.ts +777 -0
- package/src/modal-kit.ts +258 -0
- package/src/review-modal.ts +95 -48
- package/src/review-ui.ts +57 -47
- package/src/settings.ts +45 -3
- package/src/show.ts +243 -0
- package/src/ui-capabilities.ts +32 -0
package/src/modal-kit.ts
ADDED
|
@@ -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
|
+
}
|
package/src/review-modal.ts
CHANGED
|
@@ -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 {
|
|
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 -
|
|
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
|
|
458
|
-
|
|
459
|
-
if (
|
|
460
|
-
const
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
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
|
|
1090
|
-
const
|
|
1091
|
-
|
|
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(
|
|
1127
|
-
Math.max(0, entries.length -
|
|
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 <
|
|
1188
|
-
const
|
|
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 (
|
|
1196
|
-
if (
|
|
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
|
-
?
|
|
1253
|
+
? isPinned
|
|
1254
|
+
? pinned[i]!
|
|
1255
|
+
: nav
|
|
1202
1256
|
: leftWidth
|
|
1203
|
-
? fit(nav, leftWidth) +
|
|
1204
|
-
|
|
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 +
|
|
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" &&
|