pi-cc-extensions 0.8.45 → 0.8.47

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