pi-cc-extensions 0.1.1 → 0.1.3

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.
@@ -1,1317 +1,1318 @@
1
- import { deleteAllKittyImages, isKeyRelease, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
- import { matchesConfiguredShortcut } from "../shortcuts.ts";
3
- import type { FixedEditorClusterRender } from "./cluster.ts";
4
-
5
- export interface TerminalLike {
6
- columns: number;
7
- rows: number;
8
- kittyProtocolActive?: boolean;
9
- write(data: string): void;
10
- }
11
-
12
- interface KeyboardScrollShortcuts {
13
- up: string;
14
- down: string;
15
- }
16
-
17
- export interface RootClickResult {
18
- handled: true;
19
- action?: "expand" | "collapse";
20
- key?: string;
21
- }
22
-
23
- interface TerminalSplitCompositorOptions {
24
- tui: any;
25
- terminal: TerminalLike;
26
- renderCluster: (width: number, terminalRows: number) => FixedEditorClusterRender;
27
- getShowHardwareCursor?: () => boolean;
28
- mouseScroll?: boolean;
29
- keyboardScrollShortcuts?: KeyboardScrollShortcuts;
30
- jumpToBottomShortcut?: string;
31
- onJumpToBottom?: () => void;
32
- onCopySelection?: (text: string) => void;
33
- /** Handle a left click on a fixed-editor cluster line. Return true to consume it. */
34
- onClusterClick?: (line: number, col: number, text: string) => boolean;
35
- /** Handle a left click on a scrollable chat line. Return a result to preserve expansion anchors. */
36
- onRootClick?: (line: number, col: number, text: string) => boolean | RootClickResult;
37
- /** Return true when a scrollable chat line should show hover feedback. */
38
- onRootHover?: (line: number, col: number, text: string) => boolean;
39
- /** Apply stable presentation-only styling to the complete rendered transcript. */
40
- decorateRootLines?: (lines: string[]) => string[];
41
- /** Notify when manual scrolling reaches the newest output. */
42
- onReachRootBottom?: () => void;
43
- }
44
-
45
- interface PatchedRenderable {
46
- render(width: number): string[];
47
- }
48
-
49
- interface RenderPatch {
50
- target: PatchedRenderable;
51
- originalRender: (width: number) => string[];
52
- }
53
-
54
- interface RenderPassCluster {
55
- width: number;
56
- terminalRows: number;
57
- cluster: FixedEditorClusterRender;
58
- }
59
-
60
- type CompositeLineAt = (
61
- baseLine: string,
62
- overlayLine: string,
63
- startCol: number,
64
- overlayWidth: number,
65
- totalWidth: number,
66
- ) => string;
67
-
68
- interface SgrMousePacket {
69
- code: number;
70
- col: number;
71
- row: number;
72
- final: "M" | "m";
73
- }
74
-
75
- interface SelectionPoint {
76
- line: number;
77
- col: number;
78
- }
79
-
80
- type SelectionArea = "root" | "cluster";
81
-
82
- interface SelectionLocation {
83
- area: SelectionArea;
84
- point: SelectionPoint;
85
- }
86
-
87
- interface DisposeOptions {
88
- resetExtendedKeyboardModes?: boolean;
89
- }
90
-
91
- type ExtendedKeyboardMode = "kitty" | "modifyOtherKeys";
92
-
93
- const CONTEXT_MENU_MOUSE_REPORTING_PAUSE_MS = 1200;
94
- const CONTEXT_MENU_SELECTION_RESTORE_WINDOW_MS = 5000;
95
- const CONTEXT_MENU_CLIPBOARD_RESTORE_INTERVAL_MS = 100;
96
- const DOUBLE_CLICK_MS = 500;
97
- const DEFAULT_KEYBOARD_SCROLL_SHORTCUTS: KeyboardScrollShortcuts = {
98
- up: "super+up",
99
- down: "super+down",
100
- };
101
-
102
- export function beginSynchronizedOutput(): string {
103
- return "\x1b[?2026h";
104
- }
105
-
106
- export function endSynchronizedOutput(): string {
107
- return "\x1b[?2026l";
108
- }
109
-
110
- export function setScrollRegion(top: number, bottom: number): string {
111
- return `\x1b[${top};${bottom}r`;
112
- }
113
-
114
- export function resetScrollRegion(): string {
115
- return "\x1b[r";
116
- }
117
-
118
- export function moveCursor(row: number, col: number): string {
119
- return `\x1b[${row};${col}H`;
120
- }
121
-
122
- function clearLine(): string {
123
- return "\x1b[2K";
124
- }
125
-
126
- function hideCursor(): string {
127
- return "\x1b[?25l";
128
- }
129
-
130
- function showCursor(): string {
131
- return "\x1b[?25h";
132
- }
133
-
134
- function enterAlternateScreen(): string {
135
- return "\x1b[?1049h";
136
- }
137
-
138
- function exitAlternateScreen(): string {
139
- return "\x1b[?1049l";
140
- }
141
-
142
- function enableAlternateScrollMode(): string {
143
- return "\x1b[?1007h";
144
- }
145
-
146
- function disableAlternateScrollMode(): string {
147
- return "\x1b[?1007l";
148
- }
149
-
150
- function disableAutoWrap(): string {
151
- return "\x1b[?7l";
152
- }
153
-
154
- function enableAutoWrap(): string {
155
- return "\x1b[?7h";
156
- }
157
-
158
- function enableMouseReporting(): string {
159
- return "\x1b[?1003h\x1b[?1006h";
160
- }
161
-
162
- function disableMouseReporting(): string {
163
- return "\x1b[?1006l\x1b[?1003l\x1b[?1002l\x1b[?1000l";
164
- }
165
-
166
- function enableExtendedKeyboardMode(mode: ExtendedKeyboardMode): string {
167
- return mode === "kitty" ? "\x1b[>7u" : "\x1b[>4;2m";
168
- }
169
-
170
- function disableExtendedKeyboardMode(mode: ExtendedKeyboardMode): string {
171
- return mode === "kitty" ? "\x1b[<u" : "\x1b[>4;0m";
172
- }
173
-
174
- function resetExtendedKeyboardModes(): string {
175
- return "\x1b[<999u\x1b[>4;0m";
176
- }
177
-
178
- export function emergencyTerminalModeReset(): string {
179
- return beginSynchronizedOutput()
180
- + resetScrollRegion()
181
- + disableMouseReporting()
182
- + enableAlternateScrollMode()
183
- + exitAlternateScreen()
184
- + resetExtendedKeyboardModes()
185
- + endSynchronizedOutput();
186
- }
187
-
188
- function parseKeyboardScrollDelta(data: string, shortcuts: KeyboardScrollShortcuts = DEFAULT_KEYBOARD_SCROLL_SHORTCUTS): number {
189
- if (isKeyRelease(data)) return 0;
190
-
191
- if (
192
- matchesConfiguredShortcut(data, shortcuts.up)
193
- || matchesKey(data, "pageUp")
194
- || matchesKey(data, "shift+pageUp")
195
- || matchesKey(data, "ctrl+pageUp")
196
- || matchesKey(data, "ctrl+shift+up")
197
- || /^\x1b\[(?:5;9(?::[12])?~|1;6(?::[12])?A|57421;9(?::[12])?u|57419;6(?::[12])?u)$/.test(data)
198
- ) return 10;
199
- if (
200
- matchesConfiguredShortcut(data, shortcuts.down)
201
- || matchesKey(data, "pageDown")
202
- || matchesKey(data, "shift+pageDown")
203
- || matchesKey(data, "ctrl+pageDown")
204
- || matchesKey(data, "ctrl+shift+down")
205
- || /^\x1b\[(?:6;9(?::[12])?~|1;6(?::[12])?B|57422;9(?::[12])?u|57420;6(?::[12])?u)$/.test(data)
206
- ) return -10;
207
- return 0;
208
- }
209
-
210
- function parseSgrMousePackets(data: string): SgrMousePacket[] | null {
211
- const pattern = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
212
- const packets: SgrMousePacket[] = [];
213
- let offset = 0;
214
-
215
- for (const match of data.matchAll(pattern)) {
216
- if (match.index !== offset) return null;
217
- offset = match.index + match[0].length;
218
- packets.push({
219
- code: Number(match[1]),
220
- col: Number(match[2]),
221
- row: Number(match[3]),
222
- final: match[4] as "M" | "m",
223
- });
224
- }
225
-
226
- return packets.length > 0 && offset === data.length ? packets : null;
227
- }
228
-
229
- function mouseBaseButton(code: number): number {
230
- return code & ~(4 | 8 | 16 | 32);
231
- }
232
-
233
- function mouseScrollDelta(packet: SgrMousePacket): number {
234
- if (packet.final !== "M") return 0;
235
- const baseButton = mouseBaseButton(packet.code);
236
- if (baseButton === 64) return 3;
237
- if (baseButton === 65) return -3;
238
- return 0;
239
- }
240
-
241
- function isLeftPress(packet: SgrMousePacket): boolean {
242
- return packet.final === "M" && mouseBaseButton(packet.code) === 0 && (packet.code & 32) === 0;
243
- }
244
-
245
- function isLeftDrag(packet: SgrMousePacket): boolean {
246
- return packet.final === "M" && mouseBaseButton(packet.code) === 0 && (packet.code & 32) !== 0;
247
- }
248
-
249
- function isMouseMotion(packet: SgrMousePacket): boolean {
250
- return packet.final === "M" && (packet.code & 32) !== 0;
251
- }
252
-
253
- function isRightPress(packet: SgrMousePacket): boolean {
254
- return packet.final === "M" && mouseBaseButton(packet.code) === 2 && (packet.code & 32) === 0;
255
- }
256
-
257
- function isMouseRelease(packet: SgrMousePacket): boolean {
258
- return packet.final === "m";
259
- }
260
-
261
- function stripOscSequences(line: string): string {
262
- return line.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "");
263
- }
264
-
265
- function stripAnsi(line: string): string {
266
- return stripOscSequences(line).replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "");
267
- }
268
-
269
- const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
270
-
271
- function sliceColumns(text: string, startCol: number, endCol: number): string {
272
- let col = 0;
273
- let result = "";
274
- for (const { segment } of graphemeSegmenter.segment(text)) {
275
- const width = Math.max(0, visibleWidth(segment));
276
- if (col >= startCol && col < endCol) {
277
- result += segment;
278
- }
279
- col += width;
280
- }
281
- return result;
282
- }
283
-
284
- function compareSelectionPoints(a: SelectionPoint, b: SelectionPoint): number {
285
- return a.line === b.line ? a.col - b.col : a.line - b.line;
286
- }
287
-
288
- function descriptorForRows(terminal: TerminalLike): PropertyDescriptor | undefined {
289
- let target: object | null = terminal;
290
- while (target) {
291
- const descriptor = Object.getOwnPropertyDescriptor(target, "rows");
292
- if (descriptor) return descriptor;
293
- target = Object.getPrototypeOf(target);
294
- }
295
-
296
- return undefined;
297
- }
298
-
299
- function readRows(terminal: TerminalLike, descriptor: PropertyDescriptor | undefined): number {
300
- if (descriptor?.get) {
301
- const value = descriptor.get.call(terminal);
302
- return typeof value === "number" && Number.isFinite(value) ? value : 24;
303
- }
304
-
305
- const value = Reflect.get(terminal, "rows");
306
- return typeof value === "number" && Number.isFinite(value) ? value : 24;
307
- }
308
-
309
- function sanitizeLine(line: string, width: number): string {
310
- return visibleWidth(line) > width ? truncateToWidth(line, width, "", true) : line;
311
- }
312
-
313
- function sanitizeOverlayBaseLine(line: string, width: number): string {
314
- return sanitizeLine(stripOscSequences(line), width);
315
- }
316
-
317
- function normalizeOverlayCompositionLine(line: string): string {
318
- return line.includes("\t") ? line.replace(/\t/g, " ") : line;
319
- }
320
-
321
- export function buildFixedClusterPaint(
322
- cluster: FixedEditorClusterRender,
323
- terminalRows: number,
324
- width: number,
325
- showHardwareCursor: boolean,
326
- ): string {
327
- if (cluster.lines.length === 0) return "";
328
-
329
- const startRow = Math.max(1, terminalRows - cluster.lines.length + 1);
330
- let buffer = resetScrollRegion();
331
-
332
- for (let i = 0; i < cluster.lines.length; i++) {
333
- buffer += moveCursor(startRow + i, 1);
334
- buffer += clearLine();
335
- buffer += sanitizeLine(cluster.lines[i] ?? "", width);
336
- }
337
-
338
- if (cluster.cursor) {
339
- // Keep the real terminal cursor parked at the logical editor cursor even
340
- // when it is visually hidden. macOS IME candidate windows anchor to the
341
- // terminal cursor position, so skipping the move when PI_HARDWARE_CURSOR is
342
- // off leaves the candidate window at a stale/offset location.
343
- buffer += moveCursor(startRow + cluster.cursor.row, Math.max(1, cluster.cursor.col + 1));
344
- buffer += showHardwareCursor ? showCursor() : hideCursor();
345
- } else {
346
- buffer += hideCursor();
347
- }
348
-
349
- return buffer;
350
- }
351
-
352
- export class TerminalSplitCompositor {
353
- private readonly tui: any;
354
- private readonly terminal: TerminalLike;
355
- private readonly renderCluster: (width: number, terminalRows: number) => FixedEditorClusterRender;
356
- private readonly getShowHardwareCursor: () => boolean;
357
- private readonly mouseScroll: boolean;
358
- private readonly keyboardScrollShortcuts: KeyboardScrollShortcuts;
359
- private readonly jumpToBottomShortcut: string | null;
360
- private readonly onJumpToBottom: (() => void) | null;
361
- private readonly onCopySelection: ((text: string) => void) | null;
362
- private readonly onClusterClick: ((line: number, col: number, text: string) => boolean) | null;
363
- private readonly onRootClick: ((line: number, col: number, text: string) => boolean | RootClickResult) | null;
364
- private readonly onRootHover: ((line: number, col: number, text: string) => boolean) | null;
365
- private readonly decorateRootLines: ((lines: string[]) => string[]) | null;
366
- private readonly onReachRootBottom: (() => void) | null;
367
- private extendedKeyboardMode: ExtendedKeyboardMode | null = null;
368
- private readonly rowsDescriptor: PropertyDescriptor | undefined;
369
- private readonly originalWrite: (data: string) => void;
370
- private readonly originalDoRender: (() => void) | null;
371
- private readonly originalRender: ((width: number) => string[]) | null;
372
- private originalCompositeLineAt: CompositeLineAt | null = null;
373
- private readonly patchedRenders: RenderPatch[] = [];
374
- private removeInputListener: (() => void) | null = null;
375
- private emergencyCleanup: (() => void) | null = null;
376
- private mouseReportingResumeTimer: ReturnType<typeof setTimeout> | null = null;
377
- private clipboardRestoreTimer: ReturnType<typeof setTimeout> | null = null;
378
- private installed = false;
379
- private disposed = false;
380
- private writing = false;
381
- private renderPassActive = false;
382
- private wroteDuringRenderPass = false;
383
- private renderPassCluster: RenderPassCluster | null = null;
384
- private renderingCluster = false;
385
- private renderingScrollableRoot = false;
386
- private checkingOverlay = false;
387
- private scrollOffset = 0;
388
- private maxScrollOffset = 0;
389
- private lastRootLineCount = 0;
390
- private rootLines: string[] = [];
391
- private suppressRootGrowthAdjustment = false;
392
- private readonly toolExpansionStarts = new Map<string, number>();
393
- private visibleRootStart = 0;
394
- private visibleScrollableRows = 0;
395
- private visibleRootLines: string[] = [];
396
- private visibleClusterLines: string[] = [];
397
- private hoveredRootLine: number | null = null;
398
- private selectionArea: SelectionArea | null = null;
399
- private selectionAnchor: SelectionPoint | null = null;
400
- private selectionFocus: SelectionPoint | null = null;
401
- private selectionDragging = false;
402
- private preserveSelectionFocusOnRelease = false;
403
- private lastLeftPress: { area: SelectionArea; line: number; at: number } | null = null;
404
- private consumedClusterClick = false;
405
- private pendingImageCleanup = false;
406
-
407
- constructor(options: TerminalSplitCompositorOptions) {
408
- this.tui = options.tui;
409
- this.terminal = options.terminal;
410
- this.renderCluster = options.renderCluster;
411
- this.getShowHardwareCursor = options.getShowHardwareCursor ?? (() => false);
412
- this.mouseScroll = options.mouseScroll !== false;
413
- this.keyboardScrollShortcuts = options.keyboardScrollShortcuts ?? DEFAULT_KEYBOARD_SCROLL_SHORTCUTS;
414
- this.jumpToBottomShortcut = options.jumpToBottomShortcut ?? null;
415
- this.onJumpToBottom = options.onJumpToBottom ?? null;
416
- this.onCopySelection = options.onCopySelection ?? null;
417
- this.onClusterClick = options.onClusterClick ?? null;
418
- this.onRootClick = options.onRootClick ?? null;
419
- this.onRootHover = options.onRootHover ?? null;
420
- this.decorateRootLines = options.decorateRootLines ?? null;
421
- this.onReachRootBottom = options.onReachRootBottom ?? null;
422
- this.rowsDescriptor = descriptorForRows(options.terminal);
423
- this.originalWrite = options.terminal.write.bind(options.terminal);
424
- this.originalDoRender = typeof options.tui.doRender === "function" ? options.tui.doRender.bind(options.tui) : null;
425
- this.originalRender = typeof options.tui.render === "function" ? options.tui.render.bind(options.tui) : null;
426
- }
427
-
428
- install(): void {
429
- if (this.installed) return;
430
- if (typeof this.terminal.write !== "function") {
431
- throw new Error("[powerline-footer] Fixed editor compositor expected terminal.write(data) to exist");
432
- }
433
-
434
- this.originalWrite(
435
- beginSynchronizedOutput()
436
- + enterAlternateScreen()
437
- + this.enableAlternateScreenKeyboardMode()
438
- + disableAlternateScrollMode()
439
- + this.mouseReportingStateGuard()
440
- + endSynchronizedOutput(),
441
- );
442
- this.emergencyCleanup = () => {
443
- if (!this.disposed) {
444
- this.restoreTerminalStateForExit();
445
- }
446
- };
447
- process.once("exit", this.emergencyCleanup);
448
-
449
- Object.defineProperty(this.terminal, "rows", {
450
- configurable: true,
451
- get: () => this.getScrollableRows(),
452
- });
453
-
454
- if (this.originalRender) {
455
- this.tui.render = (width: number) => this.renderScrollableRoot(width);
456
- }
457
-
458
- if (typeof this.tui.addInputListener === "function") {
459
- this.removeInputListener = this.tui.addInputListener((data: string) => this.handleInput(data));
460
- }
461
-
462
- this.terminal.write = (data: string) => this.write(data);
463
- if (this.originalDoRender) {
464
- this.tui.doRender = () => {
465
- this.renderPassActive = true;
466
- this.wroteDuringRenderPass = false;
467
- this.renderPassCluster = null;
468
- try {
469
- this.originalDoRender?.();
470
- // Patched terminal writes already append the fixed cluster. Repainting
471
- // again here doubles terminal output and is a major source of flicker.
472
- if (!this.wroteDuringRenderPass) this.requestRepaint();
473
- } finally {
474
- this.renderPassActive = false;
475
- this.wroteDuringRenderPass = false;
476
- this.renderPassCluster = null;
477
- }
478
- };
479
- }
480
- if (typeof this.tui.compositeLineAt === "function") {
481
- this.originalCompositeLineAt = this.tui.compositeLineAt.bind(this.tui) as CompositeLineAt;
482
- this.tui.compositeLineAt = (
483
- baseLine: string,
484
- overlayLine: string,
485
- startCol: number,
486
- overlayWidth: number,
487
- totalWidth: number,
488
- ) => this.originalCompositeLineAt?.(
489
- normalizeOverlayCompositionLine(baseLine),
490
- normalizeOverlayCompositionLine(overlayLine),
491
- startCol,
492
- overlayWidth,
493
- totalWidth,
494
- ) ?? "";
495
- }
496
- this.installed = true;
497
- }
498
-
499
- hideRenderable(target: PatchedRenderable): void {
500
- if (this.patchedRenders.some((patch) => patch.target === target)) return;
501
- const originalRender = target.render.bind(target);
502
- this.patchedRenders.push({ target, originalRender });
503
- target.render = () => [];
504
- }
505
-
506
- renderHidden(target: PatchedRenderable, width: number): string[] {
507
- const patch = this.patchedRenders.find((candidate) => candidate.target === target);
508
- const render = patch?.originalRender ?? target.render.bind(target);
509
- return render(width);
510
- }
511
-
512
- jumpToPreviousRootTarget(targetLines: readonly number[]): boolean {
513
- return this.jumpToRootTarget(targetLines, "previous");
514
- }
515
-
516
- jumpToNextRootTarget(targetLines: readonly number[]): boolean {
517
- return this.jumpToRootTarget(targetLines, "next");
518
- }
519
-
520
- jumpToRootBottom(): boolean {
521
- if (this.disposed || this.hasVisibleOverlay()) return false;
522
-
523
- const moved = this.scrollOffset !== 0;
524
- this.clearSelection();
525
- this.lastLeftPress = null;
526
- this.scrollOffset = 0;
527
- this.pendingImageCleanup = true;
528
-
529
- // Always force a full repaint. After the first jump, some terminals can
530
- // move their physical viewport while our logical offset remains zero; the
531
- // old early return then made every later Ctrl+End a no-op.
532
- if (typeof this.tui.requestRender === "function") {
533
- this.tui.requestRender(true);
534
- }
535
- this.requestRepaint();
536
- return moved;
537
- }
538
-
539
- /** Whether the chat viewport is currently following the newest output. */
540
- isAtRootBottom(): boolean {
541
- return this.scrollOffset === 0;
542
- }
543
-
544
- /**
545
- * Drop viewport state whose line indexes belong to an obsolete transcript.
546
- * Compaction rebuilds historical message components, so retaining expansion
547
- * anchors or the previous line count makes later tool expansion drift.
548
- */
549
- resetTranscriptLayout(): void {
550
- if (this.disposed) return;
551
-
552
- this.toolExpansionStarts.clear();
553
- this.clearSelection();
554
- this.lastLeftPress = null;
555
- this.hoveredRootLine = null;
556
- this.rootLines = [];
557
- this.visibleRootLines = [];
558
- this.visibleRootStart = 0;
559
- this.lastRootLineCount = 0;
560
- this.scrollOffset = 0;
561
- this.maxScrollOffset = 0;
562
- this.pendingImageCleanup = true;
563
-
564
- if (typeof this.tui.requestRender === "function") {
565
- this.tui.requestRender(true);
566
- }
567
- }
568
-
569
- private jumpToRootTarget(targetLines: readonly number[], direction: "previous" | "next"): boolean {
570
- if (this.disposed || targetLines.length === 0 || this.hasVisibleOverlay()) return false;
571
-
572
- const start = this.visibleRootStart;
573
- const candidates = direction === "previous"
574
- ? targetLines.filter((line) => line < start).sort((a, b) => b - a)
575
- : targetLines.filter((line) => line > start).sort((a, b) => a - b);
576
-
577
- for (const target of candidates) {
578
- const nextOffset = Math.max(0, Math.min(
579
- this.lastRootLineCount - Math.max(1, this.visibleScrollableRows) - target,
580
- this.maxScrollOffset,
581
- ));
582
- if (nextOffset === this.scrollOffset) continue;
583
-
584
- this.clearSelection();
585
- this.lastLeftPress = null;
586
- this.scrollOffset = nextOffset;
587
- this.pendingImageCleanup = true;
588
- this.requestRender();
589
- return true;
590
- }
591
-
592
- return false;
593
- }
594
-
595
- requestRepaint(): void {
596
- if (this.disposed || this.hasVisibleOverlay()) return;
597
- const rawRows = this.getRawRows();
598
- const width = Math.max(1, this.terminal.columns || 80);
599
- const cluster = this.getCluster(width, rawRows);
600
- if (cluster.lines.length === 0) return;
601
-
602
- this.originalWrite(
603
- beginSynchronizedOutput()
604
- + disableAutoWrap()
605
- + buildFixedClusterPaint(this.decorateCluster(cluster), rawRows, width, this.getShowHardwareCursor())
606
- + enableAutoWrap()
607
- + this.mouseReportingStateGuard()
608
- + endSynchronizedOutput(),
609
- );
610
- }
611
-
612
- dispose(options: DisposeOptions = {}): void {
613
- if (this.disposed) return;
614
- this.disposed = true;
615
-
616
- for (const patch of this.patchedRenders.splice(0)) {
617
- patch.target.render = patch.originalRender;
618
- }
619
-
620
- this.removeInputListener?.();
621
- this.removeInputListener = null;
622
- if (this.emergencyCleanup) {
623
- process.removeListener("exit", this.emergencyCleanup);
624
- this.emergencyCleanup = null;
625
- }
626
- if (this.mouseReportingResumeTimer) {
627
- clearTimeout(this.mouseReportingResumeTimer);
628
- this.mouseReportingResumeTimer = null;
629
- }
630
- if (this.clipboardRestoreTimer) {
631
- clearTimeout(this.clipboardRestoreTimer);
632
- this.clipboardRestoreTimer = null;
633
- }
634
-
635
- this.terminal.write = this.originalWrite;
636
- if (this.originalDoRender) {
637
- this.tui.doRender = this.originalDoRender;
638
- }
639
- if (this.originalRender) {
640
- this.tui.render = this.originalRender;
641
- }
642
- if (this.originalCompositeLineAt) {
643
- this.tui.compositeLineAt = this.originalCompositeLineAt;
644
- this.originalCompositeLineAt = null;
645
- }
646
- if (this.rowsDescriptor) {
647
- Object.defineProperty(this.terminal, "rows", this.rowsDescriptor);
648
- } else {
649
- Reflect.deleteProperty(this.terminal, "rows");
650
- }
651
-
652
- this.restoreTerminalState(options);
653
- }
654
-
655
- private getRawRows(): number {
656
- return Math.max(2, readRows(this.terminal, this.rowsDescriptor));
657
- }
658
-
659
- private getScrollableRows(): number {
660
- if (this.disposed || this.writing || this.renderingCluster || this.checkingOverlay || this.hasVisibleOverlay()) {
661
- return this.getRawRows();
662
- }
663
-
664
- const rawRows = this.getRawRows();
665
- const width = Math.max(1, this.terminal.columns || 80);
666
- const cluster = this.getCluster(width, rawRows);
667
- return Math.max(1, rawRows - cluster.lines.length);
668
- }
669
-
670
- private renderScrollableRoot(width: number): string[] {
671
- if (!this.originalRender || this.disposed || this.renderingScrollableRoot) {
672
- return this.originalRender?.(width) ?? [];
673
- }
674
-
675
- if (this.hasVisibleOverlay()) {
676
- return this.originalRender(width).map((line) => sanitizeOverlayBaseLine(line, Math.max(1, width)));
677
- }
678
-
679
- this.renderingScrollableRoot = true;
680
- try {
681
- const start = this.refreshRootWindow(width);
682
- return this.visibleRootLines.map((line, index) => {
683
- const hovered = this.renderHoverHighlight(line, start + index, "root");
684
- return this.renderSelectionHighlight(hovered, start + index, "root");
685
- });
686
- } finally {
687
- this.renderingScrollableRoot = false;
688
- }
689
- }
690
-
691
- private refreshRootWindow(width: number): number {
692
- if (!this.originalRender) return this.updateVisibleRootWindow();
693
-
694
- const rawRows = this.getRawRows();
695
- const renderWidth = Math.max(1, width);
696
- const cluster = this.getCluster(renderWidth, rawRows);
697
- const scrollableRows = Math.max(1, rawRows - cluster.lines.length);
698
- const renderedLines = this.originalRender(renderWidth);
699
- const lines = this.decorateRootLines?.(renderedLines) ?? renderedLines;
700
- this.rootLines = lines;
701
- if (!this.suppressRootGrowthAdjustment
702
- && this.scrollOffset > 0
703
- && this.lastRootLineCount > 0
704
- && lines.length > this.lastRootLineCount) {
705
- this.scrollOffset += lines.length - this.lastRootLineCount;
706
- }
707
- this.lastRootLineCount = lines.length;
708
- const previousMaxScrollOffset = this.maxScrollOffset;
709
- this.maxScrollOffset = Math.max(0, lines.length - scrollableRows);
710
- const nextScrollOffset = Math.max(0, Math.min(this.scrollOffset, this.maxScrollOffset));
711
- if (nextScrollOffset !== this.scrollOffset || this.maxScrollOffset !== previousMaxScrollOffset) {
712
- this.pendingImageCleanup = true;
713
- }
714
- this.scrollOffset = nextScrollOffset;
715
-
716
- return this.updateVisibleRootWindow(scrollableRows);
717
- }
718
-
719
- private handleInput(data: string): { consume?: boolean; data?: string } | undefined {
720
- if (this.disposed || this.hasVisibleOverlay()) return undefined;
721
-
722
- // Handle this at the compositor input layer rather than relying on the
723
- // editor wrapper. Pi can replace/rebind the editor during a session, which
724
- // made the shortcut work once and then disappear on subsequent scrolls.
725
- if (
726
- !isKeyRelease(data)
727
- && this.jumpToBottomShortcut
728
- && matchesConfiguredShortcut(data, this.jumpToBottomShortcut)
729
- ) {
730
- this.jumpToRootBottom();
731
- this.onJumpToBottom?.();
732
- return { consume: true };
733
- }
734
-
735
- const mousePackets = this.mouseScroll ? parseSgrMousePackets(data) : null;
736
- if (mousePackets) {
737
- for (const packet of mousePackets) {
738
- this.handleMousePacket(packet);
739
- }
740
- return { consume: true };
741
- }
742
-
743
- const keyboardDelta = parseKeyboardScrollDelta(data, this.keyboardScrollShortcuts);
744
- if (keyboardDelta === 0) return undefined;
745
-
746
- this.scrollBy(keyboardDelta);
747
- return { consume: true };
748
- }
749
-
750
- private handleMousePacket(packet: SgrMousePacket): void {
751
- const delta = mouseScrollDelta(packet);
752
- if (delta !== 0) {
753
- this.selectionDragging = false;
754
- this.scrollBy(delta);
755
- return;
756
- }
757
-
758
- // Mode 1003 reports every mouse move. Hover can use the last rendered
759
- // viewport; rebuilding the whole root for each packet causes visible stalls.
760
- if (isMouseMotion(packet) && !this.selectionDragging) {
761
- const location = this.selectionLocationForPacket(packet);
762
- const rootText = location?.area === "root"
763
- ? this.visibleRootLines[location.point.line - this.visibleRootStart] ?? ""
764
- : "";
765
- const nextHoveredLine = location?.area === "root" && this.onRootHover?.(
766
- location.point.line,
767
- location.point.col,
768
- rootText,
769
- )
770
- ? location.point.line
771
- : null;
772
- if (nextHoveredLine !== this.hoveredRootLine) {
773
- this.hoveredRootLine = nextHoveredLine;
774
- this.requestRender();
775
- }
776
- return;
777
- }
778
-
779
- this.refreshRootWindow(Math.max(1, this.terminal.columns || 80));
780
- const location = this.selectionLocationForPacket(packet);
781
-
782
- if (isRightPress(packet)) {
783
- this.selectionDragging = false;
784
- this.preserveSelectionFocusOnRelease = false;
785
- const selectedText = this.isLocationInsideSelection(location) ? this.getSelectedText() : "";
786
- if (selectedText) {
787
- this.onCopySelection?.(selectedText);
788
- this.lastLeftPress = null;
789
- this.pauseMouseReportingForContextMenu(selectedText);
790
- return;
791
- }
792
-
793
- this.clearSelection();
794
- this.lastLeftPress = null;
795
- this.pauseMouseReportingForContextMenu();
796
- return;
797
- }
798
-
799
- if (this.scrollSelectionAtViewportEdge(packet)) return;
800
- if (isMouseRelease(packet) && this.consumedClusterClick) {
801
- this.consumedClusterClick = false;
802
- this.clearSelection();
803
- this.lastLeftPress = null;
804
- return;
805
- }
806
- if (this.selectionDragging && isMouseRelease(packet)) {
807
- this.finishSelection(packet, location);
808
- return;
809
- }
810
-
811
- if (!location) return;
812
-
813
- if (isLeftPress(packet)) {
814
- if (location.area === "root" && this.onRootClick) {
815
- const rootText = this.visibleRootLines[location.point.line - this.visibleRootStart] ?? "";
816
- // Keep the clicked row anchored while a tool opens downward. Each tool
817
- // remembers the viewport start from before expansion so collapsing it can
818
- // return to exactly that position, even after scrolling inside the panel.
819
- const viewportStartBeforeClick = this.visibleRootStart;
820
- this.suppressRootGrowthAdjustment = true;
821
- let clickResult: boolean | RootClickResult = false;
822
- try {
823
- clickResult = this.onRootClick(location.point.line, location.point.col, rootText);
824
- if (clickResult) {
825
- this.refreshRootWindow(Math.max(1, this.terminal.columns || 80));
826
- const detail = typeof clickResult === "object" ? clickResult : null;
827
- let targetStart = viewportStartBeforeClick;
828
- if (detail?.key && detail.action === "expand") {
829
- this.toolExpansionStarts.set(detail.key, viewportStartBeforeClick);
830
- } else if (detail?.key && detail.action === "collapse") {
831
- targetStart = this.toolExpansionStarts.get(detail.key) ?? viewportStartBeforeClick;
832
- this.toolExpansionStarts.delete(detail.key);
833
- }
834
- this.scrollOffset = Math.max(0, Math.min(
835
- this.rootLines.length - Math.max(1, this.visibleScrollableRows) - targetStart,
836
- this.maxScrollOffset,
837
- ));
838
- this.updateVisibleRootWindow();
839
- }
840
- } finally {
841
- this.suppressRootGrowthAdjustment = false;
842
- }
843
- if (clickResult) {
844
- this.clearSelection();
845
- this.lastLeftPress = null;
846
- this.consumedClusterClick = true;
847
- this.requestRender();
848
- return;
849
- }
850
- }
851
-
852
- if (location.area === "cluster" && this.onClusterClick?.(
853
- location.point.line,
854
- location.point.col,
855
- this.visibleClusterLines[location.point.line] ?? "",
856
- )) {
857
- this.clearSelection();
858
- this.lastLeftPress = null;
859
- this.consumedClusterClick = true;
860
- return;
861
- }
862
-
863
- this.startSelection(location);
864
- return;
865
- }
866
-
867
- if (this.selectionDragging && isLeftDrag(packet) && location.area === this.selectionArea) {
868
- this.lastLeftPress = null;
869
- this.preserveSelectionFocusOnRelease = false;
870
- this.selectionFocus = location.point;
871
- this.requestRender();
872
- return;
873
- }
874
- }
875
-
876
- private updateVisibleRootWindow(scrollableRows = this.visibleScrollableRows): number {
877
- const rows = Math.max(1, scrollableRows);
878
- const start = Math.max(0, this.rootLines.length - rows - this.scrollOffset);
879
- const visibleLines = this.rootLines.slice(start, start + rows);
880
- while (visibleLines.length < rows) {
881
- visibleLines.push("");
882
- }
883
-
884
- this.visibleRootStart = start;
885
- this.visibleScrollableRows = rows;
886
- this.visibleRootLines = visibleLines;
887
- return start;
888
- }
889
-
890
- private finishSelection(packet: SgrMousePacket, location: SelectionLocation | null): void {
891
- if (!this.preserveSelectionFocusOnRelease) {
892
- this.selectionFocus = location?.area === this.selectionArea
893
- ? location.point
894
- : this.clampedSelectionPointForPacket(packet, this.selectionArea);
895
- }
896
-
897
- this.preserveSelectionFocusOnRelease = false;
898
- this.selectionDragging = false;
899
- const selectedText = this.getSelectedText();
900
- if (selectedText) {
901
- this.lastLeftPress = null;
902
- this.onCopySelection?.(selectedText);
903
- } else {
904
- this.clearSelection();
905
- }
906
- this.requestRender();
907
- }
908
-
909
- private startSelection(location: SelectionLocation): void {
910
- const now = Date.now();
911
- const line = location.point.line;
912
- if (
913
- this.lastLeftPress
914
- && this.lastLeftPress.area === location.area
915
- && this.lastLeftPress.line === line
916
- && now - this.lastLeftPress.at <= DOUBLE_CLICK_MS
917
- ) {
918
- this.selectionArea = location.area;
919
- this.selectionAnchor = { line, col: 0 };
920
- this.selectionFocus = { line, col: this.selectionLineWidth(location.area, line) };
921
- this.selectionDragging = true;
922
- this.preserveSelectionFocusOnRelease = true;
923
- this.lastLeftPress = null;
924
- this.requestRender();
925
- return;
926
- }
927
-
928
- this.selectionArea = location.area;
929
- this.selectionAnchor = location.point;
930
- this.selectionFocus = location.point;
931
- this.selectionDragging = true;
932
- this.preserveSelectionFocusOnRelease = false;
933
- this.lastLeftPress = { area: location.area, line, at: now };
934
- this.requestRender();
935
- }
936
-
937
- private selectionLocationForPacket(packet: SgrMousePacket): SelectionLocation | null {
938
- if (packet.row < 1) return null;
939
-
940
- const col = Math.max(0, packet.col - 1);
941
- if (packet.row <= this.visibleScrollableRows) {
942
- return {
943
- area: "root",
944
- point: { line: this.visibleRootStart + packet.row - 1, col },
945
- };
946
- }
947
-
948
- const clusterLine = packet.row - this.visibleScrollableRows - 1;
949
- if (clusterLine >= this.visibleClusterLines.length) return null;
950
-
951
- return {
952
- area: "cluster",
953
- point: { line: clusterLine, col },
954
- };
955
- }
956
-
957
- private scrollSelectionAtViewportEdge(packet: SgrMousePacket): boolean {
958
- if (!this.selectionDragging || this.selectionArea !== "root" || !isLeftDrag(packet)) return false;
959
-
960
- const delta = packet.row <= 1 ? 1 : packet.row >= this.visibleScrollableRows ? -1 : 0;
961
- if (delta === 0) return false;
962
-
963
- const nextOffset = Math.max(0, Math.min(this.scrollOffset + delta, this.maxScrollOffset));
964
- if (nextOffset === this.scrollOffset) return false;
965
-
966
- this.lastLeftPress = null;
967
- this.preserveSelectionFocusOnRelease = true;
968
- this.scrollOffset = nextOffset;
969
- this.pendingImageCleanup = true;
970
- const start = this.updateVisibleRootWindow();
971
- const edgeLine = delta > 0 ? start : start + Math.max(0, this.visibleScrollableRows - 1);
972
- this.selectionFocus = {
973
- line: edgeLine,
974
- col: Math.max(0, packet.col - 1),
975
- };
976
- this.requestRender();
977
- return true;
978
- }
979
-
980
- private clampedSelectionPointForPacket(packet: SgrMousePacket, area: SelectionArea | null): SelectionPoint {
981
- if (area === "cluster") {
982
- return {
983
- line: Math.max(0, Math.min(packet.row - this.visibleScrollableRows - 1, this.visibleClusterLines.length - 1)),
984
- col: Math.max(0, packet.col - 1),
985
- };
986
- }
987
-
988
- const row = Math.max(1, Math.min(packet.row, this.visibleScrollableRows));
989
- return {
990
- line: this.visibleRootStart + row - 1,
991
- col: Math.max(0, packet.col - 1),
992
- };
993
- }
994
-
995
- private renderHoverHighlight(line: string, lineIndex: number, area: SelectionArea): string {
996
- if (area !== "root" || this.hoveredRootLine !== lineIndex) return line;
997
- const plain = stripAnsi(line);
998
- if (!plain.trim()) return line;
999
- return `\x1b[1;97m${plain}\x1b[22;39m`;
1000
- }
1001
-
1002
- private renderSelectionHighlight(line: string, lineIndex: number, area: SelectionArea): string {
1003
- const range = this.getSelectionRangeForLine(lineIndex, area);
1004
- if (!range) return line;
1005
-
1006
- const plain = stripAnsi(line);
1007
- const startCol = Math.max(0, Math.min(range.startCol, visibleWidth(plain)));
1008
- const endCol = Math.max(startCol, Math.min(range.endCol, visibleWidth(plain)));
1009
- if (startCol === endCol) return line;
1010
-
1011
- const before = sliceColumns(plain, 0, startCol);
1012
- const selected = sliceColumns(plain, startCol, endCol);
1013
- const after = sliceColumns(plain, endCol, Number.POSITIVE_INFINITY);
1014
- return `${before}\x1b[7m${selected}\x1b[27m${after}`;
1015
- }
1016
-
1017
- private selectionLineWidth(area: SelectionArea, lineIndex: number): number {
1018
- const lines = area === "root" ? this.visibleRootLines : this.visibleClusterLines;
1019
- const firstLine = area === "root" ? this.visibleRootStart : 0;
1020
- return visibleWidth(stripAnsi(lines[lineIndex - firstLine] ?? ""));
1021
- }
1022
-
1023
- private getSelectedText(): string {
1024
- if (!this.selectionArea || !this.selectionAnchor || !this.selectionFocus) return "";
1025
-
1026
- const start = compareSelectionPoints(this.selectionAnchor, this.selectionFocus) <= 0
1027
- ? this.selectionAnchor
1028
- : this.selectionFocus;
1029
- const end = start === this.selectionAnchor ? this.selectionFocus : this.selectionAnchor;
1030
- if (start.line === end.line && start.col === end.col) return "";
1031
-
1032
- const lines = this.selectionArea === "root" ? this.rootLines : this.visibleClusterLines;
1033
- const selected: string[] = [];
1034
- for (let lineIndex = start.line; lineIndex <= end.line; lineIndex++) {
1035
- const line = stripAnsi(lines[lineIndex] ?? "");
1036
- const startCol = lineIndex === start.line ? start.col : 0;
1037
- const endCol = lineIndex === end.line ? end.col : Number.POSITIVE_INFINITY;
1038
- selected.push(sliceColumns(line, startCol, endCol));
1039
- }
1040
-
1041
- return selected.join("\n").replace(/[ \t]+$/gm, "").trimEnd();
1042
- }
1043
-
1044
- private getSelectionRangeForLine(lineIndex: number, area: SelectionArea): { startCol: number; endCol: number } | null {
1045
- if (this.selectionArea !== area || !this.selectionAnchor || !this.selectionFocus) return null;
1046
-
1047
- const start = compareSelectionPoints(this.selectionAnchor, this.selectionFocus) <= 0
1048
- ? this.selectionAnchor
1049
- : this.selectionFocus;
1050
- const end = start === this.selectionAnchor ? this.selectionFocus : this.selectionAnchor;
1051
- if (lineIndex < start.line || lineIndex > end.line) return null;
1052
-
1053
- return {
1054
- startCol: lineIndex === start.line ? start.col : 0,
1055
- endCol: lineIndex === end.line ? end.col : Number.POSITIVE_INFINITY,
1056
- };
1057
- }
1058
-
1059
- private isLocationInsideSelection(location: SelectionLocation | null): boolean {
1060
- if (!location || location.area !== this.selectionArea) return false;
1061
- const range = this.getSelectionRangeForLine(location.point.line, location.area);
1062
- return Boolean(range && location.point.col >= range.startCol && location.point.col < range.endCol);
1063
- }
1064
-
1065
- private scrollBy(delta: number): void {
1066
- const width = Math.max(1, this.terminal.columns || 80);
1067
- this.refreshRootWindow(width);
1068
-
1069
- const wasScrolled = this.scrollOffset > 0;
1070
- const nextOffset = Math.max(0, Math.min(this.scrollOffset + delta, this.maxScrollOffset));
1071
- if (nextOffset === this.scrollOffset) return;
1072
-
1073
- this.clearSelection();
1074
- this.lastLeftPress = null;
1075
- this.hoveredRootLine = null;
1076
- this.scrollOffset = nextOffset;
1077
- this.pendingImageCleanup = true;
1078
- if (wasScrolled && nextOffset === 0) this.onReachRootBottom?.();
1079
- // This writes the complete scrollable viewport synchronously; scheduling a
1080
- // second full TUI render here only repaints the same frame again.
1081
- this.repaintScrollableViewport(width);
1082
- }
1083
-
1084
- private requestRender(): void {
1085
- if (typeof this.tui.requestRender === "function") {
1086
- this.tui.requestRender();
1087
- }
1088
- }
1089
-
1090
- private repaintScrollableViewport(width: number): void {
1091
- if (this.disposed || this.writing || this.hasVisibleOverlay()) return;
1092
-
1093
- const rawRows = this.getRawRows();
1094
- const cluster = this.getCluster(width, rawRows);
1095
- const scrollableRows = Math.max(1, rawRows - cluster.lines.length);
1096
- const start = this.updateVisibleRootWindow(scrollableRows);
1097
- let buffer = beginSynchronizedOutput()
1098
- + this.consumePendingImageCleanup()
1099
- + disableAutoWrap()
1100
- + setScrollRegion(1, scrollableRows)
1101
- + moveCursor(1, 1);
1102
-
1103
- for (let row = 0; row < scrollableRows; row++) {
1104
- if (row > 0) buffer += "\r\n";
1105
- buffer += clearLine();
1106
- const hovered = this.renderHoverHighlight(this.visibleRootLines[row] ?? "", start + row, "root");
1107
- buffer += sanitizeLine(this.renderSelectionHighlight(hovered, start + row, "root"), width);
1108
- }
1109
-
1110
- buffer += buildFixedClusterPaint(this.decorateCluster(cluster), rawRows, width, this.getShowHardwareCursor());
1111
- buffer += enableAutoWrap();
1112
- buffer += this.mouseReportingStateGuard();
1113
- buffer += endSynchronizedOutput();
1114
- this.originalWrite(buffer);
1115
- }
1116
-
1117
- private pauseMouseReportingForContextMenu(textToRestoreToClipboard: string | null = null): void {
1118
- if (this.mouseReportingResumeTimer) {
1119
- clearTimeout(this.mouseReportingResumeTimer);
1120
- }
1121
- if (this.clipboardRestoreTimer) {
1122
- clearTimeout(this.clipboardRestoreTimer);
1123
- this.clipboardRestoreTimer = null;
1124
- }
1125
-
1126
- this.originalWrite(beginSynchronizedOutput() + disableMouseReporting() + endSynchronizedOutput());
1127
- this.mouseReportingResumeTimer = setTimeout(() => {
1128
- this.mouseReportingResumeTimer = null;
1129
- if (!this.disposed) {
1130
- this.originalWrite(beginSynchronizedOutput() + enableMouseReporting() + endSynchronizedOutput());
1131
- }
1132
- }, CONTEXT_MENU_MOUSE_REPORTING_PAUSE_MS);
1133
-
1134
- if (typeof this.mouseReportingResumeTimer === "object" && "unref" in this.mouseReportingResumeTimer) {
1135
- this.mouseReportingResumeTimer.unref();
1136
- }
1137
-
1138
- const restoreClipboard = this.onCopySelection;
1139
- if (!textToRestoreToClipboard || !restoreClipboard) return;
1140
-
1141
- let remainingRestores = Math.ceil(CONTEXT_MENU_SELECTION_RESTORE_WINDOW_MS / CONTEXT_MENU_CLIPBOARD_RESTORE_INTERVAL_MS);
1142
- const scheduleClipboardRestore = () => {
1143
- this.clipboardRestoreTimer = setTimeout(() => {
1144
- this.clipboardRestoreTimer = null;
1145
- if (this.disposed) return;
1146
-
1147
- remainingRestores -= 1;
1148
- if (this.getSelectedText() !== textToRestoreToClipboard) return;
1149
-
1150
- restoreClipboard(textToRestoreToClipboard);
1151
- if (remainingRestores > 0) {
1152
- scheduleClipboardRestore();
1153
- }
1154
- }, CONTEXT_MENU_CLIPBOARD_RESTORE_INTERVAL_MS);
1155
-
1156
- if (typeof this.clipboardRestoreTimer === "object" && "unref" in this.clipboardRestoreTimer) {
1157
- this.clipboardRestoreTimer.unref();
1158
- }
1159
- };
1160
-
1161
- scheduleClipboardRestore();
1162
- }
1163
-
1164
- private clearSelection(): void {
1165
- this.selectionArea = null;
1166
- this.selectionAnchor = null;
1167
- this.selectionFocus = null;
1168
- this.selectionDragging = false;
1169
- this.preserveSelectionFocusOnRelease = false;
1170
- }
1171
-
1172
- private activeExtendedKeyboardMode(): ExtendedKeyboardMode | null {
1173
- if (this.terminal.kittyProtocolActive === true) return "kitty";
1174
- if (Reflect.get(this.terminal, "_modifyOtherKeysActive") === true) return "modifyOtherKeys";
1175
- return null;
1176
- }
1177
-
1178
- private enableAlternateScreenKeyboardMode(): string {
1179
- this.extendedKeyboardMode = this.activeExtendedKeyboardMode();
1180
- return this.extendedKeyboardMode ? enableExtendedKeyboardMode(this.extendedKeyboardMode) : "";
1181
- }
1182
-
1183
- private restoreTerminalState(options: DisposeOptions = {}): void {
1184
- const activeMode = this.extendedKeyboardMode ?? this.activeExtendedKeyboardMode();
1185
- const restoreMainScreenMode = !options.resetExtendedKeyboardModes && this.extendedKeyboardMode === null && activeMode !== null;
1186
-
1187
- this.originalWrite(
1188
- beginSynchronizedOutput()
1189
- + resetScrollRegion()
1190
- + (this.mouseScroll ? disableMouseReporting() : "")
1191
- + (activeMode ? disableExtendedKeyboardMode(activeMode) : "")
1192
- + enableAlternateScrollMode()
1193
- + exitAlternateScreen()
1194
- + (restoreMainScreenMode && activeMode ? enableExtendedKeyboardMode(activeMode) : "")
1195
- + (options.resetExtendedKeyboardModes ? resetExtendedKeyboardModes() : "")
1196
- + endSynchronizedOutput(),
1197
- );
1198
- }
1199
-
1200
- private restoreTerminalStateForExit(): void {
1201
- try {
1202
- this.restoreTerminalState({ resetExtendedKeyboardModes: true });
1203
- } catch {
1204
- // Process-exit cleanup cannot report useful errors and must not throw.
1205
- }
1206
- }
1207
-
1208
- private write(data: string): void {
1209
- if (this.renderPassActive) this.wroteDuringRenderPass = true;
1210
- if (this.disposed || this.writing || this.hasVisibleOverlay()) {
1211
- this.originalWrite(data);
1212
- return;
1213
- }
1214
-
1215
- this.writing = true;
1216
- try {
1217
- const rawRows = this.getRawRows();
1218
- const width = Math.max(1, this.terminal.columns || 80);
1219
- const cluster = this.getCluster(width, rawRows);
1220
- const reservedRows = cluster.lines.length;
1221
-
1222
- if (reservedRows === 0 || rawRows <= 2) {
1223
- this.originalWrite(data);
1224
- return;
1225
- }
1226
-
1227
- const scrollBottom = Math.max(1, rawRows - reservedRows);
1228
- const hardwareCursorRow = typeof this.tui.hardwareCursorRow === "number"
1229
- ? this.tui.hardwareCursorRow
1230
- : typeof this.tui.cursorRow === "number"
1231
- ? this.tui.cursorRow
1232
- : 0;
1233
- const viewportTop = typeof this.tui.previousViewportTop === "number" ? this.tui.previousViewportTop : 0;
1234
- const screenRow = Math.max(1, Math.min(scrollBottom, hardwareCursorRow - viewportTop + 1));
1235
- const buffer = beginSynchronizedOutput()
1236
- + this.consumePendingImageCleanup()
1237
- + disableAutoWrap()
1238
- + setScrollRegion(1, scrollBottom)
1239
- + moveCursor(screenRow, 1)
1240
- + data
1241
- + buildFixedClusterPaint(this.decorateCluster(cluster), rawRows, width, this.getShowHardwareCursor())
1242
- + enableAutoWrap()
1243
- + this.mouseReportingStateGuard()
1244
- + endSynchronizedOutput();
1245
-
1246
- this.originalWrite(buffer);
1247
- } finally {
1248
- this.writing = false;
1249
- }
1250
- }
1251
-
1252
- private consumePendingImageCleanup(): string {
1253
- if (!this.pendingImageCleanup) return "";
1254
- this.pendingImageCleanup = false;
1255
- return deleteAllKittyImages();
1256
- }
1257
-
1258
- private mouseReportingStateGuard(): string {
1259
- return this.mouseScroll && !this.mouseReportingResumeTimer ? enableMouseReporting() : "";
1260
- }
1261
-
1262
- private getCluster(width: number, terminalRows: number): FixedEditorClusterRender {
1263
- if (
1264
- this.renderPassActive &&
1265
- this.renderPassCluster?.width === width &&
1266
- this.renderPassCluster.terminalRows === terminalRows
1267
- ) {
1268
- return this.renderPassCluster.cluster;
1269
- }
1270
-
1271
- const cluster = this.withClusterRender(() => this.renderCluster(width, terminalRows));
1272
- this.visibleClusterLines = cluster.lines;
1273
- if (this.renderPassActive) {
1274
- this.renderPassCluster = { width, terminalRows, cluster };
1275
- }
1276
- return cluster;
1277
- }
1278
-
1279
- private decorateCluster(cluster: FixedEditorClusterRender): FixedEditorClusterRender {
1280
- if (this.selectionArea !== "cluster") return cluster;
1281
-
1282
- return {
1283
- ...cluster,
1284
- lines: cluster.lines.map((line, index) => this.renderSelectionHighlight(line, index, "cluster")),
1285
- };
1286
- }
1287
-
1288
- private withClusterRender<T>(render: () => T): T {
1289
- const wasRenderingCluster = this.renderingCluster;
1290
- this.renderingCluster = true;
1291
- try {
1292
- return render();
1293
- } finally {
1294
- this.renderingCluster = wasRenderingCluster;
1295
- }
1296
- }
1297
-
1298
- private hasVisibleOverlay(): boolean {
1299
- if (this.checkingOverlay) return false;
1300
-
1301
- this.checkingOverlay = true;
1302
- try {
1303
- if (typeof this.tui.hasOverlay === "function" && this.tui.hasOverlay()) {
1304
- return true;
1305
- }
1306
-
1307
- const overlayStack = Reflect.get(this.tui, "overlayStack");
1308
- if (!Array.isArray(overlayStack)) {
1309
- return false;
1310
- }
1311
-
1312
- return overlayStack.some((entry) => entry && entry.hidden !== true);
1313
- } finally {
1314
- this.checkingOverlay = false;
1315
- }
1316
- }
1317
- }
1
+ import { deleteAllKittyImages, isKeyRelease, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+ import { matchesConfiguredShortcut } from "../shortcuts.ts";
3
+ import type { FixedEditorClusterRender } from "./cluster.ts";
4
+
5
+ export interface TerminalLike {
6
+ columns: number;
7
+ rows: number;
8
+ kittyProtocolActive?: boolean;
9
+ write(data: string): void;
10
+ }
11
+
12
+ interface KeyboardScrollShortcuts {
13
+ up: string;
14
+ down: string;
15
+ }
16
+
17
+ export interface RootClickResult {
18
+ handled: true;
19
+ action?: "expand" | "collapse";
20
+ key?: string;
21
+ }
22
+
23
+ interface TerminalSplitCompositorOptions {
24
+ tui: any;
25
+ terminal: TerminalLike;
26
+ renderCluster: (width: number, terminalRows: number) => FixedEditorClusterRender;
27
+ getShowHardwareCursor?: () => boolean;
28
+ mouseScroll?: boolean;
29
+ keyboardScrollShortcuts?: KeyboardScrollShortcuts;
30
+ jumpToBottomShortcut?: string;
31
+ onJumpToBottom?: () => void;
32
+ onCopySelection?: (text: string) => void;
33
+ /** Handle a left click on a fixed-editor cluster line. Return true to consume it. */
34
+ onClusterClick?: (line: number, col: number, text: string) => boolean;
35
+ /** Handle a left click on a scrollable chat line. Return a result to preserve expansion anchors. */
36
+ onRootClick?: (line: number, col: number, text: string) => boolean | RootClickResult;
37
+ /** Return true when a scrollable chat line should show hover feedback. */
38
+ onRootHover?: (line: number, col: number, text: string) => boolean;
39
+ /** Apply stable presentation-only styling to the complete rendered transcript. */
40
+ decorateRootLines?: (lines: string[]) => string[];
41
+ /** Notify when manual scrolling reaches the newest output. */
42
+ onReachRootBottom?: () => void;
43
+ }
44
+
45
+ interface PatchedRenderable {
46
+ render(width: number): string[];
47
+ }
48
+
49
+ interface RenderPatch {
50
+ target: PatchedRenderable;
51
+ originalRender: (width: number) => string[];
52
+ }
53
+
54
+ interface RenderPassCluster {
55
+ width: number;
56
+ terminalRows: number;
57
+ cluster: FixedEditorClusterRender;
58
+ }
59
+
60
+ type CompositeLineAt = (
61
+ baseLine: string,
62
+ overlayLine: string,
63
+ startCol: number,
64
+ overlayWidth: number,
65
+ totalWidth: number,
66
+ ) => string;
67
+
68
+ interface SgrMousePacket {
69
+ code: number;
70
+ col: number;
71
+ row: number;
72
+ final: "M" | "m";
73
+ }
74
+
75
+ interface SelectionPoint {
76
+ line: number;
77
+ col: number;
78
+ }
79
+
80
+ type SelectionArea = "root" | "cluster";
81
+
82
+ interface SelectionLocation {
83
+ area: SelectionArea;
84
+ point: SelectionPoint;
85
+ }
86
+
87
+ interface DisposeOptions {
88
+ resetExtendedKeyboardModes?: boolean;
89
+ }
90
+
91
+ type ExtendedKeyboardMode = "kitty" | "modifyOtherKeys";
92
+
93
+ const CONTEXT_MENU_MOUSE_REPORTING_PAUSE_MS = 1200;
94
+ const CONTEXT_MENU_SELECTION_RESTORE_WINDOW_MS = 5000;
95
+ const CONTEXT_MENU_CLIPBOARD_RESTORE_INTERVAL_MS = 100;
96
+ const DOUBLE_CLICK_MS = 500;
97
+ const DEFAULT_KEYBOARD_SCROLL_SHORTCUTS: KeyboardScrollShortcuts = {
98
+ up: "super+up",
99
+ down: "super+down",
100
+ };
101
+
102
+ export function beginSynchronizedOutput(): string {
103
+ return "\x1b[?2026h";
104
+ }
105
+
106
+ export function endSynchronizedOutput(): string {
107
+ return "\x1b[?2026l";
108
+ }
109
+
110
+ export function setScrollRegion(top: number, bottom: number): string {
111
+ return `\x1b[${top};${bottom}r`;
112
+ }
113
+
114
+ export function resetScrollRegion(): string {
115
+ return "\x1b[r";
116
+ }
117
+
118
+ export function moveCursor(row: number, col: number): string {
119
+ return `\x1b[${row};${col}H`;
120
+ }
121
+
122
+ function clearLine(): string {
123
+ return "\x1b[2K";
124
+ }
125
+
126
+ function hideCursor(): string {
127
+ return "\x1b[?25l";
128
+ }
129
+
130
+ function showCursor(): string {
131
+ return "\x1b[?25h";
132
+ }
133
+
134
+ function enterAlternateScreen(): string {
135
+ return "\x1b[?1049h";
136
+ }
137
+
138
+ function exitAlternateScreen(): string {
139
+ return "\x1b[?1049l";
140
+ }
141
+
142
+ function enableAlternateScrollMode(): string {
143
+ return "\x1b[?1007h";
144
+ }
145
+
146
+ function disableAlternateScrollMode(): string {
147
+ return "\x1b[?1007l";
148
+ }
149
+
150
+ function disableAutoWrap(): string {
151
+ return "\x1b[?7l";
152
+ }
153
+
154
+ function enableAutoWrap(): string {
155
+ return "\x1b[?7h";
156
+ }
157
+
158
+ function enableMouseReporting(): string {
159
+ return "\x1b[?1003h\x1b[?1006h";
160
+ }
161
+
162
+ function disableMouseReporting(): string {
163
+ return "\x1b[?1006l\x1b[?1003l\x1b[?1002l\x1b[?1000l";
164
+ }
165
+
166
+ function enableExtendedKeyboardMode(mode: ExtendedKeyboardMode): string {
167
+ return mode === "kitty" ? "\x1b[>7u" : "\x1b[>4;2m";
168
+ }
169
+
170
+ function disableExtendedKeyboardMode(mode: ExtendedKeyboardMode): string {
171
+ return mode === "kitty" ? "\x1b[<u" : "\x1b[>4;0m";
172
+ }
173
+
174
+ function resetExtendedKeyboardModes(): string {
175
+ return "\x1b[<999u\x1b[>4;0m";
176
+ }
177
+
178
+ export function emergencyTerminalModeReset(): string {
179
+ return beginSynchronizedOutput()
180
+ + resetScrollRegion()
181
+ + disableMouseReporting()
182
+ + enableAlternateScrollMode()
183
+ + exitAlternateScreen()
184
+ + resetExtendedKeyboardModes()
185
+ + endSynchronizedOutput();
186
+ }
187
+
188
+ function parseKeyboardScrollDelta(data: string, shortcuts: KeyboardScrollShortcuts = DEFAULT_KEYBOARD_SCROLL_SHORTCUTS): number {
189
+ if (isKeyRelease(data)) return 0;
190
+
191
+ if (
192
+ matchesConfiguredShortcut(data, shortcuts.up)
193
+ || matchesKey(data, "pageUp")
194
+ || matchesKey(data, "shift+pageUp")
195
+ || matchesKey(data, "ctrl+pageUp")
196
+ || matchesKey(data, "ctrl+shift+up")
197
+ || /^\x1b\[(?:5;9(?::[12])?~|1;6(?::[12])?A|57421;9(?::[12])?u|57419;6(?::[12])?u)$/.test(data)
198
+ ) return 10;
199
+ if (
200
+ matchesConfiguredShortcut(data, shortcuts.down)
201
+ || matchesKey(data, "pageDown")
202
+ || matchesKey(data, "shift+pageDown")
203
+ || matchesKey(data, "ctrl+pageDown")
204
+ || matchesKey(data, "ctrl+shift+down")
205
+ || /^\x1b\[(?:6;9(?::[12])?~|1;6(?::[12])?B|57422;9(?::[12])?u|57420;6(?::[12])?u)$/.test(data)
206
+ ) return -10;
207
+ return 0;
208
+ }
209
+
210
+ function parseSgrMousePackets(data: string): SgrMousePacket[] | null {
211
+ const pattern = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
212
+ const packets: SgrMousePacket[] = [];
213
+ let offset = 0;
214
+
215
+ for (const match of data.matchAll(pattern)) {
216
+ if (match.index !== offset) return null;
217
+ offset = match.index + match[0].length;
218
+ packets.push({
219
+ code: Number(match[1]),
220
+ col: Number(match[2]),
221
+ row: Number(match[3]),
222
+ final: match[4] as "M" | "m",
223
+ });
224
+ }
225
+
226
+ return packets.length > 0 && offset === data.length ? packets : null;
227
+ }
228
+
229
+ function mouseBaseButton(code: number): number {
230
+ return code & ~(4 | 8 | 16 | 32);
231
+ }
232
+
233
+ function mouseScrollDelta(packet: SgrMousePacket): number {
234
+ if (packet.final !== "M") return 0;
235
+ const baseButton = mouseBaseButton(packet.code);
236
+ if (baseButton === 64) return 3;
237
+ if (baseButton === 65) return -3;
238
+ return 0;
239
+ }
240
+
241
+ function isLeftPress(packet: SgrMousePacket): boolean {
242
+ return packet.final === "M" && mouseBaseButton(packet.code) === 0 && (packet.code & 32) === 0;
243
+ }
244
+
245
+ function isLeftDrag(packet: SgrMousePacket): boolean {
246
+ return packet.final === "M" && mouseBaseButton(packet.code) === 0 && (packet.code & 32) !== 0;
247
+ }
248
+
249
+ function isMouseMotion(packet: SgrMousePacket): boolean {
250
+ return packet.final === "M" && (packet.code & 32) !== 0;
251
+ }
252
+
253
+ function isRightPress(packet: SgrMousePacket): boolean {
254
+ return packet.final === "M" && mouseBaseButton(packet.code) === 2 && (packet.code & 32) === 0;
255
+ }
256
+
257
+ function isMouseRelease(packet: SgrMousePacket): boolean {
258
+ return packet.final === "m";
259
+ }
260
+
261
+ function stripOscSequences(line: string): string {
262
+ return line.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "");
263
+ }
264
+
265
+ function stripAnsi(line: string): string {
266
+ return stripOscSequences(line).replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "");
267
+ }
268
+
269
+ const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
270
+
271
+ function sliceColumns(text: string, startCol: number, endCol: number): string {
272
+ let col = 0;
273
+ let result = "";
274
+ for (const { segment } of graphemeSegmenter.segment(text)) {
275
+ const width = Math.max(0, visibleWidth(segment));
276
+ if (col >= startCol && col < endCol) {
277
+ result += segment;
278
+ }
279
+ col += width;
280
+ }
281
+ return result;
282
+ }
283
+
284
+ function compareSelectionPoints(a: SelectionPoint, b: SelectionPoint): number {
285
+ return a.line === b.line ? a.col - b.col : a.line - b.line;
286
+ }
287
+
288
+ function descriptorForRows(terminal: TerminalLike): PropertyDescriptor | undefined {
289
+ let target: object | null = terminal;
290
+ while (target) {
291
+ const descriptor = Object.getOwnPropertyDescriptor(target, "rows");
292
+ if (descriptor) return descriptor;
293
+ target = Object.getPrototypeOf(target);
294
+ }
295
+
296
+ return undefined;
297
+ }
298
+
299
+ function readRows(terminal: TerminalLike, descriptor: PropertyDescriptor | undefined): number {
300
+ if (descriptor?.get) {
301
+ const value = descriptor.get.call(terminal);
302
+ return typeof value === "number" && Number.isFinite(value) ? value : 24;
303
+ }
304
+
305
+ const value = Reflect.get(terminal, "rows");
306
+ return typeof value === "number" && Number.isFinite(value) ? value : 24;
307
+ }
308
+
309
+ function sanitizeLine(line: string, width: number): string {
310
+ return visibleWidth(line) > width ? truncateToWidth(line, width, "", true) : line;
311
+ }
312
+
313
+ function sanitizeOverlayBaseLine(line: string, width: number): string {
314
+ return sanitizeLine(stripOscSequences(line), width);
315
+ }
316
+
317
+ function normalizeOverlayCompositionLine(line: string): string {
318
+ return line.includes("\t") ? line.replace(/\t/g, " ") : line;
319
+ }
320
+
321
+ export function buildFixedClusterPaint(
322
+ cluster: FixedEditorClusterRender,
323
+ terminalRows: number,
324
+ width: number,
325
+ showHardwareCursor: boolean,
326
+ ): string {
327
+ if (cluster.lines.length === 0) return "";
328
+
329
+ const startRow = Math.max(1, terminalRows - cluster.lines.length + 1);
330
+ let buffer = resetScrollRegion();
331
+
332
+ for (let i = 0; i < cluster.lines.length; i++) {
333
+ buffer += moveCursor(startRow + i, 1);
334
+ buffer += clearLine();
335
+ buffer += sanitizeLine(cluster.lines[i] ?? "", width);
336
+ }
337
+
338
+ if (cluster.cursor) {
339
+ // Keep the real terminal cursor parked at the logical editor cursor even
340
+ // when it is visually hidden. macOS IME candidate windows anchor to the
341
+ // terminal cursor position, so skipping the move when PI_HARDWARE_CURSOR is
342
+ // off leaves the candidate window at a stale/offset location.
343
+ buffer += moveCursor(startRow + cluster.cursor.row, Math.max(1, cluster.cursor.col + 1));
344
+ buffer += showHardwareCursor ? showCursor() : hideCursor();
345
+ } else {
346
+ buffer += hideCursor();
347
+ }
348
+
349
+ return buffer;
350
+ }
351
+
352
+ export class TerminalSplitCompositor {
353
+ private readonly tui: any;
354
+ private readonly terminal: TerminalLike;
355
+ private readonly renderCluster: (width: number, terminalRows: number) => FixedEditorClusterRender;
356
+ private readonly getShowHardwareCursor: () => boolean;
357
+ private readonly mouseScroll: boolean;
358
+ private readonly keyboardScrollShortcuts: KeyboardScrollShortcuts;
359
+ private readonly jumpToBottomShortcut: string | null;
360
+ private readonly onJumpToBottom: (() => void) | null;
361
+ private readonly onCopySelection: ((text: string) => void) | null;
362
+ private readonly onClusterClick: ((line: number, col: number, text: string) => boolean) | null;
363
+ private readonly onRootClick: ((line: number, col: number, text: string) => boolean | RootClickResult) | null;
364
+ private readonly onRootHover: ((line: number, col: number, text: string) => boolean) | null;
365
+ private readonly decorateRootLines: ((lines: string[]) => string[]) | null;
366
+ private readonly onReachRootBottom: (() => void) | null;
367
+ private extendedKeyboardMode: ExtendedKeyboardMode | null = null;
368
+ private readonly rowsDescriptor: PropertyDescriptor | undefined;
369
+ private readonly originalWrite: (data: string) => void;
370
+ private readonly originalDoRender: (() => void) | null;
371
+ private readonly originalRender: ((width: number) => string[]) | null;
372
+ private originalCompositeLineAt: CompositeLineAt | null = null;
373
+ private readonly patchedRenders: RenderPatch[] = [];
374
+ private removeInputListener: (() => void) | null = null;
375
+ private emergencyCleanup: (() => void) | null = null;
376
+ private mouseReportingResumeTimer: ReturnType<typeof setTimeout> | null = null;
377
+ private clipboardRestoreTimer: ReturnType<typeof setTimeout> | null = null;
378
+ private installed = false;
379
+ private disposed = false;
380
+ private writing = false;
381
+ private renderPassActive = false;
382
+ private wroteDuringRenderPass = false;
383
+ private renderPassCluster: RenderPassCluster | null = null;
384
+ private renderingCluster = false;
385
+ private renderingScrollableRoot = false;
386
+ private checkingOverlay = false;
387
+ private scrollOffset = 0;
388
+ private maxScrollOffset = 0;
389
+ private lastRootLineCount = 0;
390
+ private rootLines: string[] = [];
391
+ private suppressRootGrowthAdjustment = false;
392
+ private readonly toolExpansionStarts = new Map<string, number>();
393
+ private visibleRootStart = 0;
394
+ private visibleScrollableRows = 0;
395
+ private visibleRootLines: string[] = [];
396
+ private visibleClusterLines: string[] = [];
397
+ private hoveredRootLine: number | null = null;
398
+ private selectionArea: SelectionArea | null = null;
399
+ private selectionAnchor: SelectionPoint | null = null;
400
+ private selectionFocus: SelectionPoint | null = null;
401
+ private selectionDragging = false;
402
+ private preserveSelectionFocusOnRelease = false;
403
+ private lastLeftPress: { area: SelectionArea; line: number; at: number } | null = null;
404
+ private consumedClusterClick = false;
405
+ private pendingImageCleanup = false;
406
+
407
+ constructor(options: TerminalSplitCompositorOptions) {
408
+ this.tui = options.tui;
409
+ this.terminal = options.terminal;
410
+ this.renderCluster = options.renderCluster;
411
+ this.getShowHardwareCursor = options.getShowHardwareCursor ?? (() => false);
412
+ this.mouseScroll = options.mouseScroll !== false;
413
+ this.keyboardScrollShortcuts = options.keyboardScrollShortcuts ?? DEFAULT_KEYBOARD_SCROLL_SHORTCUTS;
414
+ this.jumpToBottomShortcut = options.jumpToBottomShortcut ?? null;
415
+ this.onJumpToBottom = options.onJumpToBottom ?? null;
416
+ this.onCopySelection = options.onCopySelection ?? null;
417
+ this.onClusterClick = options.onClusterClick ?? null;
418
+ this.onRootClick = options.onRootClick ?? null;
419
+ this.onRootHover = options.onRootHover ?? null;
420
+ this.decorateRootLines = options.decorateRootLines ?? null;
421
+ this.onReachRootBottom = options.onReachRootBottom ?? null;
422
+ this.rowsDescriptor = descriptorForRows(options.terminal);
423
+ this.originalWrite = options.terminal.write.bind(options.terminal);
424
+ this.originalDoRender = typeof options.tui.doRender === "function" ? options.tui.doRender.bind(options.tui) : null;
425
+ this.originalRender = typeof options.tui.render === "function" ? options.tui.render.bind(options.tui) : null;
426
+ }
427
+
428
+ install(): void {
429
+ if (this.installed) return;
430
+ if (typeof this.terminal.write !== "function") {
431
+ throw new Error("[powerline-footer] Fixed editor compositor expected terminal.write(data) to exist");
432
+ }
433
+
434
+ this.originalWrite(
435
+ beginSynchronizedOutput()
436
+ + enterAlternateScreen()
437
+ + this.enableAlternateScreenKeyboardMode()
438
+ + disableAlternateScrollMode()
439
+ + this.mouseReportingStateGuard()
440
+ + endSynchronizedOutput(),
441
+ );
442
+ this.emergencyCleanup = () => {
443
+ if (!this.disposed) {
444
+ this.restoreTerminalStateForExit();
445
+ }
446
+ };
447
+ process.once("exit", this.emergencyCleanup);
448
+
449
+ Object.defineProperty(this.terminal, "rows", {
450
+ configurable: true,
451
+ get: () => this.getScrollableRows(),
452
+ });
453
+
454
+ if (this.originalRender) {
455
+ this.tui.render = (width: number) => this.renderScrollableRoot(width);
456
+ }
457
+
458
+ if (typeof this.tui.addInputListener === "function") {
459
+ this.removeInputListener = this.tui.addInputListener((data: string) => this.handleInput(data));
460
+ }
461
+
462
+ this.terminal.write = (data: string) => this.write(data);
463
+ if (this.originalDoRender) {
464
+ this.tui.doRender = () => {
465
+ this.renderPassActive = true;
466
+ this.wroteDuringRenderPass = false;
467
+ this.renderPassCluster = null;
468
+ try {
469
+ this.originalDoRender?.();
470
+ // Patched terminal writes already append the fixed cluster. Repainting
471
+ // again here doubles terminal output and is a major source of flicker.
472
+ if (!this.wroteDuringRenderPass) this.requestRepaint();
473
+ } finally {
474
+ this.renderPassActive = false;
475
+ this.wroteDuringRenderPass = false;
476
+ this.renderPassCluster = null;
477
+ }
478
+ };
479
+ }
480
+ if (typeof this.tui.compositeLineAt === "function") {
481
+ this.originalCompositeLineAt = this.tui.compositeLineAt.bind(this.tui) as CompositeLineAt;
482
+ this.tui.compositeLineAt = (
483
+ baseLine: string,
484
+ overlayLine: string,
485
+ startCol: number,
486
+ overlayWidth: number,
487
+ totalWidth: number,
488
+ ) => this.originalCompositeLineAt?.(
489
+ normalizeOverlayCompositionLine(baseLine),
490
+ normalizeOverlayCompositionLine(overlayLine),
491
+ startCol,
492
+ overlayWidth,
493
+ totalWidth,
494
+ ) ?? "";
495
+ }
496
+ this.installed = true;
497
+ }
498
+
499
+ hideRenderable(target: PatchedRenderable): void {
500
+ if (this.patchedRenders.some((patch) => patch.target === target)) return;
501
+ const originalRender = target.render.bind(target);
502
+ this.patchedRenders.push({ target, originalRender });
503
+ target.render = () => [];
504
+ }
505
+
506
+ renderHidden(target: PatchedRenderable, width: number): string[] {
507
+ const patch = this.patchedRenders.find((candidate) => candidate.target === target);
508
+ const render = patch?.originalRender ?? target.render.bind(target);
509
+ return render(width);
510
+ }
511
+
512
+ jumpToPreviousRootTarget(targetLines: readonly number[]): boolean {
513
+ return this.jumpToRootTarget(targetLines, "previous");
514
+ }
515
+
516
+ jumpToNextRootTarget(targetLines: readonly number[]): boolean {
517
+ return this.jumpToRootTarget(targetLines, "next");
518
+ }
519
+
520
+ jumpToRootBottom(): boolean {
521
+ if (this.disposed || this.hasVisibleOverlay()) return false;
522
+
523
+ const moved = this.scrollOffset !== 0;
524
+ this.clearSelection();
525
+ this.lastLeftPress = null;
526
+ this.scrollOffset = 0;
527
+ this.pendingImageCleanup = true;
528
+
529
+ // Always force a full repaint. After the first jump, some terminals can
530
+ // move their physical viewport while our logical offset remains zero; the
531
+ // old early return then made every later Ctrl+End a no-op.
532
+ if (typeof this.tui.requestRender === "function") {
533
+ this.tui.requestRender(true);
534
+ }
535
+ this.requestRepaint();
536
+ return moved;
537
+ }
538
+
539
+ /** Whether the chat viewport is currently following the newest output. */
540
+ isAtRootBottom(): boolean {
541
+ return this.scrollOffset === 0;
542
+ }
543
+
544
+ /**
545
+ * Drop viewport state whose line indexes belong to an obsolete transcript.
546
+ * Compaction rebuilds historical message components, so retaining expansion
547
+ * anchors or the previous line count makes later tool expansion drift.
548
+ */
549
+ resetTranscriptLayout(): void {
550
+ if (this.disposed) return;
551
+
552
+ this.toolExpansionStarts.clear();
553
+ this.clearSelection();
554
+ this.lastLeftPress = null;
555
+ this.hoveredRootLine = null;
556
+ this.rootLines = [];
557
+ this.visibleRootLines = [];
558
+ this.visibleRootStart = 0;
559
+ this.lastRootLineCount = 0;
560
+ this.scrollOffset = 0;
561
+ this.maxScrollOffset = 0;
562
+ this.pendingImageCleanup = true;
563
+
564
+ if (typeof this.tui.requestRender === "function") {
565
+ this.tui.requestRender(true);
566
+ }
567
+ }
568
+
569
+ private jumpToRootTarget(targetLines: readonly number[], direction: "previous" | "next"): boolean {
570
+ if (this.disposed || targetLines.length === 0 || this.hasVisibleOverlay()) return false;
571
+
572
+ const start = this.visibleRootStart;
573
+ const candidates = direction === "previous"
574
+ ? targetLines.filter((line) => line < start).sort((a, b) => b - a)
575
+ : targetLines.filter((line) => line > start).sort((a, b) => a - b);
576
+
577
+ for (const target of candidates) {
578
+ const nextOffset = Math.max(0, Math.min(
579
+ this.lastRootLineCount - Math.max(1, this.visibleScrollableRows) - target,
580
+ this.maxScrollOffset,
581
+ ));
582
+ if (nextOffset === this.scrollOffset) continue;
583
+
584
+ this.clearSelection();
585
+ this.lastLeftPress = null;
586
+ this.scrollOffset = nextOffset;
587
+ this.pendingImageCleanup = true;
588
+ this.requestRender();
589
+ return true;
590
+ }
591
+
592
+ return false;
593
+ }
594
+
595
+ requestRepaint(): void {
596
+ if (this.disposed || this.hasVisibleOverlay()) return;
597
+ const rawRows = this.getRawRows();
598
+ const width = Math.max(1, this.terminal.columns || 80);
599
+ const cluster = this.getCluster(width, rawRows);
600
+ if (cluster.lines.length === 0) return;
601
+
602
+ this.originalWrite(
603
+ beginSynchronizedOutput()
604
+ + disableAutoWrap()
605
+ + buildFixedClusterPaint(this.decorateCluster(cluster), rawRows, width, this.getShowHardwareCursor())
606
+ + enableAutoWrap()
607
+ + this.mouseReportingStateGuard()
608
+ + endSynchronizedOutput(),
609
+ );
610
+ }
611
+
612
+ dispose(options: DisposeOptions = {}): void {
613
+ if (this.disposed) return;
614
+ this.disposed = true;
615
+
616
+ for (const patch of this.patchedRenders.splice(0)) {
617
+ patch.target.render = patch.originalRender;
618
+ }
619
+
620
+ this.removeInputListener?.();
621
+ this.removeInputListener = null;
622
+ if (this.emergencyCleanup) {
623
+ process.removeListener("exit", this.emergencyCleanup);
624
+ this.emergencyCleanup = null;
625
+ }
626
+ if (this.mouseReportingResumeTimer) {
627
+ clearTimeout(this.mouseReportingResumeTimer);
628
+ this.mouseReportingResumeTimer = null;
629
+ }
630
+ if (this.clipboardRestoreTimer) {
631
+ clearTimeout(this.clipboardRestoreTimer);
632
+ this.clipboardRestoreTimer = null;
633
+ }
634
+
635
+ this.terminal.write = this.originalWrite;
636
+ if (this.originalDoRender) {
637
+ this.tui.doRender = this.originalDoRender;
638
+ }
639
+ if (this.originalRender) {
640
+ this.tui.render = this.originalRender;
641
+ }
642
+ if (this.originalCompositeLineAt) {
643
+ this.tui.compositeLineAt = this.originalCompositeLineAt;
644
+ this.originalCompositeLineAt = null;
645
+ }
646
+ if (this.rowsDescriptor) {
647
+ Object.defineProperty(this.terminal, "rows", this.rowsDescriptor);
648
+ } else {
649
+ Reflect.deleteProperty(this.terminal, "rows");
650
+ }
651
+
652
+ this.restoreTerminalState(options);
653
+ }
654
+
655
+ private getRawRows(): number {
656
+ return Math.max(2, readRows(this.terminal, this.rowsDescriptor));
657
+ }
658
+
659
+ private getScrollableRows(): number {
660
+ if (this.disposed || this.writing || this.renderingCluster || this.checkingOverlay || this.hasVisibleOverlay()) {
661
+ return this.getRawRows();
662
+ }
663
+
664
+ const rawRows = this.getRawRows();
665
+ const width = Math.max(1, this.terminal.columns || 80);
666
+ const cluster = this.getCluster(width, rawRows);
667
+ return Math.max(1, rawRows - cluster.lines.length);
668
+ }
669
+
670
+ private renderScrollableRoot(width: number): string[] {
671
+ if (!this.originalRender || this.disposed || this.renderingScrollableRoot) {
672
+ return this.originalRender?.(width) ?? [];
673
+ }
674
+
675
+ if (this.hasVisibleOverlay()) {
676
+ return this.originalRender(width).map((line) => sanitizeOverlayBaseLine(line, Math.max(1, width)));
677
+ }
678
+
679
+ this.renderingScrollableRoot = true;
680
+ try {
681
+ const start = this.refreshRootWindow(width);
682
+ return this.visibleRootLines.map((line, index) => {
683
+ const hovered = this.renderHoverHighlight(line, start + index, "root");
684
+ const selected = this.renderSelectionHighlight(hovered, start + index, "root");
685
+ return sanitizeLine(selected, Math.max(1, width));
686
+ });
687
+ } finally {
688
+ this.renderingScrollableRoot = false;
689
+ }
690
+ }
691
+
692
+ private refreshRootWindow(width: number): number {
693
+ if (!this.originalRender) return this.updateVisibleRootWindow();
694
+
695
+ const rawRows = this.getRawRows();
696
+ const renderWidth = Math.max(1, width);
697
+ const cluster = this.getCluster(renderWidth, rawRows);
698
+ const scrollableRows = Math.max(1, rawRows - cluster.lines.length);
699
+ const renderedLines = this.originalRender(renderWidth);
700
+ const lines = this.decorateRootLines?.(renderedLines) ?? renderedLines;
701
+ this.rootLines = lines;
702
+ if (!this.suppressRootGrowthAdjustment
703
+ && this.scrollOffset > 0
704
+ && this.lastRootLineCount > 0
705
+ && lines.length > this.lastRootLineCount) {
706
+ this.scrollOffset += lines.length - this.lastRootLineCount;
707
+ }
708
+ this.lastRootLineCount = lines.length;
709
+ const previousMaxScrollOffset = this.maxScrollOffset;
710
+ this.maxScrollOffset = Math.max(0, lines.length - scrollableRows);
711
+ const nextScrollOffset = Math.max(0, Math.min(this.scrollOffset, this.maxScrollOffset));
712
+ if (nextScrollOffset !== this.scrollOffset || this.maxScrollOffset !== previousMaxScrollOffset) {
713
+ this.pendingImageCleanup = true;
714
+ }
715
+ this.scrollOffset = nextScrollOffset;
716
+
717
+ return this.updateVisibleRootWindow(scrollableRows);
718
+ }
719
+
720
+ private handleInput(data: string): { consume?: boolean; data?: string } | undefined {
721
+ if (this.disposed || this.hasVisibleOverlay()) return undefined;
722
+
723
+ // Handle this at the compositor input layer rather than relying on the
724
+ // editor wrapper. Pi can replace/rebind the editor during a session, which
725
+ // made the shortcut work once and then disappear on subsequent scrolls.
726
+ if (
727
+ !isKeyRelease(data)
728
+ && this.jumpToBottomShortcut
729
+ && matchesConfiguredShortcut(data, this.jumpToBottomShortcut)
730
+ ) {
731
+ this.jumpToRootBottom();
732
+ this.onJumpToBottom?.();
733
+ return { consume: true };
734
+ }
735
+
736
+ const mousePackets = this.mouseScroll ? parseSgrMousePackets(data) : null;
737
+ if (mousePackets) {
738
+ for (const packet of mousePackets) {
739
+ this.handleMousePacket(packet);
740
+ }
741
+ return { consume: true };
742
+ }
743
+
744
+ const keyboardDelta = parseKeyboardScrollDelta(data, this.keyboardScrollShortcuts);
745
+ if (keyboardDelta === 0) return undefined;
746
+
747
+ this.scrollBy(keyboardDelta);
748
+ return { consume: true };
749
+ }
750
+
751
+ private handleMousePacket(packet: SgrMousePacket): void {
752
+ const delta = mouseScrollDelta(packet);
753
+ if (delta !== 0) {
754
+ this.selectionDragging = false;
755
+ this.scrollBy(delta);
756
+ return;
757
+ }
758
+
759
+ // Mode 1003 reports every mouse move. Hover can use the last rendered
760
+ // viewport; rebuilding the whole root for each packet causes visible stalls.
761
+ if (isMouseMotion(packet) && !this.selectionDragging) {
762
+ const location = this.selectionLocationForPacket(packet);
763
+ const rootText = location?.area === "root"
764
+ ? this.visibleRootLines[location.point.line - this.visibleRootStart] ?? ""
765
+ : "";
766
+ const nextHoveredLine = location?.area === "root" && this.onRootHover?.(
767
+ location.point.line,
768
+ location.point.col,
769
+ rootText,
770
+ )
771
+ ? location.point.line
772
+ : null;
773
+ if (nextHoveredLine !== this.hoveredRootLine) {
774
+ this.hoveredRootLine = nextHoveredLine;
775
+ this.requestRender();
776
+ }
777
+ return;
778
+ }
779
+
780
+ this.refreshRootWindow(Math.max(1, this.terminal.columns || 80));
781
+ const location = this.selectionLocationForPacket(packet);
782
+
783
+ if (isRightPress(packet)) {
784
+ this.selectionDragging = false;
785
+ this.preserveSelectionFocusOnRelease = false;
786
+ const selectedText = this.isLocationInsideSelection(location) ? this.getSelectedText() : "";
787
+ if (selectedText) {
788
+ this.onCopySelection?.(selectedText);
789
+ this.lastLeftPress = null;
790
+ this.pauseMouseReportingForContextMenu(selectedText);
791
+ return;
792
+ }
793
+
794
+ this.clearSelection();
795
+ this.lastLeftPress = null;
796
+ this.pauseMouseReportingForContextMenu();
797
+ return;
798
+ }
799
+
800
+ if (this.scrollSelectionAtViewportEdge(packet)) return;
801
+ if (isMouseRelease(packet) && this.consumedClusterClick) {
802
+ this.consumedClusterClick = false;
803
+ this.clearSelection();
804
+ this.lastLeftPress = null;
805
+ return;
806
+ }
807
+ if (this.selectionDragging && isMouseRelease(packet)) {
808
+ this.finishSelection(packet, location);
809
+ return;
810
+ }
811
+
812
+ if (!location) return;
813
+
814
+ if (isLeftPress(packet)) {
815
+ if (location.area === "root" && this.onRootClick) {
816
+ const rootText = this.visibleRootLines[location.point.line - this.visibleRootStart] ?? "";
817
+ // Keep the clicked row anchored while a tool opens downward. Each tool
818
+ // remembers the viewport start from before expansion so collapsing it can
819
+ // return to exactly that position, even after scrolling inside the panel.
820
+ const viewportStartBeforeClick = this.visibleRootStart;
821
+ this.suppressRootGrowthAdjustment = true;
822
+ let clickResult: boolean | RootClickResult = false;
823
+ try {
824
+ clickResult = this.onRootClick(location.point.line, location.point.col, rootText);
825
+ if (clickResult) {
826
+ this.refreshRootWindow(Math.max(1, this.terminal.columns || 80));
827
+ const detail = typeof clickResult === "object" ? clickResult : null;
828
+ let targetStart = viewportStartBeforeClick;
829
+ if (detail?.key && detail.action === "expand") {
830
+ this.toolExpansionStarts.set(detail.key, viewportStartBeforeClick);
831
+ } else if (detail?.key && detail.action === "collapse") {
832
+ targetStart = this.toolExpansionStarts.get(detail.key) ?? viewportStartBeforeClick;
833
+ this.toolExpansionStarts.delete(detail.key);
834
+ }
835
+ this.scrollOffset = Math.max(0, Math.min(
836
+ this.rootLines.length - Math.max(1, this.visibleScrollableRows) - targetStart,
837
+ this.maxScrollOffset,
838
+ ));
839
+ this.updateVisibleRootWindow();
840
+ }
841
+ } finally {
842
+ this.suppressRootGrowthAdjustment = false;
843
+ }
844
+ if (clickResult) {
845
+ this.clearSelection();
846
+ this.lastLeftPress = null;
847
+ this.consumedClusterClick = true;
848
+ this.requestRender();
849
+ return;
850
+ }
851
+ }
852
+
853
+ if (location.area === "cluster" && this.onClusterClick?.(
854
+ location.point.line,
855
+ location.point.col,
856
+ this.visibleClusterLines[location.point.line] ?? "",
857
+ )) {
858
+ this.clearSelection();
859
+ this.lastLeftPress = null;
860
+ this.consumedClusterClick = true;
861
+ return;
862
+ }
863
+
864
+ this.startSelection(location);
865
+ return;
866
+ }
867
+
868
+ if (this.selectionDragging && isLeftDrag(packet) && location.area === this.selectionArea) {
869
+ this.lastLeftPress = null;
870
+ this.preserveSelectionFocusOnRelease = false;
871
+ this.selectionFocus = location.point;
872
+ this.requestRender();
873
+ return;
874
+ }
875
+ }
876
+
877
+ private updateVisibleRootWindow(scrollableRows = this.visibleScrollableRows): number {
878
+ const rows = Math.max(1, scrollableRows);
879
+ const start = Math.max(0, this.rootLines.length - rows - this.scrollOffset);
880
+ const visibleLines = this.rootLines.slice(start, start + rows);
881
+ while (visibleLines.length < rows) {
882
+ visibleLines.push("");
883
+ }
884
+
885
+ this.visibleRootStart = start;
886
+ this.visibleScrollableRows = rows;
887
+ this.visibleRootLines = visibleLines;
888
+ return start;
889
+ }
890
+
891
+ private finishSelection(packet: SgrMousePacket, location: SelectionLocation | null): void {
892
+ if (!this.preserveSelectionFocusOnRelease) {
893
+ this.selectionFocus = location?.area === this.selectionArea
894
+ ? location.point
895
+ : this.clampedSelectionPointForPacket(packet, this.selectionArea);
896
+ }
897
+
898
+ this.preserveSelectionFocusOnRelease = false;
899
+ this.selectionDragging = false;
900
+ const selectedText = this.getSelectedText();
901
+ if (selectedText) {
902
+ this.lastLeftPress = null;
903
+ this.onCopySelection?.(selectedText);
904
+ } else {
905
+ this.clearSelection();
906
+ }
907
+ this.requestRender();
908
+ }
909
+
910
+ private startSelection(location: SelectionLocation): void {
911
+ const now = Date.now();
912
+ const line = location.point.line;
913
+ if (
914
+ this.lastLeftPress
915
+ && this.lastLeftPress.area === location.area
916
+ && this.lastLeftPress.line === line
917
+ && now - this.lastLeftPress.at <= DOUBLE_CLICK_MS
918
+ ) {
919
+ this.selectionArea = location.area;
920
+ this.selectionAnchor = { line, col: 0 };
921
+ this.selectionFocus = { line, col: this.selectionLineWidth(location.area, line) };
922
+ this.selectionDragging = true;
923
+ this.preserveSelectionFocusOnRelease = true;
924
+ this.lastLeftPress = null;
925
+ this.requestRender();
926
+ return;
927
+ }
928
+
929
+ this.selectionArea = location.area;
930
+ this.selectionAnchor = location.point;
931
+ this.selectionFocus = location.point;
932
+ this.selectionDragging = true;
933
+ this.preserveSelectionFocusOnRelease = false;
934
+ this.lastLeftPress = { area: location.area, line, at: now };
935
+ this.requestRender();
936
+ }
937
+
938
+ private selectionLocationForPacket(packet: SgrMousePacket): SelectionLocation | null {
939
+ if (packet.row < 1) return null;
940
+
941
+ const col = Math.max(0, packet.col - 1);
942
+ if (packet.row <= this.visibleScrollableRows) {
943
+ return {
944
+ area: "root",
945
+ point: { line: this.visibleRootStart + packet.row - 1, col },
946
+ };
947
+ }
948
+
949
+ const clusterLine = packet.row - this.visibleScrollableRows - 1;
950
+ if (clusterLine >= this.visibleClusterLines.length) return null;
951
+
952
+ return {
953
+ area: "cluster",
954
+ point: { line: clusterLine, col },
955
+ };
956
+ }
957
+
958
+ private scrollSelectionAtViewportEdge(packet: SgrMousePacket): boolean {
959
+ if (!this.selectionDragging || this.selectionArea !== "root" || !isLeftDrag(packet)) return false;
960
+
961
+ const delta = packet.row <= 1 ? 1 : packet.row >= this.visibleScrollableRows ? -1 : 0;
962
+ if (delta === 0) return false;
963
+
964
+ const nextOffset = Math.max(0, Math.min(this.scrollOffset + delta, this.maxScrollOffset));
965
+ if (nextOffset === this.scrollOffset) return false;
966
+
967
+ this.lastLeftPress = null;
968
+ this.preserveSelectionFocusOnRelease = true;
969
+ this.scrollOffset = nextOffset;
970
+ this.pendingImageCleanup = true;
971
+ const start = this.updateVisibleRootWindow();
972
+ const edgeLine = delta > 0 ? start : start + Math.max(0, this.visibleScrollableRows - 1);
973
+ this.selectionFocus = {
974
+ line: edgeLine,
975
+ col: Math.max(0, packet.col - 1),
976
+ };
977
+ this.requestRender();
978
+ return true;
979
+ }
980
+
981
+ private clampedSelectionPointForPacket(packet: SgrMousePacket, area: SelectionArea | null): SelectionPoint {
982
+ if (area === "cluster") {
983
+ return {
984
+ line: Math.max(0, Math.min(packet.row - this.visibleScrollableRows - 1, this.visibleClusterLines.length - 1)),
985
+ col: Math.max(0, packet.col - 1),
986
+ };
987
+ }
988
+
989
+ const row = Math.max(1, Math.min(packet.row, this.visibleScrollableRows));
990
+ return {
991
+ line: this.visibleRootStart + row - 1,
992
+ col: Math.max(0, packet.col - 1),
993
+ };
994
+ }
995
+
996
+ private renderHoverHighlight(line: string, lineIndex: number, area: SelectionArea): string {
997
+ if (area !== "root" || this.hoveredRootLine !== lineIndex) return line;
998
+ const plain = stripAnsi(line);
999
+ if (!plain.trim()) return line;
1000
+ return `\x1b[1;97m${plain}\x1b[22;39m`;
1001
+ }
1002
+
1003
+ private renderSelectionHighlight(line: string, lineIndex: number, area: SelectionArea): string {
1004
+ const range = this.getSelectionRangeForLine(lineIndex, area);
1005
+ if (!range) return line;
1006
+
1007
+ const plain = stripAnsi(line);
1008
+ const startCol = Math.max(0, Math.min(range.startCol, visibleWidth(plain)));
1009
+ const endCol = Math.max(startCol, Math.min(range.endCol, visibleWidth(plain)));
1010
+ if (startCol === endCol) return line;
1011
+
1012
+ const before = sliceColumns(plain, 0, startCol);
1013
+ const selected = sliceColumns(plain, startCol, endCol);
1014
+ const after = sliceColumns(plain, endCol, Number.POSITIVE_INFINITY);
1015
+ return `${before}\x1b[7m${selected}\x1b[27m${after}`;
1016
+ }
1017
+
1018
+ private selectionLineWidth(area: SelectionArea, lineIndex: number): number {
1019
+ const lines = area === "root" ? this.visibleRootLines : this.visibleClusterLines;
1020
+ const firstLine = area === "root" ? this.visibleRootStart : 0;
1021
+ return visibleWidth(stripAnsi(lines[lineIndex - firstLine] ?? ""));
1022
+ }
1023
+
1024
+ private getSelectedText(): string {
1025
+ if (!this.selectionArea || !this.selectionAnchor || !this.selectionFocus) return "";
1026
+
1027
+ const start = compareSelectionPoints(this.selectionAnchor, this.selectionFocus) <= 0
1028
+ ? this.selectionAnchor
1029
+ : this.selectionFocus;
1030
+ const end = start === this.selectionAnchor ? this.selectionFocus : this.selectionAnchor;
1031
+ if (start.line === end.line && start.col === end.col) return "";
1032
+
1033
+ const lines = this.selectionArea === "root" ? this.rootLines : this.visibleClusterLines;
1034
+ const selected: string[] = [];
1035
+ for (let lineIndex = start.line; lineIndex <= end.line; lineIndex++) {
1036
+ const line = stripAnsi(lines[lineIndex] ?? "");
1037
+ const startCol = lineIndex === start.line ? start.col : 0;
1038
+ const endCol = lineIndex === end.line ? end.col : Number.POSITIVE_INFINITY;
1039
+ selected.push(sliceColumns(line, startCol, endCol));
1040
+ }
1041
+
1042
+ return selected.join("\n").replace(/[ \t]+$/gm, "").trimEnd();
1043
+ }
1044
+
1045
+ private getSelectionRangeForLine(lineIndex: number, area: SelectionArea): { startCol: number; endCol: number } | null {
1046
+ if (this.selectionArea !== area || !this.selectionAnchor || !this.selectionFocus) return null;
1047
+
1048
+ const start = compareSelectionPoints(this.selectionAnchor, this.selectionFocus) <= 0
1049
+ ? this.selectionAnchor
1050
+ : this.selectionFocus;
1051
+ const end = start === this.selectionAnchor ? this.selectionFocus : this.selectionAnchor;
1052
+ if (lineIndex < start.line || lineIndex > end.line) return null;
1053
+
1054
+ return {
1055
+ startCol: lineIndex === start.line ? start.col : 0,
1056
+ endCol: lineIndex === end.line ? end.col : Number.POSITIVE_INFINITY,
1057
+ };
1058
+ }
1059
+
1060
+ private isLocationInsideSelection(location: SelectionLocation | null): boolean {
1061
+ if (!location || location.area !== this.selectionArea) return false;
1062
+ const range = this.getSelectionRangeForLine(location.point.line, location.area);
1063
+ return Boolean(range && location.point.col >= range.startCol && location.point.col < range.endCol);
1064
+ }
1065
+
1066
+ private scrollBy(delta: number): void {
1067
+ const width = Math.max(1, this.terminal.columns || 80);
1068
+ this.refreshRootWindow(width);
1069
+
1070
+ const wasScrolled = this.scrollOffset > 0;
1071
+ const nextOffset = Math.max(0, Math.min(this.scrollOffset + delta, this.maxScrollOffset));
1072
+ if (nextOffset === this.scrollOffset) return;
1073
+
1074
+ this.clearSelection();
1075
+ this.lastLeftPress = null;
1076
+ this.hoveredRootLine = null;
1077
+ this.scrollOffset = nextOffset;
1078
+ this.pendingImageCleanup = true;
1079
+ if (wasScrolled && nextOffset === 0) this.onReachRootBottom?.();
1080
+ // This writes the complete scrollable viewport synchronously; scheduling a
1081
+ // second full TUI render here only repaints the same frame again.
1082
+ this.repaintScrollableViewport(width);
1083
+ }
1084
+
1085
+ private requestRender(): void {
1086
+ if (typeof this.tui.requestRender === "function") {
1087
+ this.tui.requestRender();
1088
+ }
1089
+ }
1090
+
1091
+ private repaintScrollableViewport(width: number): void {
1092
+ if (this.disposed || this.writing || this.hasVisibleOverlay()) return;
1093
+
1094
+ const rawRows = this.getRawRows();
1095
+ const cluster = this.getCluster(width, rawRows);
1096
+ const scrollableRows = Math.max(1, rawRows - cluster.lines.length);
1097
+ const start = this.updateVisibleRootWindow(scrollableRows);
1098
+ let buffer = beginSynchronizedOutput()
1099
+ + this.consumePendingImageCleanup()
1100
+ + disableAutoWrap()
1101
+ + setScrollRegion(1, scrollableRows)
1102
+ + moveCursor(1, 1);
1103
+
1104
+ for (let row = 0; row < scrollableRows; row++) {
1105
+ if (row > 0) buffer += "\r\n";
1106
+ buffer += clearLine();
1107
+ const hovered = this.renderHoverHighlight(this.visibleRootLines[row] ?? "", start + row, "root");
1108
+ buffer += sanitizeLine(this.renderSelectionHighlight(hovered, start + row, "root"), width);
1109
+ }
1110
+
1111
+ buffer += buildFixedClusterPaint(this.decorateCluster(cluster), rawRows, width, this.getShowHardwareCursor());
1112
+ buffer += enableAutoWrap();
1113
+ buffer += this.mouseReportingStateGuard();
1114
+ buffer += endSynchronizedOutput();
1115
+ this.originalWrite(buffer);
1116
+ }
1117
+
1118
+ private pauseMouseReportingForContextMenu(textToRestoreToClipboard: string | null = null): void {
1119
+ if (this.mouseReportingResumeTimer) {
1120
+ clearTimeout(this.mouseReportingResumeTimer);
1121
+ }
1122
+ if (this.clipboardRestoreTimer) {
1123
+ clearTimeout(this.clipboardRestoreTimer);
1124
+ this.clipboardRestoreTimer = null;
1125
+ }
1126
+
1127
+ this.originalWrite(beginSynchronizedOutput() + disableMouseReporting() + endSynchronizedOutput());
1128
+ this.mouseReportingResumeTimer = setTimeout(() => {
1129
+ this.mouseReportingResumeTimer = null;
1130
+ if (!this.disposed) {
1131
+ this.originalWrite(beginSynchronizedOutput() + enableMouseReporting() + endSynchronizedOutput());
1132
+ }
1133
+ }, CONTEXT_MENU_MOUSE_REPORTING_PAUSE_MS);
1134
+
1135
+ if (typeof this.mouseReportingResumeTimer === "object" && "unref" in this.mouseReportingResumeTimer) {
1136
+ this.mouseReportingResumeTimer.unref();
1137
+ }
1138
+
1139
+ const restoreClipboard = this.onCopySelection;
1140
+ if (!textToRestoreToClipboard || !restoreClipboard) return;
1141
+
1142
+ let remainingRestores = Math.ceil(CONTEXT_MENU_SELECTION_RESTORE_WINDOW_MS / CONTEXT_MENU_CLIPBOARD_RESTORE_INTERVAL_MS);
1143
+ const scheduleClipboardRestore = () => {
1144
+ this.clipboardRestoreTimer = setTimeout(() => {
1145
+ this.clipboardRestoreTimer = null;
1146
+ if (this.disposed) return;
1147
+
1148
+ remainingRestores -= 1;
1149
+ if (this.getSelectedText() !== textToRestoreToClipboard) return;
1150
+
1151
+ restoreClipboard(textToRestoreToClipboard);
1152
+ if (remainingRestores > 0) {
1153
+ scheduleClipboardRestore();
1154
+ }
1155
+ }, CONTEXT_MENU_CLIPBOARD_RESTORE_INTERVAL_MS);
1156
+
1157
+ if (typeof this.clipboardRestoreTimer === "object" && "unref" in this.clipboardRestoreTimer) {
1158
+ this.clipboardRestoreTimer.unref();
1159
+ }
1160
+ };
1161
+
1162
+ scheduleClipboardRestore();
1163
+ }
1164
+
1165
+ private clearSelection(): void {
1166
+ this.selectionArea = null;
1167
+ this.selectionAnchor = null;
1168
+ this.selectionFocus = null;
1169
+ this.selectionDragging = false;
1170
+ this.preserveSelectionFocusOnRelease = false;
1171
+ }
1172
+
1173
+ private activeExtendedKeyboardMode(): ExtendedKeyboardMode | null {
1174
+ if (this.terminal.kittyProtocolActive === true) return "kitty";
1175
+ if (Reflect.get(this.terminal, "_modifyOtherKeysActive") === true) return "modifyOtherKeys";
1176
+ return null;
1177
+ }
1178
+
1179
+ private enableAlternateScreenKeyboardMode(): string {
1180
+ this.extendedKeyboardMode = this.activeExtendedKeyboardMode();
1181
+ return this.extendedKeyboardMode ? enableExtendedKeyboardMode(this.extendedKeyboardMode) : "";
1182
+ }
1183
+
1184
+ private restoreTerminalState(options: DisposeOptions = {}): void {
1185
+ const activeMode = this.extendedKeyboardMode ?? this.activeExtendedKeyboardMode();
1186
+ const restoreMainScreenMode = !options.resetExtendedKeyboardModes && this.extendedKeyboardMode === null && activeMode !== null;
1187
+
1188
+ this.originalWrite(
1189
+ beginSynchronizedOutput()
1190
+ + resetScrollRegion()
1191
+ + (this.mouseScroll ? disableMouseReporting() : "")
1192
+ + (activeMode ? disableExtendedKeyboardMode(activeMode) : "")
1193
+ + enableAlternateScrollMode()
1194
+ + exitAlternateScreen()
1195
+ + (restoreMainScreenMode && activeMode ? enableExtendedKeyboardMode(activeMode) : "")
1196
+ + (options.resetExtendedKeyboardModes ? resetExtendedKeyboardModes() : "")
1197
+ + endSynchronizedOutput(),
1198
+ );
1199
+ }
1200
+
1201
+ private restoreTerminalStateForExit(): void {
1202
+ try {
1203
+ this.restoreTerminalState({ resetExtendedKeyboardModes: true });
1204
+ } catch {
1205
+ // Process-exit cleanup cannot report useful errors and must not throw.
1206
+ }
1207
+ }
1208
+
1209
+ private write(data: string): void {
1210
+ if (this.renderPassActive) this.wroteDuringRenderPass = true;
1211
+ if (this.disposed || this.writing || this.hasVisibleOverlay()) {
1212
+ this.originalWrite(data);
1213
+ return;
1214
+ }
1215
+
1216
+ this.writing = true;
1217
+ try {
1218
+ const rawRows = this.getRawRows();
1219
+ const width = Math.max(1, this.terminal.columns || 80);
1220
+ const cluster = this.getCluster(width, rawRows);
1221
+ const reservedRows = cluster.lines.length;
1222
+
1223
+ if (reservedRows === 0 || rawRows <= 2) {
1224
+ this.originalWrite(data);
1225
+ return;
1226
+ }
1227
+
1228
+ const scrollBottom = Math.max(1, rawRows - reservedRows);
1229
+ const hardwareCursorRow = typeof this.tui.hardwareCursorRow === "number"
1230
+ ? this.tui.hardwareCursorRow
1231
+ : typeof this.tui.cursorRow === "number"
1232
+ ? this.tui.cursorRow
1233
+ : 0;
1234
+ const viewportTop = typeof this.tui.previousViewportTop === "number" ? this.tui.previousViewportTop : 0;
1235
+ const screenRow = Math.max(1, Math.min(scrollBottom, hardwareCursorRow - viewportTop + 1));
1236
+ const buffer = beginSynchronizedOutput()
1237
+ + this.consumePendingImageCleanup()
1238
+ + disableAutoWrap()
1239
+ + setScrollRegion(1, scrollBottom)
1240
+ + moveCursor(screenRow, 1)
1241
+ + data
1242
+ + buildFixedClusterPaint(this.decorateCluster(cluster), rawRows, width, this.getShowHardwareCursor())
1243
+ + enableAutoWrap()
1244
+ + this.mouseReportingStateGuard()
1245
+ + endSynchronizedOutput();
1246
+
1247
+ this.originalWrite(buffer);
1248
+ } finally {
1249
+ this.writing = false;
1250
+ }
1251
+ }
1252
+
1253
+ private consumePendingImageCleanup(): string {
1254
+ if (!this.pendingImageCleanup) return "";
1255
+ this.pendingImageCleanup = false;
1256
+ return deleteAllKittyImages();
1257
+ }
1258
+
1259
+ private mouseReportingStateGuard(): string {
1260
+ return this.mouseScroll && !this.mouseReportingResumeTimer ? enableMouseReporting() : "";
1261
+ }
1262
+
1263
+ private getCluster(width: number, terminalRows: number): FixedEditorClusterRender {
1264
+ if (
1265
+ this.renderPassActive &&
1266
+ this.renderPassCluster?.width === width &&
1267
+ this.renderPassCluster.terminalRows === terminalRows
1268
+ ) {
1269
+ return this.renderPassCluster.cluster;
1270
+ }
1271
+
1272
+ const cluster = this.withClusterRender(() => this.renderCluster(width, terminalRows));
1273
+ this.visibleClusterLines = cluster.lines;
1274
+ if (this.renderPassActive) {
1275
+ this.renderPassCluster = { width, terminalRows, cluster };
1276
+ }
1277
+ return cluster;
1278
+ }
1279
+
1280
+ private decorateCluster(cluster: FixedEditorClusterRender): FixedEditorClusterRender {
1281
+ if (this.selectionArea !== "cluster") return cluster;
1282
+
1283
+ return {
1284
+ ...cluster,
1285
+ lines: cluster.lines.map((line, index) => this.renderSelectionHighlight(line, index, "cluster")),
1286
+ };
1287
+ }
1288
+
1289
+ private withClusterRender<T>(render: () => T): T {
1290
+ const wasRenderingCluster = this.renderingCluster;
1291
+ this.renderingCluster = true;
1292
+ try {
1293
+ return render();
1294
+ } finally {
1295
+ this.renderingCluster = wasRenderingCluster;
1296
+ }
1297
+ }
1298
+
1299
+ private hasVisibleOverlay(): boolean {
1300
+ if (this.checkingOverlay) return false;
1301
+
1302
+ this.checkingOverlay = true;
1303
+ try {
1304
+ if (typeof this.tui.hasOverlay === "function" && this.tui.hasOverlay()) {
1305
+ return true;
1306
+ }
1307
+
1308
+ const overlayStack = Reflect.get(this.tui, "overlayStack");
1309
+ if (!Array.isArray(overlayStack)) {
1310
+ return false;
1311
+ }
1312
+
1313
+ return overlayStack.some((entry) => entry && entry.hidden !== true);
1314
+ } finally {
1315
+ this.checkingOverlay = false;
1316
+ }
1317
+ }
1318
+ }