pi-tab-focus 1.0.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/LICENSE ADDED
@@ -0,0 +1,7 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026 pi-tab-focus contributors
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,85 @@
1
+ # pi-tab-focus
2
+
3
+ Crush-style transcript focus mode for Pi. Navigate, select and copy transcript content without leaving the terminal.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ pi install npm:pi-tab-focus
9
+ ```
10
+
11
+ Run Pi in fullscreen mode:
12
+
13
+ ```sh
14
+ pi --tui-mode fullscreen
15
+ ```
16
+
17
+ ### pi-vim
18
+
19
+ `pi-tab-focus` works with Pi's built-in editor and supports `pi-vim`. If you use both, install `pi-vim` first:
20
+
21
+ ```sh
22
+ pi install npm:pi-vim
23
+ pi install npm:pi-tab-focus
24
+ ```
25
+
26
+ The same ordering should be used in `settings.json`.
27
+
28
+ ## Usage
29
+
30
+ Press `Tab` to enter transcript focus. Press `Tab` or `Esc` to return to the editor.
31
+
32
+ | Key | Action |
33
+ | --- | --- |
34
+ | `j` / `↓` | scroll down |
35
+ | `k` / `↑` | scroll up |
36
+ | `u` / `d` | half-page up / down |
37
+ | `Shift+J` / `Shift+↓` | next transcript item |
38
+ | `Shift+K` / `Shift+↑` | previous transcript item |
39
+ | `b` / `PgUp` | page up |
40
+ | `f` / `PgDn` | page down |
41
+ | `g` / `Home` | top |
42
+ | `G` / `End` | bottom |
43
+ | `y` / `c` | copy selected item or visual selection |
44
+ | `v` | visual cursor / character selection |
45
+ | `V` | line selection |
46
+ | `Enter` | open the first link in the selected item; in visual mode, open the link under the cursor |
47
+ | `:` | open EX mode when using `pi-vim` |
48
+ | `Ctrl+O` | toggle tool output |
49
+ | `Ctrl+T` | toggle thinking visibility |
50
+ | `Ctrl+C` | return to editor focus |
51
+ | `Ctrl+D` | shut down Pi |
52
+
53
+ Visual mode supports familiar Vim-style navigation including `h/j/k/l`, word motions, counts, `f/F/t/T`, `gg/G`, paragraph motions and common text objects such as `iw`, `aw` and quoted/bracketed selections.
54
+
55
+ ## Configuration
56
+
57
+ `Tab` is the default focus key. To change it globally, create `~/.pi/agent/pi-tab-focus.json`:
58
+
59
+ ```json
60
+ {
61
+ "focusKey": "f6",
62
+ "hideDefaultScrollIndicator": true
63
+ }
64
+ ```
65
+
66
+ A trusted project can override this with `.pi/pi-tab-focus.json`. Use Pi's normal key format and run `/reload` after changing the config.
67
+
68
+ Prefer modifier or function-key bindings for `focusKey` (for example `ctrl+g` or `f6`). An unmodified printable key such as `f` will intercept normal typing while Pi is in fullscreen mode.
69
+
70
+ Set `hideDefaultScrollIndicator` to `false` if you want to keep Pi's built-in "Jump to latest message" indicator.
71
+
72
+ ## Compatibility
73
+
74
+ Tested with Pi `0.85.1`.
75
+
76
+ Optional `pi-vim` integration is tested with `pi-vim` `0.14.2`.
77
+
78
+ ## Development
79
+
80
+ ```sh
81
+ npm ci
82
+ npm run typecheck
83
+ npm test
84
+ npm pack --dry-run
85
+ ```
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "pi-tab-focus",
3
+ "keywords": [
4
+ "pi-package"
5
+ ],
6
+ "version": "1.0.0",
7
+ "description": "Crush-style fullscreen transcript focus mode for Pi",
8
+ "license": "ISC",
9
+ "type": "module",
10
+ "main": "./src/index.ts",
11
+ "files": [
12
+ "src/*.ts",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "scripts": {
17
+ "test": "node --experimental-strip-types --test \"src/tests/**/*.test.ts\"",
18
+ "typecheck": "tsc --noEmit",
19
+ "prepack": "npm run typecheck && npm test"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "https://github.com/philvernon/pi-tab-focus.git"
24
+ },
25
+ "bugs": {
26
+ "url": "https://github.com/philvernon/pi-tab-focus/issues"
27
+ },
28
+ "homepage": "https://github.com/philvernon/pi-tab-focus#readme",
29
+ "engines": {
30
+ "node": ">=22.19.0"
31
+ },
32
+ "peerDependencies": {
33
+ "@earendil-works/pi-coding-agent": "*",
34
+ "@earendil-works/pi-tui": "*"
35
+ },
36
+ "pi": {
37
+ "extensions": [
38
+ "./src/index.ts"
39
+ ]
40
+ },
41
+ "devDependencies": {
42
+ "@earendil-works/pi-coding-agent": "^0.85.1",
43
+ "@earendil-works/pi-tui": "^0.85.1",
44
+ "typescript": "^6.0.2"
45
+ }
46
+ }
package/src/config.ts ADDED
@@ -0,0 +1,96 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
5
+ import { Key, type KeyId } from "@earendil-works/pi-tui";
6
+
7
+ type ConfigContext = {
8
+ cwd: string;
9
+ isProjectTrusted(): boolean;
10
+ };
11
+
12
+ type ResolvedConfig = {
13
+ focusKey: KeyId;
14
+ hideDefaultScrollIndicator: boolean;
15
+ warnings: string[];
16
+ };
17
+
18
+ type PartialConfig = {
19
+ focusKey?: KeyId;
20
+ hideDefaultScrollIndicator?: boolean;
21
+ warnings: string[];
22
+ };
23
+
24
+ const CONFIG_FILE = "pi-tab-focus.json";
25
+ const DEFAULT_FOCUS_KEY: KeyId = Key.tab;
26
+ const DEFAULT_HIDE_DEFAULT_SCROLL_INDICATOR = true;
27
+
28
+ function readConfig(path: string): PartialConfig {
29
+ if (!existsSync(path)) return { warnings: [] };
30
+
31
+ try {
32
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
33
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
34
+ return {
35
+ warnings: [`Invalid ${CONFIG_FILE}: expected a JSON object (${path}).`],
36
+ };
37
+ }
38
+
39
+ const config = parsed as Record<string, unknown>;
40
+ const result: PartialConfig = { warnings: [] };
41
+
42
+ const focusKey = config.focusKey;
43
+ if (focusKey !== undefined) {
44
+ if (typeof focusKey !== "string" || focusKey.length === 0) {
45
+ result.warnings.push(
46
+ `Invalid focusKey in ${path}; using the previous/default binding.`,
47
+ );
48
+ } else {
49
+ result.focusKey = focusKey as KeyId;
50
+ }
51
+ }
52
+
53
+ const hideDefaultScrollIndicator = config.hideDefaultScrollIndicator;
54
+ if (hideDefaultScrollIndicator !== undefined) {
55
+ if (typeof hideDefaultScrollIndicator !== "boolean") {
56
+ result.warnings.push(
57
+ `Invalid hideDefaultScrollIndicator in ${path}; using the previous/default value.`,
58
+ );
59
+ } else {
60
+ result.hideDefaultScrollIndicator = hideDefaultScrollIndicator;
61
+ }
62
+ }
63
+
64
+ return result;
65
+ } catch (error) {
66
+ const message = error instanceof Error ? error.message : String(error);
67
+ return { warnings: [`Could not read ${path}: ${message}`] };
68
+ }
69
+ }
70
+
71
+ export function resolveConfig(
72
+ ctx: ConfigContext,
73
+ agentDir: string,
74
+ ): ResolvedConfig {
75
+ let focusKey = DEFAULT_FOCUS_KEY;
76
+ let hideDefaultScrollIndicator = DEFAULT_HIDE_DEFAULT_SCROLL_INDICATOR;
77
+ const warnings: string[] = [];
78
+
79
+ const globalConfig = readConfig(join(agentDir, CONFIG_FILE));
80
+ if (globalConfig.focusKey) focusKey = globalConfig.focusKey;
81
+ if (globalConfig.hideDefaultScrollIndicator !== undefined) {
82
+ hideDefaultScrollIndicator = globalConfig.hideDefaultScrollIndicator;
83
+ }
84
+ warnings.push(...globalConfig.warnings);
85
+
86
+ if (ctx.isProjectTrusted()) {
87
+ const projectConfig = readConfig(join(ctx.cwd, CONFIG_DIR_NAME, CONFIG_FILE));
88
+ if (projectConfig.focusKey) focusKey = projectConfig.focusKey;
89
+ if (projectConfig.hideDefaultScrollIndicator !== undefined) {
90
+ hideDefaultScrollIndicator = projectConfig.hideDefaultScrollIndicator;
91
+ }
92
+ warnings.push(...projectConfig.warnings);
93
+ }
94
+
95
+ return { focusKey, hideDefaultScrollIndicator, warnings };
96
+ }
@@ -0,0 +1,459 @@
1
+ import {
2
+ HStack,
3
+ VStack,
4
+ sliceByColumn,
5
+ stripTerminalSequences,
6
+ visibleWidth,
7
+ type Component,
8
+ type TUI,
9
+ } from "@earendil-works/pi-tui";
10
+ import { suppressDefaultScrollIndicator } from "./fullscreen-ui.ts";
11
+ import type { VimPoint } from "./vim-navigation.ts";
12
+
13
+ export type PrivateScrollView = Component & {
14
+ scrollTop: number;
15
+ viewportHeight: number;
16
+ primary?: boolean;
17
+ getContentWidth?: (width: number) => number;
18
+ scrollTo?: (scrollTop: number, options?: { disableFollow?: boolean }) => void;
19
+ };
20
+
21
+ export type NativeSelectionPoint = VimPoint & {
22
+ scrollView?: PrivateScrollView;
23
+ boundary?: boolean;
24
+ };
25
+
26
+ export type NativeSelectionRange = {
27
+ start: NativeSelectionPoint;
28
+ end: NativeSelectionPoint;
29
+ };
30
+
31
+ export type FullscreenTui = TUI & {
32
+ viewportTop?: number;
33
+ scrollBy?: (lines: number) => void;
34
+ scrollToTop?: () => void;
35
+ scrollToBottom?: () => void;
36
+ setLayoutRoot?: (component: Component | undefined) => void;
37
+ getFocusedComponent?: () => Component | null;
38
+ // These fields are private in TuiAltScreen's public type. Access is guarded and
39
+ // limited to fullscreen transcript layout/viewport integration.
40
+ layoutRoot?: Component;
41
+ currentLayout?: {
42
+ root?: { component: Component };
43
+ primaryScrollView?: PrivateScrollView;
44
+ };
45
+ selectionAnchor?: NativeSelectionPoint;
46
+ selectionFocus?: NativeSelectionPoint;
47
+ selectionGranularity?: "character" | "word" | "line";
48
+ selectionInitialRange?: NativeSelectionRange;
49
+ clearTextSelection?: () => void;
50
+ getSelectionSourceLine?: (point: NativeSelectionPoint) => string;
51
+ copyActiveSelectionToClipboard?: () => Promise<boolean>;
52
+ openUrl?: (url: string) => void;
53
+ };
54
+
55
+ export type TranscriptGutterSelection = {
56
+ gutterStartRow: number;
57
+ gutterEndRow: number;
58
+ kind: string;
59
+ };
60
+
61
+ type PrivateStackEntry = {
62
+ component: Component;
63
+ basis?: number | "auto";
64
+ grow?: number;
65
+ shrink?: number;
66
+ minSize?: number;
67
+ maxSize?: number;
68
+ visible?: (viewport: { width: number; height: number }) => boolean;
69
+ };
70
+
71
+ type PrivateStackLayoutNode = {
72
+ type: "vstack" | "hstack";
73
+ entries: PrivateStackEntry[];
74
+ gap: number;
75
+ align: "stretch" | "start" | "center" | "end";
76
+ };
77
+
78
+ type PrivateScrollLayoutNode = {
79
+ type: "scroll";
80
+ component: Component;
81
+ state: PrivateScrollView;
82
+ };
83
+
84
+ type PrivateLayoutNode = PrivateStackLayoutNode | PrivateScrollLayoutNode;
85
+
86
+ type TranscriptLayoutInstallation = {
87
+ originalRoot: Component;
88
+ installedRoot: Component;
89
+ scrollView: PrivateScrollView;
90
+ gutter: TranscriptGutterComponent;
91
+ rendererChildren: Component[];
92
+ };
93
+
94
+ type FullscreenLayoutControllerOptions = {
95
+ getTui: () => FullscreenTui | undefined;
96
+ getCursor: () => VimPoint | undefined;
97
+ isSelecting: () => boolean;
98
+ hideDefaultScrollIndicator: boolean;
99
+ onIntegrationInvalidated?: () => void;
100
+ };
101
+
102
+ const LAYOUT_NODE = Symbol.for("@earendil-works/pi-tui/layout-node");
103
+ const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, {
104
+ granularity: "grapheme",
105
+ });
106
+ const TRANSCRIPT_GUTTER_WIDTH = 1;
107
+ const PROMPT_SELECTION_MARKER = "\x1b[38;2;255;121;198m┃\x1b[39m";
108
+ const RESPONSE_SELECTION_MARKER = "\x1b[38;2;92;196;147m┃\x1b[39m";
109
+
110
+ function privateLayoutNode(
111
+ component: Component,
112
+ ): PrivateLayoutNode | undefined {
113
+ // SAFETY: Pi stores private layout hooks under this symbol on component objects;
114
+ // structural access is guarded by checking the value is callable before use.
115
+ const getter = (component as unknown as Record<symbol, unknown>)[LAYOUT_NODE];
116
+ return typeof getter === "function"
117
+ ? (getter.call(component) as PrivateLayoutNode | undefined)
118
+ : undefined;
119
+ }
120
+
121
+ function graphemeWidthAtColumn(line: string, col: number): number {
122
+ const stripped = stripTerminalSequences(line);
123
+ let currentCol = 0;
124
+
125
+ for (const { segment } of GRAPHEME_SEGMENTER.segment(stripped)) {
126
+ const width = Math.max(0, visibleWidth(segment));
127
+ if (width > 0 && col >= currentCol && col < currentCol + width) return width;
128
+ currentCol += width;
129
+ }
130
+
131
+ return 1;
132
+ }
133
+
134
+ class VisualCursorContentProxy implements Component {
135
+ private readonly component: Component;
136
+ private readonly getCursor: () => VimPoint | undefined;
137
+ private readonly isSelecting: () => boolean;
138
+
139
+ constructor(
140
+ component: Component,
141
+ getCursor: () => VimPoint | undefined,
142
+ isSelecting: () => boolean,
143
+ ) {
144
+ this.component = component;
145
+ this.getCursor = getCursor;
146
+ this.isSelecting = isSelecting;
147
+ }
148
+
149
+ render(width: number): string[] {
150
+ const lines = [...this.component.render(width)];
151
+ const cursor = this.getCursor();
152
+ if (!cursor || this.isSelecting()) return lines;
153
+
154
+ const line = lines[cursor.row] ?? "";
155
+ const lineWidth = visibleWidth(line);
156
+ const col = Math.max(0, Math.min(cursor.col, lineWidth));
157
+ const before = sliceByColumn(line, 0, col, true);
158
+ const cursorWidth =
159
+ col >= lineWidth ? 1 : graphemeWidthAtColumn(line, col);
160
+ const atCursor =
161
+ col >= lineWidth
162
+ ? " "
163
+ : sliceByColumn(line, col, cursorWidth, false) || " ";
164
+ const after = sliceByColumn(
165
+ line,
166
+ col + cursorWidth,
167
+ Math.max(0, lineWidth - col - cursorWidth),
168
+ true,
169
+ );
170
+ lines[cursor.row] = `${before}\x1b[7m${atCursor}\x1b[27m${after}`;
171
+ return lines;
172
+ }
173
+
174
+ invalidate(): void {
175
+ this.component.invalidate?.();
176
+ }
177
+ }
178
+
179
+ class ScrollLayoutProxy implements Component {
180
+ private readonly scrollView: PrivateScrollView;
181
+ private readonly getCursor: () => VimPoint | undefined;
182
+ private readonly isSelecting: () => boolean;
183
+
184
+ constructor(
185
+ scrollView: PrivateScrollView,
186
+ getCursor: () => VimPoint | undefined,
187
+ isSelecting: () => boolean,
188
+ ) {
189
+ this.scrollView = scrollView;
190
+ this.getCursor = getCursor;
191
+ this.isSelecting = isSelecting;
192
+ }
193
+
194
+ render(_width: number): string[] {
195
+ // HStack measures child height before laying it out. Returning no legacy
196
+ // lines avoids a second full transcript render; the delegated scroll layout
197
+ // below still renders the real document exactly once through Pi's layout engine.
198
+ return [];
199
+ }
200
+
201
+ invalidate(): void {
202
+ this.scrollView.invalidate?.();
203
+ }
204
+
205
+ [LAYOUT_NODE](): PrivateLayoutNode | undefined {
206
+ const node = privateLayoutNode(this.scrollView);
207
+ if (!node || node.type !== "scroll") return node;
208
+ return {
209
+ ...node,
210
+ component: new VisualCursorContentProxy(
211
+ node.component,
212
+ this.getCursor,
213
+ this.isSelecting,
214
+ ),
215
+ };
216
+ }
217
+ }
218
+
219
+ class TranscriptGutterComponent implements Component {
220
+ private selected:
221
+ | { startRow: number; endRow: number; kind: string }
222
+ | undefined;
223
+ private readonly getScrollTop: () => number;
224
+ private readonly getRenderRows: () => number;
225
+
226
+ constructor(getScrollTop: () => number, getRenderRows: () => number) {
227
+ this.getScrollTop = getScrollTop;
228
+ this.getRenderRows = getRenderRows;
229
+ }
230
+
231
+ setSelection(item: TranscriptGutterSelection | undefined): void {
232
+ this.selected = item
233
+ ? {
234
+ startRow: item.gutterStartRow,
235
+ endRow: item.gutterEndRow,
236
+ kind: item.kind,
237
+ }
238
+ : undefined;
239
+ }
240
+
241
+ render(_width: number): string[] {
242
+ const rows = Math.max(1, this.getRenderRows());
243
+ const lines = Array.from({ length: rows }, () => "");
244
+ if (!this.selected) return lines;
245
+
246
+ const top = this.getScrollTop();
247
+ const first = Math.max(0, this.selected.startRow - top);
248
+ const last = Math.min(rows, this.selected.endRow - top);
249
+ if (first >= last) return lines;
250
+
251
+ const marker =
252
+ this.selected.kind === "prompt"
253
+ ? PROMPT_SELECTION_MARKER
254
+ : RESPONSE_SELECTION_MARKER;
255
+
256
+ for (let row = first; row < last; row++) lines[row] = marker;
257
+ return lines;
258
+ }
259
+
260
+ invalidate(): void {}
261
+ }
262
+
263
+ export class FullscreenLayoutController {
264
+ private readonly options: FullscreenLayoutControllerOptions;
265
+ private installation: TranscriptLayoutInstallation | undefined;
266
+ private restoreScrollIndicator: (() => void) | undefined;
267
+ private scrollIndicatorRendererChildren: Component[] | undefined;
268
+
269
+ constructor(options: FullscreenLayoutControllerOptions) {
270
+ this.options = options;
271
+ }
272
+
273
+ activeScrollView(): PrivateScrollView | undefined {
274
+ return (
275
+ this.installation?.scrollView ??
276
+ this.options.getTui()?.currentLayout?.primaryScrollView
277
+ );
278
+ }
279
+
280
+ contentWidth(): number {
281
+ const tui = this.options.getTui();
282
+ if (!tui) return 1;
283
+ const terminalWidth = Math.max(
284
+ 1,
285
+ tui.terminal.columns - (this.installation ? TRANSCRIPT_GUTTER_WIDTH : 0),
286
+ );
287
+ return this.activeScrollView()?.getContentWidth?.(terminalWidth) ?? terminalWidth;
288
+ }
289
+
290
+ setSelection(item: TranscriptGutterSelection | undefined): void {
291
+ this.installation?.gutter.setSelection(item);
292
+ }
293
+
294
+ ensure(): boolean {
295
+ const tui = this.options.getTui();
296
+ if (!tui) return false;
297
+
298
+ const installation = this.installation;
299
+ if (installation) {
300
+ const sameRenderer = installation.rendererChildren === tui.children;
301
+ const stillInstalled =
302
+ tui.mode === "fullscreen" &&
303
+ sameRenderer &&
304
+ this.currentLayoutRoot() === installation.installedRoot;
305
+
306
+ if (!stillInstalled) {
307
+ installation.gutter.setSelection(undefined);
308
+ this.installation = undefined;
309
+ this.options.onIntegrationInvalidated?.();
310
+ if (sameRenderer) this.restoreCurrentScrollIndicator();
311
+ else this.abandonScrollIndicatorRestorer();
312
+ }
313
+ } else if (
314
+ this.restoreScrollIndicator &&
315
+ this.scrollIndicatorRendererChildren !== tui.children
316
+ ) {
317
+ this.abandonScrollIndicatorRestorer();
318
+ }
319
+
320
+ if (tui.mode !== "fullscreen") {
321
+ this.restoreCurrentScrollIndicator();
322
+ return false;
323
+ }
324
+
325
+ if (!this.installation && !this.install()) return false;
326
+
327
+ if (!this.restoreScrollIndicator) {
328
+ const restore = suppressDefaultScrollIndicator(
329
+ tui,
330
+ this.options.hideDefaultScrollIndicator,
331
+ );
332
+ if (restore) {
333
+ this.restoreScrollIndicator = restore;
334
+ this.scrollIndicatorRendererChildren = tui.children;
335
+ }
336
+ }
337
+
338
+ return true;
339
+ }
340
+
341
+ restore(): void {
342
+ const installation = this.installation;
343
+ this.installation = undefined;
344
+
345
+ const tui = this.options.getTui();
346
+ if (
347
+ installation &&
348
+ tui?.setLayoutRoot &&
349
+ installation.rendererChildren === tui.children
350
+ ) {
351
+ const currentRoot = this.currentLayoutRoot();
352
+ if (!currentRoot || currentRoot === installation.installedRoot) {
353
+ tui.setLayoutRoot(installation.originalRoot);
354
+ }
355
+ }
356
+
357
+ this.restoreCurrentScrollIndicator();
358
+ }
359
+
360
+ private install(): boolean {
361
+ const tui = this.options.getTui();
362
+ if (!tui || tui.mode !== "fullscreen" || !tui.setLayoutRoot) return false;
363
+ if (this.installation) return true;
364
+
365
+ const originalRoot = this.currentLayoutRoot();
366
+ if (!originalRoot) return false;
367
+
368
+ const rootNode = privateLayoutNode(originalRoot);
369
+ if (!rootNode || rootNode.type !== "vstack") return false;
370
+
371
+ const currentPrimary = tui.currentLayout?.primaryScrollView;
372
+ let transcriptIndex = -1;
373
+ let scrollNode: PrivateScrollLayoutNode | undefined;
374
+
375
+ for (let index = 0; index < rootNode.entries.length; index++) {
376
+ const node = privateLayoutNode(rootNode.entries[index].component);
377
+ if (!node || node.type !== "scroll") continue;
378
+ if (currentPrimary && node.state !== currentPrimary) continue;
379
+ if (!currentPrimary && !node.state.primary) continue;
380
+ transcriptIndex = index;
381
+ scrollNode = node;
382
+ break;
383
+ }
384
+
385
+ if (transcriptIndex < 0 || !scrollNode) return false;
386
+
387
+ const scrollView = scrollNode.state;
388
+ const gutter = new TranscriptGutterComponent(
389
+ () => scrollView.scrollTop,
390
+ () => this.options.getTui()?.terminal.rows ?? 1,
391
+ );
392
+ const transcriptPane = new HStack(
393
+ [
394
+ {
395
+ component: gutter,
396
+ basis: TRANSCRIPT_GUTTER_WIDTH,
397
+ grow: 0,
398
+ shrink: 0,
399
+ minSize: TRANSCRIPT_GUTTER_WIDTH,
400
+ maxSize: TRANSCRIPT_GUTTER_WIDTH,
401
+ },
402
+ {
403
+ component: new ScrollLayoutProxy(
404
+ scrollView,
405
+ this.options.getCursor,
406
+ this.options.isSelecting,
407
+ ),
408
+ basis: 0,
409
+ grow: 1,
410
+ shrink: 1,
411
+ minSize: 1,
412
+ },
413
+ ],
414
+ { align: "stretch" },
415
+ );
416
+ const rootEntries = rootNode.entries.map((entry, index) =>
417
+ index === transcriptIndex
418
+ ? { ...entry, component: transcriptPane }
419
+ : { ...entry },
420
+ );
421
+ const installedRoot = new VStack(rootEntries, {
422
+ gap: rootNode.gap,
423
+ align: rootNode.align,
424
+ });
425
+
426
+ this.installation = {
427
+ originalRoot,
428
+ installedRoot,
429
+ scrollView,
430
+ gutter,
431
+ rendererChildren: tui.children,
432
+ };
433
+ tui.setLayoutRoot(installedRoot);
434
+ return true;
435
+ }
436
+
437
+ private currentLayoutRoot(): Component | undefined {
438
+ const tui = this.options.getTui();
439
+ return tui?.currentLayout?.root?.component ?? tui?.layoutRoot;
440
+ }
441
+
442
+ private restoreCurrentScrollIndicator(): void {
443
+ const tui = this.options.getTui();
444
+ if (
445
+ this.restoreScrollIndicator &&
446
+ tui &&
447
+ this.scrollIndicatorRendererChildren === tui.children
448
+ ) {
449
+ this.restoreScrollIndicator();
450
+ }
451
+ this.restoreScrollIndicator = undefined;
452
+ this.scrollIndicatorRendererChildren = undefined;
453
+ }
454
+
455
+ private abandonScrollIndicatorRestorer(): void {
456
+ this.restoreScrollIndicator = undefined;
457
+ this.scrollIndicatorRendererChildren = undefined;
458
+ }
459
+ }