@adeildo/pi-look 5.9.2 → 5.10.1

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/ui/editor.ts CHANGED
@@ -162,7 +162,10 @@ export class LookEditor extends CustomEditor {
162
162
  }
163
163
  }
164
164
 
165
- return result.map((line) => truncateToWidth(line, width, ""));
165
+ // Every line here is drawn to the width already, and measuring is cheaper than cutting.
166
+ return result.map((line) =>
167
+ visibleWidth(line) > width ? truncateToWidth(line, width, "") : line,
168
+ );
166
169
  }
167
170
 
168
171
  #lead(budget: number): string | undefined {
@@ -186,8 +189,11 @@ function withoutSoftwareCursor(line: string): string {
186
189
  }
187
190
 
188
191
  function fill(line: string, width: number): string {
192
+ const cells = visibleWidth(line);
193
+ // Measuring is cheap, and cutting a line that already fits is not.
194
+ if (cells <= width) return line + " ".repeat(width - cells);
189
195
  const clipped = truncateToWidth(line, width, "");
190
- return `${clipped}${" ".repeat(Math.max(0, width - visibleWidth(clipped)))}`;
196
+ return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
191
197
  }
192
198
 
193
199
  export function applyCursor(tui: TUI, style: CursorStyle): void {
package/src/ui/footer.ts CHANGED
@@ -8,6 +8,45 @@ import { type Screen, type SlotName, slotPieces } from "./screen.ts";
8
8
  const GAP = 3;
9
9
  /** Room reserved for the strip, so its changing width never moves the right side between lines. */
10
10
  const STRIP_ROOM = 56;
11
+ /**
12
+ * How long the footer and the strip are drawn again without a reason. What they show moves with the
13
+ * clock, the live tokens and the context, and none of that reads differently 100 ms later, while a
14
+ * keystroke always redraws both. Anything that asks for a redraw drops the drawing anyway.
15
+ */
16
+ const REDRAW_MS = 100;
17
+
18
+ /**
19
+ * A drawing kept per width and per revision of the screen for a moment. The transcript draws every
20
+ * entry on every keystroke, and the footer and the strip were built again each time.
21
+ */
22
+ class KeptDrawing {
23
+ #drawn: { width: number; revision: number; at: number; lines: string[] } | undefined;
24
+
25
+ constructor(
26
+ private readonly screen: Screen,
27
+ private readonly draw: (width: number) => string[],
28
+ ) {}
29
+
30
+ render(width: number): string[] {
31
+ const now = Date.now();
32
+ const revision = this.screen.revision();
33
+ const drawn = this.#drawn;
34
+ if (
35
+ drawn !== undefined &&
36
+ drawn.width === width &&
37
+ drawn.revision === revision &&
38
+ now - drawn.at < REDRAW_MS
39
+ )
40
+ return drawn.lines;
41
+ const lines = this.draw(width);
42
+ this.#drawn = { width, revision, at: now, lines };
43
+ return lines;
44
+ }
45
+
46
+ invalidate(): void {
47
+ this.#drawn = undefined;
48
+ }
49
+ }
11
50
 
12
51
  export function indentOf(screen: Screen): number {
13
52
  const style = screen.frameStyle();
@@ -16,12 +55,22 @@ export function indentOf(screen: Screen): number {
16
55
 
17
56
  export class StripComponent implements Component {
18
57
  readonly #screen: Screen;
58
+ readonly #kept: KeptDrawing;
19
59
 
20
60
  constructor(screen: Screen) {
21
61
  this.#screen = screen;
62
+ this.#kept = new KeptDrawing(screen, (width) => this.#draw(width));
22
63
  }
23
64
 
24
65
  render(width: number): string[] {
66
+ return this.#kept.render(width);
67
+ }
68
+
69
+ invalidate(): void {
70
+ this.#kept.invalidate();
71
+ }
72
+
73
+ #draw(width: number): string[] {
25
74
  const screen = this.#screen;
26
75
  const indent = indentOf(screen);
27
76
  const room = width - indent * 2;
@@ -40,8 +89,6 @@ export class StripComponent implements Component {
40
89
  }
41
90
  return [pad(renderSlot(screen, "strip", room)), pad(renderSlot(screen, "stripRight", room))];
42
91
  }
43
-
44
- invalidate(): void {}
45
92
  }
46
93
 
47
94
  export interface FooterHooks {
@@ -53,15 +100,29 @@ export class FooterComponent implements Component {
53
100
  readonly #screen: Screen;
54
101
  readonly #hooks: FooterHooks;
55
102
  readonly #stopWatching: () => void;
103
+ readonly #kept: KeptDrawing;
56
104
 
57
105
  constructor(tui: TUI, data: ReadonlyFooterDataProvider, screen: Screen, hooks: FooterHooks) {
58
106
  this.#screen = screen;
59
107
  this.#hooks = hooks;
108
+ this.#kept = new KeptDrawing(screen, (width) => this.#draw(width));
60
109
  hooks.attach(data, () => tui.requestRender());
61
- this.#stopWatching = data.onBranchChange(() => tui.requestRender());
110
+ this.#stopWatching = data.onBranchChange(() => {
111
+ // A new branch is drawn now, not on the next keystroke.
112
+ screen.bump();
113
+ tui.requestRender();
114
+ });
62
115
  }
63
116
 
64
117
  render(width: number): string[] {
118
+ return this.#kept.render(width);
119
+ }
120
+
121
+ invalidate(): void {
122
+ this.#kept.invalidate();
123
+ }
124
+
125
+ #draw(width: number): string[] {
65
126
  const screen = this.#screen;
66
127
  const indent = indentOf(screen);
67
128
  const room = width - indent * 2;
@@ -78,8 +139,6 @@ export class FooterComponent implements Component {
78
139
  return kept.length === 0 ? [""] : kept;
79
140
  }
80
141
 
81
- invalidate(): void {}
82
-
83
142
  dispose(): void {
84
143
  this.#stopWatching();
85
144
  this.#hooks.detach();
package/src/ui/header.ts CHANGED
@@ -1,6 +1,11 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ import { CUT, GLYPH } from "@adeildo/pi-kit";
1
4
  import { keyText } from "@earendil-works/pi-coding-agent";
2
5
  import { type Component, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
6
 
7
+ import type { GithubState } from "../data/github.ts";
8
+ import { type LastVisit, since, type StartNote, startNotes } from "../data/start-notes.ts";
4
9
  import { BOXES, border } from "../render/frame.ts";
5
10
  import { EFFORT_LEVELS, type Paint } from "../render/paint.ts";
6
11
  import { renderSegments, type SegmentId } from "../render/segments.ts";
@@ -35,6 +40,10 @@ export interface LoadedCounts {
35
40
  export interface HeaderSource {
36
41
  style(): HeaderStyle;
37
42
  counts(): LoadedCounts;
43
+ /** The last session in this folder, undefined for the first one. */
44
+ lastVisit(): LastVisit | undefined;
45
+ /** What GitHub answered for this folder, undefined while it is still being asked. */
46
+ github?(): GithubState | undefined;
38
47
  }
39
48
 
40
49
  interface Hint {
@@ -42,80 +51,195 @@ interface Hint {
42
51
  does: string;
43
52
  }
44
53
 
54
+ /** The π rises from its feet once per launch, one row of the art every step. */
55
+ const RISE_STEP_MS = 70;
56
+ const NOTES = 6;
57
+ /** How long a drawing of the card stands before it is drawn again. */
58
+ const REDRAW_MS = 1000;
59
+ /** The harness's version, read once from the package that ships it. */
60
+ export const HARNESS_VERSION = harnessVersion();
61
+
62
+ /**
63
+ * The start screen: the π, the folder and the model, and under them only what changed or waits on
64
+ * you since the last session here. See DESIGN.md.
65
+ */
45
66
  export class HeaderComponent implements Component {
46
67
  readonly #screen: Screen;
47
68
  readonly #source: HeaderSource;
69
+ /** When the π started rising. Without a terminal to redraw it, it stands from the start. */
70
+ readonly #born: number;
48
71
 
49
- constructor(screen: Screen, source: HeaderSource) {
72
+ constructor(screen: Screen, source: HeaderSource, tui?: { requestRender(): void }) {
50
73
  this.#screen = screen;
51
74
  this.#source = source;
75
+ this.#born = tui === undefined ? 0 : Date.now();
76
+ if (tui === undefined) return;
77
+ for (let row = 1; row <= LOGO.length; row++)
78
+ setTimeout(() => tui.requestRender(), row * RISE_STEP_MS).unref?.();
52
79
  }
53
80
 
81
+ /** The last drawing, kept for a moment: the card changes rarely and pi redraws on every keystroke. */
82
+ #drawn:
83
+ | { width: number; at: number; github: GithubState | undefined; lines: string[] }
84
+ | undefined;
85
+
54
86
  render(width: number): string[] {
55
- if (width < MIN_CARD || this.#source.style() === "compact") return this.#compact(width);
56
- return this.#card(width);
87
+ const now = Date.now();
88
+ const github = this.#source.github?.();
89
+ const risen = now - this.#born > LOGO.length * RISE_STEP_MS;
90
+ const drawn = this.#drawn;
91
+ // GitHub answers after the card is up, and that answer must not wait for the next keystroke.
92
+ if (risen && drawn?.width === width && drawn.github === github && now - drawn.at < REDRAW_MS)
93
+ return drawn.lines;
94
+ const notes = startNotes(
95
+ this.#screen.snapshot(),
96
+ HARNESS_VERSION,
97
+ this.#source.lastVisit(),
98
+ github,
99
+ );
100
+ const lines =
101
+ width < MIN_CARD || this.#source.style() === "compact"
102
+ ? this.#compact(width, notes)
103
+ : this.#card(width, notes);
104
+ this.#drawn = { width, at: now, github, lines };
105
+ return lines;
57
106
  }
58
107
 
59
- invalidate(): void {}
108
+ invalidate(): void {
109
+ this.#drawn = undefined;
110
+ }
60
111
 
61
- #card(width: number): string[] {
112
+ #card(width: number, notes: readonly StartNote[]): string[] {
62
113
  const screen = this.#screen;
63
114
  const paint = screen.paint();
64
115
  const box = BOXES.rounded;
65
116
  const frame = paint.frame;
66
- const title = this.#title(paint);
67
117
  const inner = width - 4;
68
- const withLogo = inner - LOGO_COLUMN - 3 >= MIN_RIGHT;
69
- const rightWidth = withLogo ? inner - LOGO_COLUMN - 3 : inner;
118
+ const withLogo = inner - LOGO_COLUMN - 2 >= MIN_RIGHT;
119
+ const rightWidth = withLogo ? inner - LOGO_COLUMN - 2 : inner;
120
+ const snapshot = screen.snapshot();
121
+ const last = this.#source.lastVisit();
70
122
 
71
123
  const right = [
72
- this.#segments(["model", "effort"], paint),
124
+ "",
73
125
  this.#segments(["path", "branch"], paint),
74
- this.#loaded(paint),
75
- paint.dim("─".repeat(Math.max(0, rightWidth))),
76
- ...this.#hints(paint, rightWidth),
77
- ];
78
- const left = [
79
- ...LOGO.map((row, index) => paint.effort(LOGO_RAMP[index] ?? "max", row)),
126
+ this.#segments(["model", "effort"], paint),
80
127
  "",
81
- paint.italic(paint.dim("ready when you are")),
128
+ ...this.#notes(notes, paint, last, snapshot.now),
82
129
  ];
130
+ const left = ["", ...this.#logo(paint), ""];
83
131
 
84
132
  const rows = Math.max(left.length, right.length);
85
133
  const lines = [
86
- border(width, [{ text: title, priority: 1 }], [], {
87
- box,
88
- edge: "top",
89
- paint: frame,
90
- separator: " ",
91
- }),
134
+ border(
135
+ width,
136
+ [{ text: this.#title(paint), priority: 1 }],
137
+ [{ text: paint.dim(`pi ${snapshot.version}`), priority: 2 }],
138
+ { box, edge: "top", paint: frame, separator: " " },
139
+ ),
92
140
  ];
93
141
  for (let row = 0; row < rows; row++) {
94
142
  const content = withLogo
95
- ? `${center(left[row] ?? "", LOGO_COLUMN)} ${frame("│")} ${pad(right[row] ?? "", rightWidth)}`
143
+ ? `${center(left[row] ?? "", LOGO_COLUMN)} ${pad(right[row] ?? "", rightWidth)}`
96
144
  : pad(right[row] ?? "", rightWidth);
97
145
  lines.push(`${frame(box.vertical)} ${content} ${frame(box.vertical)}`);
98
146
  }
99
- lines.push(border(width, [], [], { box, edge: "bottom", paint: frame, separator: " " }));
147
+ lines.push(
148
+ border(
149
+ width,
150
+ [
151
+ { text: this.#closing(notes, paint, last, snapshot.now), priority: 1 },
152
+ ...this.#hintPieces(paint),
153
+ ],
154
+ [],
155
+ {
156
+ box,
157
+ edge: "bottom",
158
+ paint: frame,
159
+ separator: " ",
160
+ },
161
+ ),
162
+ );
100
163
  lines.push("");
101
- return lines.map((line) => truncateToWidth(line, width, ""));
164
+ // Every line is drawn to the width already, and measuring is cheaper than cutting.
165
+ return lines.map((line) =>
166
+ visibleWidth(line) > width ? truncateToWidth(line, width, "") : line,
167
+ );
168
+ }
169
+
170
+ /** The art, its rows lit from the feet up while it rises. */
171
+ #logo(paint: Paint): string[] {
172
+ const risen = Math.floor((Date.now() - this.#born) / RISE_STEP_MS) + 1;
173
+ return LOGO.map((row, index) =>
174
+ LOGO.length - index > risen
175
+ ? " ".repeat(row.length)
176
+ : paint.effort(LOGO_RAMP[index] ?? "max", row),
177
+ );
178
+ }
179
+
180
+ #notes(
181
+ notes: readonly StartNote[],
182
+ paint: Paint,
183
+ last: LastVisit | undefined,
184
+ now: number,
185
+ ): string[] {
186
+ if (notes.length === 0)
187
+ return [
188
+ paint.muted(
189
+ last === undefined
190
+ ? "First session in this folder."
191
+ : `Nothing new since ${since(last.at, now)}.`,
192
+ ),
193
+ ];
194
+ const shown = notes.slice(0, NOTES).map((note) => {
195
+ const detail = note.detail === undefined ? "" : ` ${paint.dim(note.detail)}`;
196
+ return `${paint.raw(note.color, note.mark)} ${paint.text(note.text)}${detail}`;
197
+ });
198
+ if (notes.length > NOTES) shown.push(paint.dim(`and ${notes.length - NOTES} more`));
199
+ return shown;
102
200
  }
103
201
 
104
- #compact(width: number): string[] {
202
+ #closing(
203
+ notes: readonly StartNote[],
204
+ paint: Paint,
205
+ last: LastVisit | undefined,
206
+ now: number,
207
+ ): string {
208
+ if (notes.length === 0) return paint.italic(paint.dim("ready when you are"));
209
+ const things = `${notes.length} thing${notes.length === 1 ? "" : "s"}`;
210
+ return paint.raw(
211
+ "warning",
212
+ last === undefined ? things : `${things} since ${since(last.at, now)}`,
213
+ );
214
+ }
215
+
216
+ #hintPieces(paint: Paint): { text: string; priority: number }[] {
217
+ return HINTS()
218
+ .slice(0, 4)
219
+ .map((hint, index) => ({
220
+ text: `${paint.role("brand", hint.key)} ${paint.dim(hint.does)}`,
221
+ priority: 3 + index,
222
+ }));
223
+ }
224
+
225
+ #compact(width: number, notes: readonly StartNote[]): string[] {
105
226
  const paint = this.#screen.paint();
106
227
  const separator = paint.dim(this.#screen.separator());
107
228
  const first = [this.#title(paint), this.#segments(["model", "effort", "path", "branch"], paint)]
108
229
  .filter((part) => part !== "")
109
230
  .join(separator);
110
- const hints = HINTS()
111
- .map((hint) => `${paint.role("brand", hint.key)} ${paint.dim(hint.does)}`)
112
- .join(separator);
113
- return [first, hints, ""].map((line) => truncateToWidth(` ${line}`, width, "..."));
231
+ const last = this.#source.lastVisit();
232
+ return [first, ...this.#notes(notes, paint, last, this.#screen.snapshot().now), ""].map(
233
+ (line) => {
234
+ const padded = ` ${line}`;
235
+ return visibleWidth(padded) > width ? truncateToWidth(padded, width, CUT) : padded;
236
+ },
237
+ );
114
238
  }
115
239
 
116
240
  #title(paint: Paint): string {
117
- const glyph = paint.bold(paint.role("brand", "π"));
118
- return `${glyph} ${paint.bold("pi")} ${paint.dim(`v${this.#screen.snapshot().version}`)}`;
241
+ const glyph = paint.bold(paint.role("brand", GLYPH.pi));
242
+ return `${glyph}${paint.bold("-harness")} ${paint.dim(HARNESS_VERSION)}`;
119
243
  }
120
244
 
121
245
  #segments(ids: readonly SegmentId[], paint: Paint): string {
@@ -127,50 +251,24 @@ export class HeaderComponent implements Component {
127
251
  });
128
252
  return pieces.map((piece) => piece.text).join(paint.dim(this.#screen.separator()));
129
253
  }
254
+ }
130
255
 
131
- #loaded(paint: Paint): string {
132
- const counts = this.#source.counts();
133
- const parts = [
134
- this.#segments(["host"], paint),
135
- ...[
136
- [counts.tools, "tools"],
137
- [counts.skills, "skills"],
138
- [counts.prompts, "prompts"],
139
- [counts.extensions, "extensions"],
140
- ]
141
- .filter(([value]) => (value as number) > 0)
142
- .map(([value, label]) => `${paint.text(String(value))} ${paint.dim(String(label))}`),
143
- ];
144
- return parts.filter((part) => part !== "").join(paint.dim(this.#screen.separator()));
145
- }
146
-
147
- #hints(paint: Paint, width: number): string[] {
148
- const hints = HINTS();
149
- const keyWidth = Math.max(...hints.map((hint) => visibleWidth(hint.key)));
150
- const cell = (hint: Hint) =>
151
- `${paint.role("brand", hint.key.padEnd(keyWidth))} ${paint.dim(hint.does)}`;
152
- const cellWidth = keyWidth + 1 + Math.max(...hints.map((hint) => visibleWidth(hint.does)));
153
- if (width < cellWidth * 2 + 3) return hints.slice(0, 3).map(cell);
154
- const rows: string[] = [];
155
- for (let index = 0; index < hints.length; index += 2) {
156
- const first = hints[index];
157
- const second = hints[index + 1];
158
- if (first === undefined) break;
159
- rows.push(
160
- second === undefined ? cell(first) : `${pad(cell(first), cellWidth)} ${cell(second)}`,
161
- );
162
- }
163
- return rows;
256
+ function harnessVersion(): string {
257
+ try {
258
+ const file = new URL("../../package.json", import.meta.url);
259
+ return (JSON.parse(readFileSync(file, "utf8")) as { version?: string }).version ?? "";
260
+ } catch {
261
+ return "";
164
262
  }
165
263
  }
166
264
 
167
265
  function HINTS(): Hint[] {
168
266
  return [
267
+ { key: "/", does: "commands" },
169
268
  { key: keyText("app.model.select") || "/model", does: "model" },
170
269
  { key: keyText("app.thinking.cycle") || "/thinking", does: "effort" },
171
- { key: "/", does: "commands" },
270
+ { key: "alt+s", does: "settings" },
172
271
  { key: "!", does: "shell" },
173
- { key: keyText("app.editor.external") || "/editor", does: "external editor" },
174
272
  { key: "/tree", does: "session history" },
175
273
  ];
176
274
  }
@@ -192,13 +290,19 @@ export function halfBlocks(pixels: readonly string[]): string[] {
192
290
  }
193
291
 
194
292
  function pad(text: string, width: number): string {
195
- const clipped = truncateToWidth(text, Math.max(0, width), "...");
196
- return `${clipped}${" ".repeat(Math.max(0, width - visibleWidth(clipped)))}`;
293
+ const cells = visibleWidth(text);
294
+ // Measuring is cheap, and cutting a line that already fits is not.
295
+ if (cells <= width) return text + " ".repeat(width - cells);
296
+ const clipped = truncateToWidth(text, width, CUT);
297
+ return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
197
298
  }
198
299
 
199
300
  function center(text: string, width: number): string {
200
- const clipped = truncateToWidth(text, width, "");
201
- const room = Math.max(0, width - visibleWidth(clipped));
301
+ const cells = visibleWidth(text);
302
+ const fits = cells <= width;
303
+ // Measuring is cheap, and cutting a line that already fits is not.
304
+ const clipped = fits ? text : truncateToWidth(text, width, "");
305
+ const room = Math.max(0, width - (fits ? cells : visibleWidth(clipped)));
202
306
  const before = Math.floor(room / 2);
203
307
  return `${" ".repeat(before)}${clipped}${" ".repeat(room - before)}`;
204
308
  }
@@ -0,0 +1,173 @@
1
+ // Alt+O: a tool call's whole output in a frame you scroll, newest call first. Pi only shows a long
2
+ // output cut in the transcript, and Ctrl+O opens every call at once; here one call opens at a time,
3
+ // and the arrows move between calls.
4
+ import { callTarget, CUT, frame, frameInner, FRAME_Y, GLYPH, type KeyHint } from "@adeildo/pi-kit";
5
+ import type { SessionEntry, Theme } from "@earendil-works/pi-coding-agent";
6
+ import {
7
+ type Component,
8
+ Key,
9
+ matchesKey,
10
+ truncateToWidth,
11
+ wrapTextWithAnsi,
12
+ } from "@earendil-works/pi-tui";
13
+
14
+ /** One call and what it printed. */
15
+ export interface Output {
16
+ tool: string;
17
+ /** What it pointed at: the path, the command, the query. */
18
+ target: string;
19
+ lines: string[];
20
+ failed: boolean;
21
+ }
22
+
23
+ /** Every tool call on the branch that printed something, oldest first. */
24
+ export function outputsOf(branch: readonly SessionEntry[]): Output[] {
25
+ const calls = new Map<string, { tool: string; target: string }>();
26
+ const outputs: Output[] = [];
27
+ for (const entry of branch) {
28
+ if (entry.type !== "message") continue;
29
+ const message = entry.message;
30
+ if (message.role === "assistant") {
31
+ for (const part of message.content)
32
+ if (part.type === "toolCall")
33
+ calls.set(part.id, { tool: part.name, target: callTarget(part.arguments) });
34
+ }
35
+ if (message.role !== "toolResult") continue;
36
+ const call = calls.get(message.toolCallId);
37
+ const text = message.content
38
+ .map((part) => (part.type === "text" ? part.text : "[image]"))
39
+ .join("\n")
40
+ .trimEnd();
41
+ if (text === "") continue;
42
+ outputs.push({
43
+ tool: call?.tool ?? message.toolName,
44
+ target: call?.target ?? "",
45
+ lines: text.split("\n"),
46
+ failed: message.isError,
47
+ });
48
+ }
49
+ return outputs;
50
+ }
51
+
52
+ export interface OutputViewOptions {
53
+ theme: Theme;
54
+ outputs: readonly Output[];
55
+ rows: () => number;
56
+ close: () => void;
57
+ }
58
+
59
+ /** The frame: one output at a time, newest first, scrolled with the arrows and the page keys. */
60
+ export class OutputView implements Component {
61
+ readonly #options: OutputViewOptions;
62
+ /** Which output, counted from the newest. */
63
+ #index = 0;
64
+ #scroll = 0;
65
+ #width = 80;
66
+ /** The wrapped lines of what is on screen, wrapped again only when it or the width changes. */
67
+ #wrapped: { output: Output; inner: number; lines: string[] } | undefined;
68
+ /** The last drawing of the frame, drawn again only when a key moved something. */
69
+ #drawn:
70
+ | { output: Output; width: number; scroll: number; page: number; lines: string[] }
71
+ | undefined;
72
+
73
+ constructor(options: OutputViewOptions) {
74
+ this.#options = options;
75
+ }
76
+
77
+ handleInput(data: string): void {
78
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.alt("o")) || data === "q")
79
+ return this.#options.close();
80
+ if (matchesKey(data, Key.up)) return this.#scrollBy(-1);
81
+ if (matchesKey(data, Key.down)) return this.#scrollBy(1);
82
+ if (matchesKey(data, Key.pageUp)) return this.#scrollBy(-this.#page());
83
+ if (matchesKey(data, Key.pageDown)) return this.#scrollBy(this.#page());
84
+ if (matchesKey(data, Key.home)) return this.#scrollBy(-Number.MAX_SAFE_INTEGER);
85
+ if (matchesKey(data, Key.end)) return this.#scrollBy(Number.MAX_SAFE_INTEGER);
86
+ if (matchesKey(data, Key.left)) return this.#move(1);
87
+ if (matchesKey(data, Key.right)) return this.#move(-1);
88
+ }
89
+
90
+ render(width: number): string[] {
91
+ this.#width = width;
92
+ const { theme, outputs } = this.#options;
93
+ const output = this.#current();
94
+ if (output === undefined)
95
+ return frame(theme, width, [theme.fg("muted", "No tool output in this session yet.")], {
96
+ title: "Tool output",
97
+ keys: [["esc", "close"]],
98
+ });
99
+ const lines = this.#lines(output);
100
+ const page = this.#page();
101
+ this.#scroll = Math.min(this.#scroll, Math.max(0, lines.length - page));
102
+ const known = this.#drawn;
103
+ if (
104
+ known !== undefined &&
105
+ known.output === output &&
106
+ known.width === width &&
107
+ known.scroll === this.#scroll &&
108
+ known.page === page
109
+ )
110
+ return known.lines;
111
+
112
+ const shown = lines
113
+ .slice(this.#scroll, this.#scroll + page)
114
+ .map((line) => theme.fg("toolOutput", line));
115
+ const last = Math.min(lines.length, this.#scroll + page);
116
+ const keys: KeyHint[] = [
117
+ ["↑↓", "scroll"],
118
+ ["←→", "calls"],
119
+ ["esc", "close"],
120
+ ];
121
+ const drawn = frame(theme, width, shown, {
122
+ title: this.#title(output),
123
+ status: theme.fg("muted", `${this.#index + 1} of ${outputs.length}`),
124
+ keys,
125
+ footnote: theme.fg("muted", `lines ${this.#scroll + 1}-${last} of ${lines.length}`),
126
+ });
127
+ this.#drawn = { output, width, scroll: this.#scroll, page, lines: drawn };
128
+ return drawn;
129
+ }
130
+
131
+ invalidate(): void {
132
+ this.#wrapped = undefined;
133
+ this.#drawn = undefined;
134
+ }
135
+
136
+ /** The mark, the tool and what it pointed at, cut so the count beside it still fits. */
137
+ #title(output: Output): string {
138
+ const { theme } = this.#options;
139
+ const mark = output.failed ? theme.fg("error", GLYPH.cross) : theme.fg("success", GLYPH.check);
140
+ const room = Math.max(8, Math.floor(this.#width / 2) - output.tool.length);
141
+ const target = truncateToWidth(output.target, room, CUT);
142
+ return `${mark} ${theme.fg("toolTitle", theme.bold(output.tool))} ${theme.fg("muted", target)}`;
143
+ }
144
+
145
+ #current(): Output | undefined {
146
+ const { outputs } = this.#options;
147
+ return outputs[outputs.length - 1 - this.#index];
148
+ }
149
+
150
+ #lines(output: Output): string[] {
151
+ const inner = frameInner(this.#width);
152
+ const known = this.#wrapped;
153
+ if (known !== undefined && known.output === output && known.inner === inner) return known.lines;
154
+ const lines = output.lines.flatMap((line) => wrapTextWithAnsi(line, inner));
155
+ this.#wrapped = { output, inner, lines };
156
+ return lines;
157
+ }
158
+
159
+ #page(): number {
160
+ return Math.max(1, this.#options.rows() - FRAME_Y);
161
+ }
162
+
163
+ #scrollBy(rows: number): void {
164
+ this.#scroll = Math.max(0, this.#scroll + rows);
165
+ }
166
+
167
+ #move(step: number): void {
168
+ const next = this.#index + step;
169
+ if (next < 0 || next >= this.#options.outputs.length) return;
170
+ this.#index = next;
171
+ this.#scroll = 0;
172
+ }
173
+ }