pi-compact-tools 0.9.2 → 0.10.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,334 @@
1
+ import {
2
+ AssistantMessageComponent,
3
+ CustomMessageComponent,
4
+ DynamicBorder,
5
+ ToolExecutionComponent,
6
+ type ExtensionAPI,
7
+ type ExtensionContext,
8
+ } from "@earendil-works/pi-coding-agent";
9
+ import { Markdown, MouseRegion, Spacer, Text } from "@earendil-works/pi-tui";
10
+ import { hookMethod } from "./compact-tools-hook.ts";
11
+ import type { DisplayMode } from "./compact-tools-types.ts";
12
+ import { captureViewport } from "./compact-tools-viewport.ts";
13
+ import { SilentActivityAnimator } from "./compact-tools-activity.ts";
14
+
15
+ const STATUS_KEY = "compact-tools";
16
+ const WIDGET_KEY = "compact-tools-silent";
17
+ const COMMAND_ARGUMENTS = ["on", "off"] as const;
18
+ export const SILENT_SHORTCUT = "ctrl+'";
19
+ // Symbol.for keys resolve to the same symbol in every evaluation of this module.
20
+ // /reload re-evaluates the module, but the patched prototypes belong to Pi and
21
+ // outlive it, so the flag they read and the patch marker must be shared.
22
+ const STATE_KEY = Symbol.for("pi-compact-tools.silent.state");
23
+ // Versioned: an instance loaded before thinking was hidden may already have
24
+ // wrapped these prototypes in the running process. A new key wraps once more
25
+ // rather than silently keeping the older behavior until Pi restarts.
26
+ const ORIGINAL_RENDER = Symbol.for("pi-compact-tools.silent.originalRender.v2");
27
+ const OVERRIDES_KEY = Symbol.for("pi-compact-tools.silent.overrides.v2");
28
+
29
+ interface SilentState {
30
+ active: boolean;
31
+ enabled: boolean;
32
+ /** Set by /silent so that a /reload keeps the user's choice over the configured mode. */
33
+ overridden: boolean;
34
+ }
35
+
36
+ type Renderable = { render(width: number): string[] };
37
+ type Render = (width: number) => string[];
38
+ export type RenderOverride = (component: any, width: number, render: Render) => string[];
39
+
40
+ type AssistantLike = {
41
+ hasToolCalls?: unknown;
42
+ contentContainer?: { children?: unknown[] };
43
+ };
44
+
45
+ interface RenderOverrides {
46
+ tool: RenderOverride;
47
+ assistant: RenderOverride;
48
+ notice: RenderOverride;
49
+ }
50
+
51
+ function sharedState(): SilentState {
52
+ const holder = globalThis as typeof globalThis & { [STATE_KEY]?: SilentState };
53
+ holder[STATE_KEY] ??= { active: false, enabled: false, overridden: false };
54
+ return holder[STATE_KEY];
55
+ }
56
+
57
+ /**
58
+ * Pi's own pi-tui classes. Inside Pi an extension imports the same copy Pi uses;
59
+ * an install with a second copy (a development checkout, for one) still builds
60
+ * the same classes, so a matching name identifies them there.
61
+ */
62
+ function isKind(child: unknown, kind: abstract new (...args: any[]) => unknown): boolean {
63
+ return child instanceof kind
64
+ || (typeof child === "object" && child !== null && child.constructor?.name === kind.name);
65
+ }
66
+
67
+ /** Whether silent mode is currently hiding the transcript's tool rows. */
68
+ export function isSilent(): boolean {
69
+ const state = sharedState();
70
+ return state.active && state.enabled;
71
+ }
72
+
73
+ /** A turn that hands off to tools is progress, not an answer, however it ended. */
74
+ export function isIntermediateAssistant(component: AssistantLike): boolean {
75
+ return component.hasToolCalls === true;
76
+ }
77
+
78
+ /**
79
+ * Render only an assistant turn's answer text. Pi builds a turn from three kinds
80
+ * of children: each thinking run, shown or collapsed to its label, is a
81
+ * MouseRegion followed by a spacer; the answer is Markdown; and an error, abort,
82
+ * or truncation notice is a spacer followed by a plain Text. The children are
83
+ * swapped only for this one render, so the component and its message stay
84
+ * untouched and reappear in full once silent mode is off.
85
+ */
86
+ export function renderAnswerOnly(component: AssistantLike, width: number, render: Render): string[] {
87
+ const container = component.contentContainer;
88
+ const children = container?.children;
89
+ if (!container || !Array.isArray(children)) return render(width);
90
+ // Most renders have nothing to drop; skip building a filtered copy for them.
91
+ if (!children.some((child) => isKind(child, MouseRegion) || isKind(child, Text))) return render(width);
92
+ const kept: unknown[] = [];
93
+ let afterThinking = false;
94
+ for (const child of children) {
95
+ if (isKind(child, MouseRegion)) {
96
+ afterThinking = true;
97
+ continue;
98
+ }
99
+ if (afterThinking && isKind(child, Spacer)) {
100
+ afterThinking = false;
101
+ continue;
102
+ }
103
+ afterThinking = false;
104
+ if (isKind(child, Text)) {
105
+ if (isKind(kept.at(-1), Spacer)) kept.pop();
106
+ continue;
107
+ }
108
+ kept.push(child);
109
+ }
110
+ if (kept.length === children.length) return render(width);
111
+ // A turn that was only thinking would otherwise leave its leading blank line.
112
+ if (kept.every((child) => isKind(child, Spacer))) return [];
113
+ container.children = kept;
114
+ try {
115
+ return render(width);
116
+ } finally {
117
+ container.children = children;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * A row Pi (or this extension) adds to the chat directly rather than as a message:
123
+ * status lines are Text, and "What's New" blocks add borders and Markdown too.
124
+ */
125
+ function isNotice(child: unknown): boolean {
126
+ return isKind(child, Text) || isKind(child, DynamicBorder) || isKind(child, Markdown);
127
+ }
128
+
129
+ /**
130
+ * Render a transcript container without Pi's own notices. Pi writes warnings,
131
+ * errors, and status lines ("Reloaded …", "Wait for the current response …")
132
+ * straight into the chat as a spacer followed by a plain Text, while every
133
+ * message has a component of its own. Dropping top-level Text rows and the
134
+ * spacer before each leaves the conversation itself untouched.
135
+ */
136
+ export function renderWithoutNotices(container: { children?: unknown[] }, width: number, render: Render): string[] {
137
+ const children = container.children;
138
+ if (!Array.isArray(children) || !children.some(isNotice)) return render(width);
139
+ const kept: unknown[] = [];
140
+ for (const child of children) {
141
+ if (isNotice(child)) {
142
+ if (isKind(kept.at(-1), Spacer)) kept.pop();
143
+ continue;
144
+ }
145
+ kept.push(child);
146
+ }
147
+ if (kept.length === children.length) return render(width);
148
+ container.children = kept;
149
+ try {
150
+ return render(width);
151
+ } finally {
152
+ container.children = children;
153
+ }
154
+ }
155
+
156
+ type TuiLike = { children?: Array<{ children?: unknown[] }> };
157
+
158
+ /**
159
+ * Pi mounts the transcript document first in both regular and fullscreen mode, and
160
+ * its chat is the document's last child, after the header and resource list.
161
+ */
162
+ function hookChatNotices(tui: unknown): void {
163
+ const documentChildren = (tui as TuiLike).children?.[0]?.children;
164
+ const chat = Array.isArray(documentChildren) ? documentChildren.at(-1) : undefined;
165
+ if (typeof chat !== "object" || chat === null || !Array.isArray((chat as { children?: unknown }).children)) return;
166
+ hookMethod(chat, "render", "silent.chatNotices", (self, args, original) => {
167
+ const state = sharedState();
168
+ if (!state.active || !state.enabled) return original.apply(self, args);
169
+ return renderWithoutNotices(self, args[0], (width) => original.call(self, width));
170
+ });
171
+ }
172
+
173
+ /**
174
+ * Pi exposes no API to hide another extension's tool rows, so silent mode wraps
175
+ * the shared component prototypes. Returns false when the host no longer has the
176
+ * expected shape, which leaves the transcript untouched.
177
+ */
178
+ export function patchRender(
179
+ prototype: Renderable | undefined,
180
+ override: RenderOverride,
181
+ /** When this returns false the original render runs directly, with no override and no allocation. */
182
+ engaged: () => boolean = () => true,
183
+ ): boolean {
184
+ if (!prototype || typeof prototype.render !== "function") return false;
185
+ // Checked as an own property: a component that inherits render() from a shared
186
+ // base (CustomMessageComponent uses Container's) must still get its own wrapper.
187
+ if (Object.hasOwn(prototype, ORIGINAL_RENDER)) return true;
188
+ const original = prototype.render;
189
+ prototype.render = function (this: unknown, width: number): string[] {
190
+ if (!engaged()) return original.call(this, width);
191
+ return override(this, width, (nextWidth) => original.call(this, nextWidth));
192
+ };
193
+ Object.defineProperty(prototype, ORIGINAL_RENDER, { value: original });
194
+ return true;
195
+ }
196
+
197
+ function sharedOverrides(): RenderOverrides | undefined {
198
+ return (globalThis as typeof globalThis & { [OVERRIDES_KEY]?: RenderOverrides })[OVERRIDES_KEY];
199
+ }
200
+
201
+ function publishOverrides(): void {
202
+ const state = sharedState();
203
+ const silent = () => state.active && state.enabled;
204
+ (globalThis as typeof globalThis & { [OVERRIDES_KEY]?: RenderOverrides })[OVERRIDES_KEY] = {
205
+ tool: (_component, width, render) => silent() ? [] : render(width),
206
+ // Only the prompt and the answer remain: intermediate turns go entirely, and
207
+ // the final turn keeps its text but not its thinking or any stop notice.
208
+ assistant: (component: AssistantLike, width, render) => {
209
+ if (!silent()) return render(width);
210
+ return isIntermediateAssistant(component) ? [] : renderAnswerOnly(component, width, render);
211
+ },
212
+ // Extension notices such as pi-web-access's "content ready" arrive between
213
+ // tool calls; they are progress, not answers.
214
+ notice: (_component, width, render) => silent() ? [] : render(width),
215
+ };
216
+ }
217
+
218
+ // The patched prototypes look behavior up here on every render, so after a
219
+ // /reload they follow the newly loaded module instead of the one that patched them.
220
+ function delegate(key: keyof RenderOverrides): RenderOverride {
221
+ return (component, width, render) => {
222
+ const override = sharedOverrides()?.[key];
223
+ return override ? override(component, width, render) : render(width);
224
+ };
225
+ }
226
+
227
+ export function parseSilentArgument(args: string, current: boolean): boolean | undefined {
228
+ const argument = args.trim().toLowerCase();
229
+ if (argument === "") return !current;
230
+ if (argument === "on") return true;
231
+ if (argument === "off") return false;
232
+ return undefined;
233
+ }
234
+
235
+ function installPatches(): boolean {
236
+ publishOverrides();
237
+ // Every patch is checked before any is relied on, so a partial host change
238
+ // cannot leave tool rows hidden while their surrounding turns remain visible.
239
+ // Normal mode pays one flag check per render and nothing else.
240
+ const tools = patchRender(ToolExecutionComponent?.prototype, delegate("tool"), isSilent);
241
+ const assistant = patchRender(AssistantMessageComponent?.prototype, delegate("assistant"), isSilent);
242
+ const notices = patchRender(CustomMessageComponent?.prototype, delegate("notice"), isSilent);
243
+ return tools && assistant && notices;
244
+ }
245
+
246
+ /** Hides tool rows, intermediate assistant turns, thinking, and extension notices; the working label carries progress instead. */
247
+ export class SilentModeController {
248
+ private context: ExtensionContext | undefined;
249
+ private readonly activity: SilentActivityAnimator;
250
+
251
+ constructor(pi: ExtensionAPI, private readonly onVisible?: (ctx: ExtensionContext) => void) {
252
+ this.activity = new SilentActivityAnimator(pi);
253
+ pi.registerCommand("silent", {
254
+ description: `(${SILENT_SHORTCUT}) Show only prompts and final answers`,
255
+ getArgumentCompletions: (prefix) => {
256
+ const items = COMMAND_ARGUMENTS
257
+ .filter((value) => value.startsWith(prefix.trim().toLowerCase()))
258
+ .map((value) => ({ value, label: value }));
259
+ return items.length > 0 ? items : null;
260
+ },
261
+ handler: async (args, ctx) => {
262
+ const state = sharedState();
263
+ const enabled = parseSilentArgument(args, state.enabled);
264
+ if (enabled === undefined) {
265
+ ctx.ui.notify("Usage: /silent [on|off]", "warning");
266
+ return;
267
+ }
268
+ this.choose(enabled);
269
+ },
270
+ });
271
+ pi.registerShortcut(SILENT_SHORTCUT, {
272
+ description: "Toggle silent mode",
273
+ handler: () => this.choose(!sharedState().enabled),
274
+ });
275
+ }
276
+
277
+ /** A choice made in the session, which a /reload keeps over the configured mode. */
278
+ private choose(enabled: boolean): void {
279
+ sharedState().overridden = true;
280
+ // No notification: Pi writes notices into the transcript, which is exactly
281
+ // what silent mode keeps clean.
282
+ this.apply(enabled);
283
+ if (!sharedState().enabled && this.context) this.onVisible?.(this.context);
284
+ }
285
+
286
+ isEnabled(): boolean {
287
+ return sharedState().enabled;
288
+ }
289
+
290
+ bind(ctx: ExtensionContext, mode: DisplayMode, reload: boolean): void {
291
+ this.context = ctx;
292
+ this.activity.bind(ctx);
293
+ // An invisible widget is the extension API's way to reach Pi's TUI, and
294
+ // through it the chat container whose notices silent mode hides.
295
+ ctx.ui.setWidget(WIDGET_KEY, (tui) => {
296
+ hookChatNotices(tui);
297
+ return { render: () => [], invalidate() {} };
298
+ }, { placement: "belowEditor" });
299
+ const state = sharedState();
300
+ if (!reload) state.overridden = false;
301
+ this.apply(state.overridden ? state.enabled : mode === "silent");
302
+ }
303
+
304
+ dispose(): void {
305
+ this.activity.dispose();
306
+ // A patched Pi prototype outlives this extension instance. Disable hiding
307
+ // while no instance is bound, but keep the choice for a subsequent /reload.
308
+ sharedState().active = false;
309
+ this.context?.ui.setStatus(STATUS_KEY, undefined);
310
+ this.context?.ui.setWidget(WIDGET_KEY, undefined, { placement: "belowEditor" });
311
+ this.context = undefined;
312
+ }
313
+
314
+ private apply(enabled: boolean): boolean {
315
+ const state = sharedState();
316
+ if (enabled && !installPatches()) {
317
+ state.active = false;
318
+ state.enabled = false;
319
+ this.activity.setEnabled(false);
320
+ this.context?.ui.notify("Silent mode is unavailable in this version of Pi", "warning");
321
+ return false;
322
+ }
323
+ // Hiding or revealing rows above the reader would otherwise move the view.
324
+ if (enabled !== state.enabled) captureViewport();
325
+ state.enabled = enabled;
326
+ this.activity.setEnabled(enabled);
327
+ state.active = this.context !== undefined;
328
+ // Silent mode shows no indicator of its own; the transcript itself is the cue.
329
+ // Clearing the status still requests a render, which is what reveals or hides
330
+ // rows already in the transcript, and removes the label older versions left.
331
+ this.context?.ui.setStatus(STATUS_KEY, undefined);
332
+ return true;
333
+ }
334
+ }
@@ -7,7 +7,6 @@ import {
7
7
  Key,
8
8
  matchesKey,
9
9
  stripTerminalSequences,
10
- truncateToWidth,
11
10
  wrapTextWithAnsi,
12
11
  } from "@earendil-works/pi-tui";
13
12
  import { chromePainter } from "./compact-tools-palette.ts";
@@ -156,16 +155,17 @@ function renderThinkingSections(
156
155
  const styleControlPrefix = options.styleControlPrefix ?? ((prefix: string) => prefix);
157
156
  return sections
158
157
  .map(({ summary, detail }, sectionIndex) => {
159
- const fittedSummary = stripTerminalSequences(
160
- truncateToWidth(summary, Math.max(1, availableWidth), "…"),
161
- );
162
- const title = options.styleSummary?.(fittedSummary, sectionIndex) ?? fittedSummary;
158
+ // The summary is never cut short: a provider that sends one unstructured
159
+ // paragraph has no detail to expand into, so the summary is all there is.
160
+ // The host's Markdown component wraps it to the available width.
161
+ const plain = stripTerminalSequences(summary);
162
+ const title = options.styleSummary?.(plain, sectionIndex) ?? plain;
163
163
  const sectionControls = view === "detail"
164
164
  ? typeof controls === "function" ? controls(detail.length > 0) : controls
165
165
  : undefined;
166
166
  const controlLines = sectionControls
167
- ? wrapTextWithAnsi(sectionControls, Math.max(1, availableWidth - 3)).map(
168
- (line, index) => `${index === 0 ? styleControlPrefix("└─ ") : " "}${line}`,
167
+ ? wrapTextWithAnsi(sectionControls, Math.max(1, availableWidth - 2)).map(
168
+ (line, index) => `${index === 0 ? styleControlPrefix("└ ") : " "}${line}`,
169
169
  )
170
170
  : [];
171
171
  if (view === "detail" && detail) {
@@ -8,9 +8,22 @@ export type ShellToolName = "bash" | "powershell";
8
8
  export type ToolArgs = Record<string, unknown>;
9
9
  export type BuiltInDefinition = ToolDefinition<any, any, any>;
10
10
 
11
+ export const DISPLAY_MODES = ["normal", "silent"] as const;
12
+ export type DisplayMode = (typeof DISPLAY_MODES)[number];
13
+
14
+ export interface CustomToolsConfig {
15
+ /** Render tools registered by other extensions compactly as well. */
16
+ enabled: boolean;
17
+ auto_compact: boolean;
18
+ exclude: string[];
19
+ }
20
+
11
21
  export interface CompactToolsConfig {
22
+ mode: DisplayMode;
12
23
  tools: CompactToolName[];
13
- auto_compact: Record<CompactToolName, boolean>;
24
+ /** Built-ins always have an entry; custom tools may, and otherwise use custom_tools.auto_compact. */
25
+ auto_compact: Record<CompactToolName, boolean> & { [tool: string]: boolean | undefined };
26
+ custom_tools: CustomToolsConfig;
14
27
  previewLines: number;
15
28
  }
16
29
 
@@ -0,0 +1,234 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { getKeybindings, isKeyRelease, isKeyRepeat, matchesKey, stripTerminalSequences, TuiAltScreen, type TUI } from "@earendil-works/pi-tui";
3
+ import { hookMethod } from "./compact-tools-hook.ts";
4
+
5
+ const WIDGET_KEY = "compact-tools-viewport";
6
+ // Shared through globalThis: the TUI outlives a /reload of this module, so the
7
+ // render wrapper installed on it must reach whichever instance is loaded now.
8
+ const STATE_KEY = Symbol.for("pi-compact-tools.viewport.state");
9
+ /** Consecutive lines matched together, so a lone repeated line cannot anchor the view. */
10
+ const ANCHOR_LINES = 2;
11
+ /** How far past the anchor to compare when a short window matches in several places. */
12
+ const TIE_BREAK_LINES = 200;
13
+ /** Pi keybindings that change the height of rows throughout the transcript, with Pi's defaults. */
14
+ const RELAYOUT_BINDINGS = { "app.tools.expand": "ctrl+o", "app.thinking.toggle": "ctrl+t" } as const;
15
+
16
+ interface ScrollViewLike {
17
+ readonly scrollTop: number;
18
+ readonly isFollowingEnd: boolean;
19
+ readonly viewportHeight: number;
20
+ child?: { render(width: number): string[] };
21
+ getContentWidth?(width: number): number;
22
+ updateLayout?(contentHeight: number, viewportHeight: number, requestRender?: () => void): void;
23
+ requestRenderCallback?: () => void;
24
+ scrollTo(scrollTop: number, options?: { disableFollow?: boolean }): void;
25
+ }
26
+
27
+ interface LayoutBox {
28
+ scrollView?: unknown;
29
+ scrollContentLines?: string[];
30
+ rect?: { width: number };
31
+ children?: LayoutBox[];
32
+ }
33
+
34
+ interface TuiLike {
35
+ currentLayout?: { root?: LayoutBox };
36
+ terminal?: { columns?: number };
37
+ getPrimaryScrollView?(): ScrollViewLike | undefined;
38
+ doRender?(...args: unknown[]): unknown;
39
+ }
40
+
41
+ interface Snapshot {
42
+ scrollView: ScrollViewLike;
43
+ columns: number | undefined;
44
+ width: number;
45
+ lines: string[];
46
+ top: number;
47
+ height: number;
48
+ }
49
+
50
+ interface ViewportState {
51
+ tui?: TuiLike;
52
+ pending?: Snapshot;
53
+ restore?: (tui: TuiLike) => void;
54
+ }
55
+
56
+ function sharedState(): ViewportState {
57
+ const holder = globalThis as typeof globalThis & { [STATE_KEY]?: ViewportState };
58
+ holder[STATE_KEY] ??= {};
59
+ return holder[STATE_KEY];
60
+ }
61
+
62
+ function hasText(line: string | undefined): boolean {
63
+ return line !== undefined && stripTerminalSequences(line).trim().length > 0;
64
+ }
65
+
66
+ /** How many lines from `row` in `before` continue unchanged from `start` in `after`. */
67
+ function matchingRun(before: readonly string[], row: number, after: readonly string[], start: number, limit: number): number {
68
+ let length = 0;
69
+ while (length < limit && row + length < before.length && before[row + length] === after[start + length]) length++;
70
+ return length;
71
+ }
72
+
73
+ /**
74
+ * Where to scroll so the text the reader was looking at stays on the same screen
75
+ * row after rows above it grew or shrank. Lines on screen are tried top first,
76
+ * then lines above the viewport, so a row that disappeared hands over to the
77
+ * nearest text that survived. Returns undefined when nothing survived.
78
+ */
79
+ export function findAnchorTop(
80
+ before: readonly string[],
81
+ top: number,
82
+ height: number,
83
+ after: readonly string[],
84
+ ): number | undefined {
85
+ const starts = new Map<string, number[]>();
86
+ after.forEach((line, index) => {
87
+ const list = starts.get(line);
88
+ if (list) list.push(index);
89
+ else starts.set(line, [index]);
90
+ });
91
+ const bottom = Math.min(before.length, top + Math.max(1, height));
92
+ const candidates: number[] = [];
93
+ for (let row = top; row < bottom; row++) candidates.push(row);
94
+ for (let row = top - 1; row >= 0; row--) candidates.push(row);
95
+ for (const row of candidates) {
96
+ const window = before.slice(row, row + ANCHOR_LINES);
97
+ if (window.length < ANCHOR_LINES || !window.some(hasText)) continue;
98
+ // Several places can match a short window; the one that keeps matching for
99
+ // longest is the same text, and distance only breaks a remaining tie.
100
+ let best: number | undefined;
101
+ let bestRun = 0;
102
+ for (const start of starts.get(window[0]!) ?? []) {
103
+ const run = matchingRun(before, row, after, start, TIE_BREAK_LINES);
104
+ if (run < ANCHOR_LINES) continue;
105
+ if (best === undefined || run > bestRun
106
+ || (run === bestRun && Math.abs(start - row) < Math.abs(best - row))) {
107
+ best = start;
108
+ bestRun = run;
109
+ }
110
+ }
111
+ if (best !== undefined) return Math.max(0, best - (row - top));
112
+ }
113
+ return undefined;
114
+ }
115
+
116
+ function findScrollBox(box: LayoutBox | undefined, scrollView: ScrollViewLike): LayoutBox | undefined {
117
+ if (!box) return undefined;
118
+ if (box.scrollView === scrollView) return box;
119
+ for (const child of box.children ?? []) {
120
+ const match = findScrollBox(child, scrollView);
121
+ if (match) return match;
122
+ }
123
+ return undefined;
124
+ }
125
+
126
+ /**
127
+ * Remember what the transcript shows right now, before a change that resizes rows
128
+ * throughout it. The next frame scrolls so the same text stays in place. Pi's
129
+ * fullscreen viewport keeps its offset from the top, so without this, expanding
130
+ * rows above the reader pushes what they were reading out of view.
131
+ */
132
+ export function captureViewport(): void {
133
+ const state = sharedState();
134
+ state.pending = undefined;
135
+ const tui = state.tui;
136
+ const scrollView = tui?.getPrimaryScrollView?.();
137
+ // Following the end already keeps the newest output in view.
138
+ if (!tui || !scrollView || scrollView.isFollowingEnd) return;
139
+ const box = findScrollBox(tui.currentLayout?.root, scrollView);
140
+ if (!box?.scrollContentLines || !box.rect) return;
141
+ state.pending = {
142
+ scrollView,
143
+ columns: tui.terminal?.columns,
144
+ width: scrollView.getContentWidth?.(box.rect.width) ?? box.rect.width,
145
+ lines: box.scrollContentLines.slice(),
146
+ top: scrollView.scrollTop,
147
+ height: scrollView.viewportHeight,
148
+ };
149
+ }
150
+
151
+ function restoreViewport(tui: TuiLike): void {
152
+ const state = sharedState();
153
+ const pending = state.pending;
154
+ state.pending = undefined;
155
+ if (!pending || pending.columns !== tui.terminal?.columns) return;
156
+ const scrollView = pending.scrollView;
157
+ if (scrollView !== tui.getPrimaryScrollView?.() || scrollView.isFollowingEnd || !scrollView.child) return;
158
+ // A relayout leaves scrollTop alone. If it moved, the reader scrolled since the
159
+ // snapshot, and restoring would undo that.
160
+ if (scrollView.scrollTop !== pending.top) return;
161
+ const after = scrollView.child.render(pending.width);
162
+ const top = findAnchorTop(pending.lines, pending.top, pending.height, after);
163
+ if (top === undefined || top === scrollView.scrollTop) return;
164
+ // scrollTo clamps against the previous frame's height; publish the new height first.
165
+ scrollView.updateLayout?.(after.length, scrollView.viewportHeight, scrollView.requestRenderCallback);
166
+ scrollView.scrollTo(top, { disableFollow: true });
167
+ }
168
+
169
+ /**
170
+ * Run the pending restore just before Pi lays out a fullscreen frame, so no frame
171
+ * shows the jump. The hook sits on the fullscreen renderer's class rather than on
172
+ * an instance: extensions only ever see a proxy to the current renderer, and a
173
+ * switch to fullscreen in /settings creates a new one.
174
+ */
175
+ function hookFullscreenRender(): void {
176
+ const prototype = (TuiAltScreen as unknown as { prototype?: object } | undefined)?.prototype;
177
+ if (!prototype) return;
178
+ hookMethod(prototype, "doRender", "viewport.doRender", (self: TuiLike, args, original) => {
179
+ if (sharedState().pending) {
180
+ try {
181
+ restoreViewport(self);
182
+ } catch {
183
+ // Keeping the position is a nicety; never let it block a frame.
184
+ }
185
+ }
186
+ return original.apply(self, args);
187
+ });
188
+ }
189
+
190
+ function isRelayoutKey(data: string): boolean {
191
+ // Kitty terminals also report releases and repeats; only the press relays out.
192
+ if (isKeyRelease(data) || isKeyRepeat(data)) return false;
193
+ const keybindings = getKeybindings();
194
+ return Object.entries(RELAYOUT_BINDINGS).some(([binding, fallback]) =>
195
+ // A host that never registered Pi's app bindings still uses the defaults.
196
+ keybindings.getKeys(binding as never).length > 0
197
+ ? keybindings.matches(data, binding as never)
198
+ : matchesKey(data, fallback));
199
+ }
200
+
201
+ /** Keeps the reader's place when expand, collapse, thinking, or silent mode resize the transcript. */
202
+ export class ViewportKeeper {
203
+ private context: ExtensionContext | undefined;
204
+ private unsubscribe: (() => void) | undefined;
205
+
206
+ bind(ctx: ExtensionContext): void {
207
+ this.dispose();
208
+ this.context = ctx;
209
+ hookFullscreenRender();
210
+ // Earlier versions wrapped a renderer instance directly and call this slot;
211
+ // pointing it at the current restore keeps those in step until Pi restarts.
212
+ sharedState().restore = restoreViewport;
213
+ // An invisible widget is the extension API's way to reach Pi's TUI.
214
+ ctx.ui.setWidget(WIDGET_KEY, (tui: TUI) => {
215
+ sharedState().tui = tui as unknown as TuiLike;
216
+ return { render: () => [], invalidate() {} };
217
+ }, { placement: "belowEditor" });
218
+ this.unsubscribe = ctx.ui.onTerminalInput((data) => {
219
+ if (isRelayoutKey(data)) captureViewport();
220
+ // Observe only: Pi and the thinking controller still handle the key.
221
+ return undefined;
222
+ });
223
+ }
224
+
225
+ dispose(): void {
226
+ this.unsubscribe?.();
227
+ this.unsubscribe = undefined;
228
+ const state = sharedState();
229
+ state.pending = undefined;
230
+ state.tui = undefined;
231
+ this.context?.ui.setWidget(WIDGET_KEY, undefined, { placement: "belowEditor" });
232
+ this.context = undefined;
233
+ }
234
+ }