@oh-my-pi/pi-tui 17.0.1 → 17.0.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.
- package/CHANGELOG.md +24 -0
- package/README.md +1 -1
- package/dist/types/components/editor.d.ts +2 -0
- package/dist/types/terminal.d.ts +21 -2
- package/dist/types/tui.d.ts +5 -0
- package/package.json +3 -3
- package/src/autocomplete.ts +1 -1
- package/src/components/editor.ts +36 -6
- package/src/components/markdown.ts +46 -4
- package/src/stdin-buffer.ts +122 -10
- package/src/terminal-capabilities.ts +6 -5
- package/src/terminal.ts +64 -7
- package/src/tui.ts +74 -27
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [17.0.3] - 2026-07-17
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed multiline pastes arriving without bracketed-paste markers (e.g. Cmd+V in the Codex desktop embedded terminal on macOS) being split into one submit per line: `StdinBuffer` now collects adjacent ESC-free, CR/LF-bearing stdin reads in a fixed 10 ms classification window and coalesces three or more lines into one paste event, while ambiguous one-break input (including Enter batched with a following keystroke) is replayed unchanged ([#5841](https://github.com/can1357/oh-my-pi/issues/5841)).
|
|
10
|
+
- Fixed wrapped OSC 8 links in Markdown tables making cell padding, separators, and adjacent cells clickable ([#5885](https://github.com/can1357/oh-my-pi/issues/5885)).
|
|
11
|
+
- Fixed interactive sessions surviving terminal closure and entering a runaway render loop by stopping the TUI and raising SIGHUP when terminal input closes or output fails ([#5835](https://github.com/can1357/oh-my-pi/issues/5835)).
|
|
12
|
+
- Fixed native cmux SSH pane resizes inserting blank rows into terminal scrollback by routing remote-transport sessions through the in-place repaint path ([#5857](https://github.com/can1357/oh-my-pi/issues/5857)).
|
|
13
|
+
- Fixed the terminal flickering when leaving a fullscreen overlay (e.g. `/settings`) on terminals that re-report their size when the alternate screen buffer toggles: the alt-toggle SIGWINCH echo is height-only, so the resize fast path no longer borrows the alternate screen for it ([#5854](https://github.com/can1357/oh-my-pi/issues/5854)).
|
|
14
|
+
|
|
15
|
+
## [17.0.2] - 2026-07-17
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- Added a fullscreen overlay mouse-tracking opt-out to allow selection-first dialogs to preserve native terminal text selection.
|
|
20
|
+
- Added `Terminal.refreshAppearance()` to allow consumers to manually trigger a refresh of the detected dark/light terminal appearance without periodic polling.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- Fixed an issue where pressing Enter to accept a mid-prompt `/skill:<name>` autocomplete would submit and clear the draft; it now correctly inserts the skill token and leaves the prompt open.
|
|
25
|
+
- Fixed Markdown rendering incorrectly turning local file paths containing `www.` or protocol sequences into HTTP links by requiring a valid GFM left boundary for autolinks.
|
|
26
|
+
- Fixed terminal resize behavior by restoring alternate-screen rendering during drag frames, preventing wrapped fragments from polluting native scrollback while preserving the overlay-exit flicker fix.
|
|
27
|
+
- Added optional right-border scrollbar to the `Editor` component (`setScrollbarVisible`): shows a thumb glyph on the right border when content overflows `maxHeight`, enabling scrollable multi-line editors (e.g. advisor instructions) without losing the submit hint off-screen.
|
|
28
|
+
|
|
5
29
|
## [17.0.1] - 2026-07-16
|
|
6
30
|
|
|
7
31
|
### Added
|
package/README.md
CHANGED
|
@@ -522,7 +522,7 @@ The TUI works with any object implementing the `Terminal` interface:
|
|
|
522
522
|
|
|
523
523
|
```typescript
|
|
524
524
|
interface Terminal {
|
|
525
|
-
start(onInput: (data: string) => void, onResize: () => void): void;
|
|
525
|
+
start(onInput: (data: string) => void, onResize: () => void, onDisconnect?: () => void): void;
|
|
526
526
|
stop(): void;
|
|
527
527
|
write(data: string): void;
|
|
528
528
|
get columns(): number;
|
|
@@ -96,6 +96,8 @@ export declare class Editor implements Component, Focusable {
|
|
|
96
96
|
setImeSafeCursorLayout(enabled: boolean): void;
|
|
97
97
|
getUseTerminalCursor(): boolean;
|
|
98
98
|
setMaxHeight(maxHeight: number | undefined): void;
|
|
99
|
+
/** Enable/disable the right-border scrollbar. Only shown when content overflows. */
|
|
100
|
+
setScrollbarVisible(visible: boolean): void;
|
|
99
101
|
setPaddingX(paddingX: number): void;
|
|
100
102
|
getAutocompleteMaxVisible(): number;
|
|
101
103
|
setAutocompleteMaxVisible(maxVisible: number): void;
|
package/dist/types/terminal.d.ts
CHANGED
|
@@ -31,7 +31,7 @@ export declare function emergencyTerminalRestore(): void;
|
|
|
31
31
|
/** Terminal-reported appearance (dark/light mode). */
|
|
32
32
|
export type TerminalAppearance = "dark" | "light";
|
|
33
33
|
export interface Terminal {
|
|
34
|
-
start(onInput: (data: string) => void, onResize: () => void): void;
|
|
34
|
+
start(onInput: (data: string) => void, onResize: () => void, onDisconnect?: () => void): void;
|
|
35
35
|
stop(): void;
|
|
36
36
|
/**
|
|
37
37
|
* Drain stdin before exiting to prevent Kitty key release events from
|
|
@@ -63,6 +63,16 @@ export interface Terminal {
|
|
|
63
63
|
* already-detected appearance so late subscribers never miss it.
|
|
64
64
|
*/
|
|
65
65
|
onAppearanceChange(callback: (appearance: TerminalAppearance) => void): void;
|
|
66
|
+
/**
|
|
67
|
+
* Issue a single OSC 11 background-color re-query, driving the appearance
|
|
68
|
+
* callbacks through the same parse/dedup pipeline used at startup and on Mode
|
|
69
|
+
* 2031 notifications. Bounded: one probe per call, no timers. Invoked on the
|
|
70
|
+
* user's explicit display-reset gesture (Ctrl+L) so terminals that cannot
|
|
71
|
+
* deliver end-to-end Mode 2031 notifications still pick up a light/dark switch
|
|
72
|
+
* without a restart. Optional so custom Terminals built against older pi-tui
|
|
73
|
+
* versions keep working.
|
|
74
|
+
*/
|
|
75
|
+
refreshAppearance?(): void;
|
|
66
76
|
/** The last detected terminal appearance, or undefined if not yet known. */
|
|
67
77
|
get appearance(): TerminalAppearance | undefined;
|
|
68
78
|
/**
|
|
@@ -93,8 +103,17 @@ export declare class ProcessTerminal implements Terminal {
|
|
|
93
103
|
get keyboardEnhancementExitSequence(): string | null;
|
|
94
104
|
get appearance(): TerminalAppearance | undefined;
|
|
95
105
|
onAppearanceChange(callback: (appearance: TerminalAppearance) => void): void;
|
|
106
|
+
/**
|
|
107
|
+
* Re-query the terminal background via a single OSC 11 probe. Reuses the
|
|
108
|
+
* startup query path — same DA1-sentinel FIFO, pending/queued gating, parsing,
|
|
109
|
+
* dedup, and appearance callbacks — so a light/dark switch is picked up
|
|
110
|
+
* without a restart on terminals lacking end-to-end Mode 2031 notifications.
|
|
111
|
+
* Bounded to one probe per call; no timers are armed. Suppressed while headless
|
|
112
|
+
* or after the terminal is torn down.
|
|
113
|
+
*/
|
|
114
|
+
refreshAppearance(): void;
|
|
96
115
|
onPrivateModeReport(callback: (mode: number, supported: boolean, confirmed?: boolean) => void): void;
|
|
97
|
-
start(onInput: (data: string) => void, onResize: () => void): void;
|
|
116
|
+
start(onInput: (data: string) => void, onResize: () => void, onDisconnect?: () => void): void;
|
|
98
117
|
drainInput(maxMs?: number, idleMs?: number): Promise<void>;
|
|
99
118
|
stop(): void;
|
|
100
119
|
write(data: string): void;
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -236,6 +236,11 @@ export interface OverlayOptions {
|
|
|
236
236
|
* unchanged and still draw over the transcript on the normal screen.
|
|
237
237
|
*/
|
|
238
238
|
fullscreen?: boolean;
|
|
239
|
+
/**
|
|
240
|
+
* Enable terminal mouse reporting while fullscreen. Defaults on; disable it
|
|
241
|
+
* when native terminal text selection takes precedence over pointer events.
|
|
242
|
+
*/
|
|
243
|
+
mouseTracking?: boolean;
|
|
239
244
|
}
|
|
240
245
|
/**
|
|
241
246
|
* Handle returned by showOverlay for controlling the overlay
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-tui",
|
|
4
|
-
"version": "17.0.
|
|
4
|
+
"version": "17.0.3",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"fmt": "biome format --write ."
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@oh-my-pi/pi-natives": "17.0.
|
|
41
|
-
"@oh-my-pi/pi-utils": "17.0.
|
|
40
|
+
"@oh-my-pi/pi-natives": "17.0.3",
|
|
41
|
+
"@oh-my-pi/pi-utils": "17.0.3",
|
|
42
42
|
"lru-cache": "11.5.2",
|
|
43
43
|
"marked": "^18.0.6"
|
|
44
44
|
},
|
package/src/autocomplete.ts
CHANGED
|
@@ -452,7 +452,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
452
452
|
// Preserve the full text-before-cursor for submitted slash
|
|
453
453
|
// commands so the editor's Enter-staleness check still applies
|
|
454
454
|
// completion for ` /sk`. Mid-prompt skill lookup keeps only
|
|
455
|
-
// the slash token because
|
|
455
|
+
// the slash token because acceptance replaces only that token.
|
|
456
456
|
prefix: isMidPromptSkillLookup ? commandText : textBeforeCursor,
|
|
457
457
|
};
|
|
458
458
|
}
|
package/src/components/editor.ts
CHANGED
|
@@ -404,6 +404,10 @@ export class Editor implements Component, Focusable {
|
|
|
404
404
|
#paddingXOverride: number | undefined;
|
|
405
405
|
#maxHeight?: number;
|
|
406
406
|
#scrollOffset: number = 0;
|
|
407
|
+
/** When true, the right border shows a scrollbar track/thumb when content
|
|
408
|
+
* overflows {@link #maxHeight}. Enabled by {@link HookEditorComponent} and
|
|
409
|
+
* other multi-line consumers; single-line consumers are unaffected. */
|
|
410
|
+
#scrollbarVisible = false;
|
|
407
411
|
|
|
408
412
|
// Emacs-style kill ring
|
|
409
413
|
#killRing = new KillRing();
|
|
@@ -555,6 +559,11 @@ export class Editor implements Component, Focusable {
|
|
|
555
559
|
// Don't reset scrollOffset — #updateScrollOffset will clamp it on next render
|
|
556
560
|
}
|
|
557
561
|
|
|
562
|
+
/** Enable/disable the right-border scrollbar. Only shown when content overflows. */
|
|
563
|
+
setScrollbarVisible(visible: boolean): void {
|
|
564
|
+
this.#scrollbarVisible = visible;
|
|
565
|
+
}
|
|
566
|
+
|
|
558
567
|
setPaddingX(paddingX: number): void {
|
|
559
568
|
this.#paddingXOverride = Math.max(0, paddingX);
|
|
560
569
|
}
|
|
@@ -831,6 +840,22 @@ export class Editor implements Component, Focusable {
|
|
|
831
840
|
const visibleLayoutLines = layoutLines.slice(this.#scrollOffset, this.#scrollOffset + visibleContentHeight);
|
|
832
841
|
|
|
833
842
|
const result: string[] = [];
|
|
843
|
+
// Scrollbar: shown only when content overflows and the caller opted in.
|
|
844
|
+
const needsScrollbar = this.#scrollbarVisible && layoutLines.length > visibleContentHeight;
|
|
845
|
+
let scrollbarThumb: { start: number; end: number } | null = null;
|
|
846
|
+
if (needsScrollbar && visibleContentHeight > 0) {
|
|
847
|
+
const thumbSize = Math.max(
|
|
848
|
+
1,
|
|
849
|
+
Math.min(
|
|
850
|
+
Math.floor((visibleContentHeight * visibleContentHeight) / layoutLines.length),
|
|
851
|
+
visibleContentHeight,
|
|
852
|
+
),
|
|
853
|
+
);
|
|
854
|
+
const travel = visibleContentHeight - thumbSize;
|
|
855
|
+
const maxOffset = Math.max(0, layoutLines.length - visibleContentHeight);
|
|
856
|
+
const start = maxOffset === 0 ? 0 : Math.round((this.#scrollOffset / maxOffset) * travel);
|
|
857
|
+
scrollbarThumb = { start, end: start + thumbSize };
|
|
858
|
+
}
|
|
834
859
|
|
|
835
860
|
if (borderVisible) {
|
|
836
861
|
// Render top border: ╭─ [status content] ────────────────╮
|
|
@@ -1054,7 +1079,11 @@ export class Editor implements Component, Focusable {
|
|
|
1054
1079
|
result.push(`${bottomLeft}${displayText}${linePad}${bottomRightAdjusted}`);
|
|
1055
1080
|
} else {
|
|
1056
1081
|
const leftBorder = this.borderColor(`${box.vertical}${padding(paddingX)}`);
|
|
1057
|
-
|
|
1082
|
+
// When scrollbar is active, replace the right border vertical with a
|
|
1083
|
+
// thumb glyph (█) on lines inside the thumb range, keeping the track (│) elsewhere.
|
|
1084
|
+
const inThumb = scrollbarThumb && visibleIndex >= scrollbarThumb.start && visibleIndex < scrollbarThumb.end;
|
|
1085
|
+
const rightGlyph = inThumb ? "█" : box.vertical;
|
|
1086
|
+
const rightBorder = this.borderColor(`${padding(Math.max(0, rightChromeCells - 1))}${rightGlyph}`);
|
|
1058
1087
|
result.push(leftBorder + displayText + linePad + rightBorder);
|
|
1059
1088
|
}
|
|
1060
1089
|
}
|
|
@@ -1189,11 +1218,12 @@ export class Editor implements Component, Focusable {
|
|
|
1189
1218
|
return;
|
|
1190
1219
|
}
|
|
1191
1220
|
|
|
1192
|
-
// If Enter was pressed on a slash command (not an absolute-path
|
|
1193
|
-
// completion sharing the leading-slash prefix), apply and submit
|
|
1221
|
+
// If Enter was pressed on a submitted slash command (not an absolute-path
|
|
1222
|
+
// completion sharing the leading-slash prefix), apply and submit.
|
|
1194
1223
|
if (
|
|
1195
1224
|
(kb.matches(data, "tui.input.submit") || data === "\n") &&
|
|
1196
1225
|
findLeadingSlashCommandStart(this.#autocompletePrefix) !== null &&
|
|
1226
|
+
this.#isInSubmittedSlashCommandContext() &&
|
|
1197
1227
|
!this.#selectedCompletionIsPath()
|
|
1198
1228
|
) {
|
|
1199
1229
|
const selected = this.#autocompleteList.getSelectedItem();
|
|
@@ -1222,7 +1252,7 @@ export class Editor implements Component, Focusable {
|
|
|
1222
1252
|
}
|
|
1223
1253
|
// Don't return - fall through to submission logic
|
|
1224
1254
|
}
|
|
1225
|
-
//
|
|
1255
|
+
// Otherwise, apply the completion without submitting the surrounding draft.
|
|
1226
1256
|
else if (kb.matches(data, "tui.input.submit") || data === "\n") {
|
|
1227
1257
|
const selected = this.#autocompleteList.getSelectedItem();
|
|
1228
1258
|
// Check for stale autocomplete state due to buffer edits since last refresh.
|
|
@@ -2915,8 +2945,8 @@ export class Editor implements Component, Focusable {
|
|
|
2915
2945
|
* via the no-command-match fall-through) share the leading-slash prefix shape
|
|
2916
2946
|
* but must use the live-suffix path rule so the apply slice stays anchored.
|
|
2917
2947
|
* - Mid-prompt skill branch re-anchors when the popup item is a skill and the
|
|
2918
|
-
* current text still ends in a trailing slash token,
|
|
2919
|
-
*
|
|
2948
|
+
* current text still ends in a matching trailing slash token, preventing a
|
|
2949
|
+
* stale selection from replacing a newer skill prefix.
|
|
2920
2950
|
* - `@`-file branch re-anchors via `#extractAtPrefix`; safe when the current text
|
|
2921
2951
|
* still ends in a whitespace-anchored `@<token>`.
|
|
2922
2952
|
* - Everything else is stale — accepting it would corrupt the buffer (issue #4295).
|
|
@@ -592,7 +592,42 @@ const mathEnvBlockExtension: TokenizerAndRendererExtension = {
|
|
|
592
592
|
return (token as { text?: string }).text ?? "";
|
|
593
593
|
},
|
|
594
594
|
};
|
|
595
|
-
|
|
595
|
+
|
|
596
|
+
// GFM's extended autolinks (`www.`, `http://`, `https://`, `ftp://`) may only
|
|
597
|
+
// begin at a valid left boundary: start of line, whitespace, or one of `* _ ~ (`
|
|
598
|
+
// (https://github.github.com/gfm/#autolinks-extension-). marked's bundled `url`
|
|
599
|
+
// tokenizer instead fires after ANY character, so a local path such as
|
|
600
|
+
// `~/meta/www.share/blog/index.dj` is mangled into a `http://www.share/...`
|
|
601
|
+
// link. This inline extension runs before the built-in tokenizer: when an
|
|
602
|
+
// autolink candidate is glued to an invalid preceding character it emits the
|
|
603
|
+
// bare scheme prefix as literal text, so the remainder never reaches the `url`
|
|
604
|
+
// tokenizer at a valid start. Candidates at a legal boundary fall through
|
|
605
|
+
// (return undefined) to marked's own autolink handling unchanged.
|
|
606
|
+
const AUTOLINK_SCHEME_REGEX = /^(?:www\.|https?:\/\/|ftp:\/\/)/i;
|
|
607
|
+
const AUTOLINK_SCHEME_SCAN = /www\.|https?:\/\/|ftp:\/\//i;
|
|
608
|
+
const VALID_AUTOLINK_LEFT_BOUNDARY = /[\s*_~(]/;
|
|
609
|
+
const boundedAutolinkExtension: TokenizerAndRendererExtension = {
|
|
610
|
+
name: "boundedAutolink",
|
|
611
|
+
level: "inline",
|
|
612
|
+
start(src) {
|
|
613
|
+
const m = AUTOLINK_SCHEME_SCAN.exec(src);
|
|
614
|
+
return m ? m.index : undefined;
|
|
615
|
+
},
|
|
616
|
+
tokenizer(src, tokens) {
|
|
617
|
+
const match = AUTOLINK_SCHEME_REGEX.exec(src);
|
|
618
|
+
if (!match) return undefined;
|
|
619
|
+
const prevChar = tokens.at(-1)?.raw?.at(-1);
|
|
620
|
+
// Start of line or a legal delimiter → let marked autolink it.
|
|
621
|
+
if (prevChar === undefined || VALID_AUTOLINK_LEFT_BOUNDARY.test(prevChar)) return undefined;
|
|
622
|
+
// Glued to an invalid character (e.g. `/`, a letter, `.`): consume only
|
|
623
|
+
// the scheme prefix as text so the built-in `url` tokenizer cannot match.
|
|
624
|
+
const raw = match[0];
|
|
625
|
+
return { type: "text", raw, text: raw };
|
|
626
|
+
},
|
|
627
|
+
};
|
|
628
|
+
markdownParser.use({
|
|
629
|
+
extensions: [customHrExtension, mathBlockExtension, mathEnvBlockExtension, mathExtension, boundedAutolinkExtension],
|
|
630
|
+
});
|
|
596
631
|
|
|
597
632
|
// ---------------------------------------------------------------------------
|
|
598
633
|
// Module-level LRU render cache
|
|
@@ -2155,8 +2190,8 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
|
|
|
2155
2190
|
if (token.text === token.href || token.text === hrefForComparison)
|
|
2156
2191
|
result += clickableLinkText + stylePrefix;
|
|
2157
2192
|
else {
|
|
2158
|
-
const styledLinkUrl = this.#theme.linkUrl(`
|
|
2159
|
-
result += clickableLinkText
|
|
2193
|
+
const styledLinkUrl = this.#theme.linkUrl(`(${token.href})`);
|
|
2194
|
+
result += `${clickableLinkText} ${formatHyperlink(styledLinkUrl, token.href)}${stylePrefix}`;
|
|
2160
2195
|
}
|
|
2161
2196
|
break;
|
|
2162
2197
|
}
|
|
@@ -2353,7 +2388,14 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
|
|
|
2353
2388
|
*/
|
|
2354
2389
|
#wrapCellText(text: string, maxWidth: number): string[] {
|
|
2355
2390
|
const cellWidth = Math.max(1, maxWidth);
|
|
2356
|
-
|
|
2391
|
+
// Wrap the whole cell in one call so wrapTextWithAnsi() balances OSC 8
|
|
2392
|
+
// hyperlink state across explicit newlines (e.g. `<br>` rendered as \n);
|
|
2393
|
+
// per-fragment wrapping would drop the reopened link on later rows.
|
|
2394
|
+
const wrapped = wrapTextWithAnsi(text, cellWidth);
|
|
2395
|
+
while (wrapped.length > 1 && wrapped[wrapped.length - 1] === "") {
|
|
2396
|
+
wrapped.pop();
|
|
2397
|
+
}
|
|
2398
|
+
return wrapped;
|
|
2357
2399
|
}
|
|
2358
2400
|
|
|
2359
2401
|
/**
|
package/src/stdin-buffer.ts
CHANGED
|
@@ -62,6 +62,39 @@ const MAX_STRING_SEQ_BYTES = 16 * 1024 * 1024;
|
|
|
62
62
|
// runs at most once per resolved report — never inside the growth loop.
|
|
63
63
|
const SGR_MOUSE_COMPLETE = /^<\d+;\d+;\d+[Mm]$/;
|
|
64
64
|
|
|
65
|
+
// Raw-paste classification holds CR/LF-bearing, ESC-free input briefly so
|
|
66
|
+
// adjacent stdin reads from one unmarked paste can be considered together.
|
|
67
|
+
// Fixed from the first break-bearing read (not an inactivity debounce): normal
|
|
68
|
+
// Enter latency and candidate memory remain bounded even under a continuous
|
|
69
|
+
// stream. Ten milliseconds spans adjacent PTY reads without becoming perceptible.
|
|
70
|
+
const RAW_PASTE_CLASSIFICATION_TIMEOUT_MS = 10;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Whether `text` has two completed logical line breaks (three line segments).
|
|
74
|
+
*
|
|
75
|
+
* A single Enter may be batched with surrounding keystrokes in one stdin read,
|
|
76
|
+
* so one break is ambiguous and must stay on the key path. CRLF counts as one
|
|
77
|
+
* logical break. Content after the second break completes the third segment;
|
|
78
|
+
* until then the classification window keeps buffering.
|
|
79
|
+
*/
|
|
80
|
+
function isRawMultilineBurst(text: string): boolean {
|
|
81
|
+
let breaks = 0;
|
|
82
|
+
for (let i = 0; i < text.length; i++) {
|
|
83
|
+
const code = text.charCodeAt(i);
|
|
84
|
+
if (code === 0x0d) {
|
|
85
|
+
breaks++;
|
|
86
|
+
if (text.charCodeAt(i + 1) === 0x0a) i++;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (code === 0x0a) {
|
|
90
|
+
breaks++;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (breaks >= 2) return true;
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
65
98
|
/**
|
|
66
99
|
* Resolve the exclusive-end index of the escape sequence starting at `pos`
|
|
67
100
|
* (`buffer.charCodeAt(pos)` must be ESC). `resumeSearchFrom` is honored only
|
|
@@ -364,6 +397,8 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
364
397
|
#pendingKittyPrintableCodepoint: number | undefined;
|
|
365
398
|
#pendingKittyPrintableAtMs = 0;
|
|
366
399
|
#escapeSearchOffset = 0;
|
|
400
|
+
#rawPasteCandidate = "";
|
|
401
|
+
#rawPasteTimer?: NodeJS.Timeout;
|
|
367
402
|
|
|
368
403
|
constructor(options: StdinBufferOptions = {}) {
|
|
369
404
|
super();
|
|
@@ -398,20 +433,49 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
398
433
|
this.#clearFlushTimer();
|
|
399
434
|
}
|
|
400
435
|
|
|
401
|
-
if (str.length === 0 && this.#buffer.length === 0) {
|
|
436
|
+
if (str.length === 0 && this.#buffer.length === 0 && this.#rawPasteCandidate.length === 0) {
|
|
402
437
|
this.#emitDataSequence("");
|
|
403
438
|
return;
|
|
404
439
|
}
|
|
405
440
|
|
|
406
|
-
this.#buffer += str;
|
|
407
|
-
|
|
408
441
|
if (this.#pasteMode) {
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
442
|
+
this.#consumePasteChunk(str);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (this.#rawPasteCandidate.length > 0) {
|
|
447
|
+
if (str.indexOf(ESC) !== -1) {
|
|
448
|
+
// Escape-bearing input cannot belong to an unmarked raw paste.
|
|
449
|
+
// Replay the ambiguous prefix as keys before parsing the escape.
|
|
450
|
+
this.#flushRawPasteCandidate();
|
|
451
|
+
} else {
|
|
452
|
+
this.#rawPasteCandidate += str;
|
|
453
|
+
if (isRawMultilineBurst(this.#rawPasteCandidate)) {
|
|
454
|
+
this.#emitRawPasteCandidate();
|
|
455
|
+
}
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (
|
|
461
|
+
this.#buffer.length === 0 &&
|
|
462
|
+
str.indexOf(ESC) === -1 &&
|
|
463
|
+
(str.indexOf("\r") !== -1 || str.indexOf("\n") !== -1)
|
|
464
|
+
) {
|
|
465
|
+
// Hold the first break-bearing read briefly. A split raw paste can
|
|
466
|
+
// then accumulate enough logical lines to classify; an ordinary
|
|
467
|
+
// Enter is replayed unchanged when the fixed window expires.
|
|
468
|
+
this.#rawPasteCandidate = str;
|
|
469
|
+
if (isRawMultilineBurst(str)) {
|
|
470
|
+
this.#emitRawPasteCandidate();
|
|
471
|
+
} else {
|
|
472
|
+
this.#armRawPasteTimer();
|
|
473
|
+
}
|
|
412
474
|
return;
|
|
413
475
|
}
|
|
414
476
|
|
|
477
|
+
this.#buffer += str;
|
|
478
|
+
|
|
415
479
|
const startIndex = this.#buffer.indexOf(BRACKETED_PASTE_START);
|
|
416
480
|
if (startIndex !== -1) {
|
|
417
481
|
if (startIndex > 0) {
|
|
@@ -526,6 +590,46 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
526
590
|
this.emit("paste", content);
|
|
527
591
|
}
|
|
528
592
|
|
|
593
|
+
/** Start one fixed window from the first break-bearing raw read. */
|
|
594
|
+
#armRawPasteTimer(): void {
|
|
595
|
+
if (this.#rawPasteTimer) return;
|
|
596
|
+
this.#rawPasteTimer = setTimeout(() => {
|
|
597
|
+
this.#rawPasteTimer = undefined;
|
|
598
|
+
this.#flushRawPasteCandidate();
|
|
599
|
+
}, RAW_PASTE_CLASSIFICATION_TIMEOUT_MS);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
#clearRawPasteTimer(): void {
|
|
603
|
+
if (this.#rawPasteTimer) {
|
|
604
|
+
clearTimeout(this.#rawPasteTimer);
|
|
605
|
+
this.#rawPasteTimer = undefined;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
#takeRawPasteCandidate(): string {
|
|
610
|
+
this.#clearRawPasteTimer();
|
|
611
|
+
const content = this.#rawPasteCandidate;
|
|
612
|
+
this.#rawPasteCandidate = "";
|
|
613
|
+
return content;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** Emit a classified raw multiline burst through the paste channel. */
|
|
617
|
+
#emitRawPasteCandidate(): void {
|
|
618
|
+
const content = this.#takeRawPasteCandidate();
|
|
619
|
+
this.#pendingKittyPrintableCodepoint = undefined;
|
|
620
|
+
this.emit("paste", content);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** Replay an ambiguous raw candidate as the original per-key data events. */
|
|
624
|
+
#flushRawPasteCandidate(): void {
|
|
625
|
+
const content = this.#takeRawPasteCandidate();
|
|
626
|
+
if (content.length === 0) return;
|
|
627
|
+
const result = extractCompleteSequences(content, 0);
|
|
628
|
+
for (const sequence of result.sequences) {
|
|
629
|
+
this.#emitDataSequence(sequence);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
529
633
|
#emitDataSequence(sequence: string): void {
|
|
530
634
|
const rawCodepoint = sequence.length === 1 ? sequence.codePointAt(0) : undefined;
|
|
531
635
|
if (
|
|
@@ -627,8 +731,12 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
627
731
|
flush(): string[] {
|
|
628
732
|
this.#clearFlushTimer();
|
|
629
733
|
|
|
734
|
+
const rawCandidate = this.#takeRawPasteCandidate();
|
|
735
|
+
const sequences = rawCandidate.length > 0 ? extractCompleteSequences(rawCandidate, 0).sequences : [];
|
|
736
|
+
|
|
630
737
|
if (this.#buffer.length === 0) {
|
|
631
|
-
|
|
738
|
+
this.#pendingKittyPrintableCodepoint = undefined;
|
|
739
|
+
return sequences;
|
|
632
740
|
}
|
|
633
741
|
|
|
634
742
|
const buffered = this.#buffer;
|
|
@@ -641,15 +749,19 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
641
749
|
// emission swallows the double-escape gesture (#3857). Mirror the inline
|
|
642
750
|
// split in `extractCompleteSequences` and deliver two ESC events.
|
|
643
751
|
if (buffered === `${ESC}${ESC}`) {
|
|
644
|
-
|
|
752
|
+
sequences.push(ESC, ESC);
|
|
753
|
+
} else {
|
|
754
|
+
sequences.push(buffered);
|
|
645
755
|
}
|
|
646
|
-
return
|
|
756
|
+
return sequences;
|
|
647
757
|
}
|
|
648
758
|
|
|
649
759
|
clear(): void {
|
|
650
760
|
this.#clearFlushTimer();
|
|
651
761
|
this.#clearPasteWatchdog();
|
|
762
|
+
this.#clearRawPasteTimer();
|
|
652
763
|
this.#buffer = "";
|
|
764
|
+
this.#rawPasteCandidate = "";
|
|
653
765
|
this.#pasteMode = false;
|
|
654
766
|
this.#pasteChunks = [];
|
|
655
767
|
this.#pasteOverlap = "";
|
|
@@ -660,7 +772,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
660
772
|
}
|
|
661
773
|
|
|
662
774
|
getBuffer(): string {
|
|
663
|
-
return this.#buffer
|
|
775
|
+
return `${this.#rawPasteCandidate}${this.#buffer}`;
|
|
664
776
|
}
|
|
665
777
|
|
|
666
778
|
destroy(): void {
|
|
@@ -180,12 +180,13 @@ export class TerminalInfo {
|
|
|
180
180
|
|
|
181
181
|
/** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */
|
|
182
182
|
export function isInsideTerminalMultiplexer(env: NodeJS.ProcessEnv = Bun.env): boolean {
|
|
183
|
-
// TMUX/STY/ZELLIJ
|
|
184
|
-
// signals. TERM can also survive when those are
|
|
185
|
-
// `su`, env-sanitizing launchers/ssh). Do not
|
|
186
|
-
// a CLI socket override and can be set
|
|
183
|
+
// TMUX/STY/ZELLIJ and CMUX workspace/surface/remote-transport markers are
|
|
184
|
+
// authoritative session signals. TERM can also survive when those are
|
|
185
|
+
// stripped (`sudo` without -E, `su`, env-sanitizing launchers/ssh). Do not
|
|
186
|
+
// use CMUX_SOCKET_PATH here: it is a CLI socket override and can be set
|
|
187
|
+
// outside a CMUX terminal.
|
|
187
188
|
if (env.TMUX || env.STY || env.ZELLIJ) return true;
|
|
188
|
-
if (env.CMUX_WORKSPACE_ID || env.CMUX_SURFACE_ID) return true;
|
|
189
|
+
if (env.CMUX_WORKSPACE_ID || env.CMUX_SURFACE_ID || env.CMUX_REMOTE_TRANSPORT) return true;
|
|
189
190
|
const term = env.TERM?.toLowerCase() ?? "";
|
|
190
191
|
return term.startsWith("tmux") || term.startsWith("screen");
|
|
191
192
|
}
|
package/src/terminal.ts
CHANGED
|
@@ -337,8 +337,8 @@ export function emergencyTerminalRestore(): void {
|
|
|
337
337
|
/** Terminal-reported appearance (dark/light mode). */
|
|
338
338
|
export type TerminalAppearance = "dark" | "light";
|
|
339
339
|
export interface Terminal {
|
|
340
|
-
// Start the terminal with input and
|
|
341
|
-
start(onInput: (data: string) => void, onResize: () => void): void;
|
|
340
|
+
// Start the terminal with input, resize, and host-disconnect handlers.
|
|
341
|
+
start(onInput: (data: string) => void, onResize: () => void, onDisconnect?: () => void): void;
|
|
342
342
|
|
|
343
343
|
// Stop the terminal and restore state
|
|
344
344
|
stop(): void;
|
|
@@ -402,6 +402,16 @@ export interface Terminal {
|
|
|
402
402
|
* already-detected appearance so late subscribers never miss it.
|
|
403
403
|
*/
|
|
404
404
|
onAppearanceChange(callback: (appearance: TerminalAppearance) => void): void;
|
|
405
|
+
/**
|
|
406
|
+
* Issue a single OSC 11 background-color re-query, driving the appearance
|
|
407
|
+
* callbacks through the same parse/dedup pipeline used at startup and on Mode
|
|
408
|
+
* 2031 notifications. Bounded: one probe per call, no timers. Invoked on the
|
|
409
|
+
* user's explicit display-reset gesture (Ctrl+L) so terminals that cannot
|
|
410
|
+
* deliver end-to-end Mode 2031 notifications still pick up a light/dark switch
|
|
411
|
+
* without a restart. Optional so custom Terminals built against older pi-tui
|
|
412
|
+
* versions keep working.
|
|
413
|
+
*/
|
|
414
|
+
refreshAppearance?(): void;
|
|
405
415
|
/** The last detected terminal appearance, or undefined if not yet known. */
|
|
406
416
|
get appearance(): TerminalAppearance | undefined;
|
|
407
417
|
/**
|
|
@@ -473,6 +483,16 @@ export class ProcessTerminal implements Terminal {
|
|
|
473
483
|
#modifyOtherKeysTimeout?: Timer;
|
|
474
484
|
#stdinBuffer?: StdinBuffer;
|
|
475
485
|
#stdinDataHandler?: (data: string) => void;
|
|
486
|
+
#disconnectHandler?: () => void;
|
|
487
|
+
#stdinEndHandler = () => {
|
|
488
|
+
this.#markTerminalDisconnected("stdin ended");
|
|
489
|
+
};
|
|
490
|
+
#stdinCloseHandler = () => {
|
|
491
|
+
this.#markTerminalDisconnected("stdin closed");
|
|
492
|
+
};
|
|
493
|
+
#stdinErrorHandler = (err: Error) => {
|
|
494
|
+
this.#markTerminalDisconnected("stdin failed", err);
|
|
495
|
+
};
|
|
476
496
|
#dead = false;
|
|
477
497
|
// Captured at construction and re-read at start(): when true, every real
|
|
478
498
|
// terminal side effect (writes, probes, raw mode, SIGWINCH, timers) is
|
|
@@ -481,7 +501,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
481
501
|
#writeLogPath = $env.PI_TUI_WRITE_LOG || "";
|
|
482
502
|
#stdoutErrorCleanup?: () => void;
|
|
483
503
|
#stdoutErrorHandler = (err: Error) => {
|
|
484
|
-
this.#
|
|
504
|
+
this.#markTerminalDisconnected("stdout failed", err);
|
|
485
505
|
};
|
|
486
506
|
|
|
487
507
|
#windowsVTInputRestore?: () => void;
|
|
@@ -550,13 +570,27 @@ export class ProcessTerminal implements Terminal {
|
|
|
550
570
|
}
|
|
551
571
|
}
|
|
552
572
|
|
|
573
|
+
/**
|
|
574
|
+
* Re-query the terminal background via a single OSC 11 probe. Reuses the
|
|
575
|
+
* startup query path — same DA1-sentinel FIFO, pending/queued gating, parsing,
|
|
576
|
+
* dedup, and appearance callbacks — so a light/dark switch is picked up
|
|
577
|
+
* without a restart on terminals lacking end-to-end Mode 2031 notifications.
|
|
578
|
+
* Bounded to one probe per call; no timers are armed. Suppressed while headless
|
|
579
|
+
* or after the terminal is torn down.
|
|
580
|
+
*/
|
|
581
|
+
refreshAppearance(): void {
|
|
582
|
+
if (this.#headless || this.#dead) return;
|
|
583
|
+
this.#queryBackgroundColor();
|
|
584
|
+
}
|
|
585
|
+
|
|
553
586
|
onPrivateModeReport(callback: (mode: number, supported: boolean, confirmed?: boolean) => void): void {
|
|
554
587
|
this.#privateModeCallbacks.push(callback);
|
|
555
588
|
}
|
|
556
589
|
|
|
557
|
-
start(onInput: (data: string) => void, onResize: () => void): void {
|
|
590
|
+
start(onInput: (data: string) => void, onResize: () => void, onDisconnect?: () => void): void {
|
|
558
591
|
this.#inputHandler = onInput;
|
|
559
592
|
this.#resizeHandler = onResize;
|
|
593
|
+
this.#disconnectHandler = onDisconnect;
|
|
560
594
|
|
|
561
595
|
// Headless (tests): suppress every real-terminal side effect. Skip raw
|
|
562
596
|
// mode, stdin listeners, capability probes, SIGWINCH, and emergency-restore
|
|
@@ -581,6 +615,9 @@ export class ProcessTerminal implements Terminal {
|
|
|
581
615
|
process.stdin.setRawMode(true);
|
|
582
616
|
}
|
|
583
617
|
process.stdin.setEncoding("utf8");
|
|
618
|
+
process.stdin.on("end", this.#stdinEndHandler);
|
|
619
|
+
process.stdin.on("close", this.#stdinCloseHandler);
|
|
620
|
+
process.stdin.on("error", this.#stdinErrorHandler);
|
|
584
621
|
process.stdin.resume();
|
|
585
622
|
|
|
586
623
|
// Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~
|
|
@@ -1402,6 +1439,10 @@ export class ProcessTerminal implements Terminal {
|
|
|
1402
1439
|
process.stdin.removeListener("data", this.#stdinDataHandler);
|
|
1403
1440
|
this.#stdinDataHandler = undefined;
|
|
1404
1441
|
}
|
|
1442
|
+
process.stdin.removeListener("end", this.#stdinEndHandler);
|
|
1443
|
+
process.stdin.removeListener("close", this.#stdinCloseHandler);
|
|
1444
|
+
process.stdin.removeListener("error", this.#stdinErrorHandler);
|
|
1445
|
+
this.#disconnectHandler = undefined;
|
|
1405
1446
|
this.#inputHandler = undefined;
|
|
1406
1447
|
this.#appearance = undefined;
|
|
1407
1448
|
if (this.#stdoutResizeListener) {
|
|
@@ -1427,10 +1468,26 @@ export class ProcessTerminal implements Terminal {
|
|
|
1427
1468
|
this.#stdoutErrorCleanup ??= registerStdoutErrorHandler(this.#stdoutErrorHandler);
|
|
1428
1469
|
}
|
|
1429
1470
|
|
|
1430
|
-
#
|
|
1471
|
+
#markTerminalDisconnected(reason: string, err?: unknown): void {
|
|
1431
1472
|
if (this.#dead) return;
|
|
1432
1473
|
this.#dead = true;
|
|
1433
|
-
logger.warn("terminal
|
|
1474
|
+
logger.warn("terminal disconnected; stopping interactive rendering", { reason, err });
|
|
1475
|
+
|
|
1476
|
+
const disconnectHandler = this.#disconnectHandler;
|
|
1477
|
+
this.#disconnectHandler = undefined;
|
|
1478
|
+
if (!disconnectHandler) return;
|
|
1479
|
+
disconnectHandler();
|
|
1480
|
+
|
|
1481
|
+
if (process.platform === "win32") {
|
|
1482
|
+
void postmortem.quit(129);
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1485
|
+
try {
|
|
1486
|
+
process.kill(process.pid, "SIGHUP");
|
|
1487
|
+
} catch (signalErr) {
|
|
1488
|
+
logger.error("Failed to deliver terminal disconnect signal; exiting directly", { err: signalErr });
|
|
1489
|
+
void postmortem.quit(129);
|
|
1490
|
+
}
|
|
1434
1491
|
}
|
|
1435
1492
|
|
|
1436
1493
|
write(data: string): void {
|
|
@@ -1477,7 +1534,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
1477
1534
|
process.stdout.write(data);
|
|
1478
1535
|
}
|
|
1479
1536
|
} catch (err) {
|
|
1480
|
-
this.#
|
|
1537
|
+
this.#markTerminalDisconnected("stdout failed", err);
|
|
1481
1538
|
}
|
|
1482
1539
|
}
|
|
1483
1540
|
|
package/src/tui.ts
CHANGED
|
@@ -80,13 +80,15 @@ const CURSOR_BEGIN = `${HIDE_CURSOR}${SYNC_OUTPUT_BEGIN}`;
|
|
|
80
80
|
const CURSOR_BEGIN_NO_SYNC = HIDE_CURSOR;
|
|
81
81
|
const CURSOR_END = SYNC_OUTPUT_END;
|
|
82
82
|
const CURSOR_END_NO_SYNC = "";
|
|
83
|
-
// Mouse reporting
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
83
|
+
// Mouse reporting is scoped to fullscreen overlays that opt into pointer
|
|
84
|
+
// interaction. 1000h = button click tracking, 1003h = any-motion tracking for
|
|
85
|
+
// hover targets, and 1006h = SGR extended coordinates past column/row 223.
|
|
86
|
+
// Selection-first overlays leave these modes disabled so the terminal retains
|
|
87
|
+
// native text selection.
|
|
88
88
|
const MOUSE_TRACKING_ON = "\x1b[?1000h\x1b[?1003h\x1b[?1006h";
|
|
89
89
|
const MOUSE_TRACKING_OFF = "\x1b[?1006l\x1b[?1003l\x1b[?1000l";
|
|
90
|
+
const ALT_SCREEN_ENTER = "\x1b[?1049h";
|
|
91
|
+
const ALT_SCREEN_EXIT = "\x1b[?1049l";
|
|
90
92
|
|
|
91
93
|
type InputListenerResult = { consume?: boolean; data?: string } | undefined;
|
|
92
94
|
type InputListener = (data: string) => InputListenerResult;
|
|
@@ -454,6 +456,11 @@ export interface OverlayOptions {
|
|
|
454
456
|
* unchanged and still draw over the transcript on the normal screen.
|
|
455
457
|
*/
|
|
456
458
|
fullscreen?: boolean;
|
|
459
|
+
/**
|
|
460
|
+
* Enable terminal mouse reporting while fullscreen. Defaults on; disable it
|
|
461
|
+
* when native terminal text selection takes precedence over pointer events.
|
|
462
|
+
*/
|
|
463
|
+
mouseTracking?: boolean;
|
|
457
464
|
}
|
|
458
465
|
|
|
459
466
|
/**
|
|
@@ -1074,6 +1081,11 @@ export class TUI extends Container {
|
|
|
1074
1081
|
// `#fullRedrawCount`: these never enter native scrollback and exist only for
|
|
1075
1082
|
// the lifetime of the drag. Exposed for tests/diagnostics.
|
|
1076
1083
|
#resizeViewportPaintCount = 0;
|
|
1084
|
+
// During a live resize drag the terminal's normal buffer may reflow full-width
|
|
1085
|
+
// rows before our repaint lands. Borrow the alternate screen for throwaway
|
|
1086
|
+
// resize frames so width changes truncate the transient viewport instead of
|
|
1087
|
+
// pushing wrapped fragments into native scrollback.
|
|
1088
|
+
#resizeAltActive = false;
|
|
1077
1089
|
#stopped = false;
|
|
1078
1090
|
// Always-on event-loop lag probe. The high default threshold keeps it quiet;
|
|
1079
1091
|
// it only logs `ui.loop-blocked` (with the current loop phase) when a frame
|
|
@@ -1086,6 +1098,7 @@ export class TUI extends Container {
|
|
|
1086
1098
|
// untouched, so exiting reconciles cleanly against the terminal-restored
|
|
1087
1099
|
// normal screen. #altPreviousLines is the last alt frame, for repaint-skip.
|
|
1088
1100
|
#altActive = false;
|
|
1101
|
+
#altMouseTrackingActive = false;
|
|
1089
1102
|
#altPreviousLines: string[] = [];
|
|
1090
1103
|
#altEnterWidth = 0;
|
|
1091
1104
|
#altEnterHeight = 0;
|
|
@@ -1558,7 +1571,9 @@ export class TUI extends Container {
|
|
|
1558
1571
|
}
|
|
1559
1572
|
this.#armMultiplexerResizeTimer(false);
|
|
1560
1573
|
},
|
|
1574
|
+
() => this.stop(),
|
|
1561
1575
|
);
|
|
1576
|
+
if (this.#stopped) return;
|
|
1562
1577
|
for (const listener of this.#startListeners) {
|
|
1563
1578
|
try {
|
|
1564
1579
|
listener();
|
|
@@ -1741,12 +1756,18 @@ export class TUI extends Container {
|
|
|
1741
1756
|
}
|
|
1742
1757
|
|
|
1743
1758
|
stop(): void {
|
|
1759
|
+
// Leave the resize alt buffer first so the teardown cursor math below runs
|
|
1760
|
+
// against the restored normal screen (which #previousLines still describes).
|
|
1761
|
+
if (this.#resizeAltActive) {
|
|
1762
|
+
this.terminal.write(this.#leaveResizeAltSequence());
|
|
1763
|
+
}
|
|
1744
1764
|
if (this.#altActive || this.#pendingAltExit) {
|
|
1745
|
-
const
|
|
1746
|
-
|
|
1765
|
+
const mouseExit = this.#altMouseTrackingActive ? MOUSE_TRACKING_OFF : "";
|
|
1766
|
+
const exitSequence = this.#pendingAltExit || `${mouseExit}${this.#keyboardEnhancementExit()}\x1b[?1049l`;
|
|
1747
1767
|
this.terminal.write(exitSequence);
|
|
1748
1768
|
setAltScreenActive(false);
|
|
1749
1769
|
this.#altActive = false;
|
|
1770
|
+
this.#altMouseTrackingActive = false;
|
|
1750
1771
|
this.#altPreviousLines = [];
|
|
1751
1772
|
this.#pendingAltExit = "";
|
|
1752
1773
|
}
|
|
@@ -2705,24 +2726,29 @@ export class TUI extends Container {
|
|
|
2705
2726
|
// requests it, borrow the terminal's alternate buffer and paint only the
|
|
2706
2727
|
// modal there; the normal screen and all accounting stay untouched.
|
|
2707
2728
|
let deferredAltExit = this.#pendingAltExit;
|
|
2708
|
-
const
|
|
2729
|
+
const topOverlay = this.#getTopmostVisibleOverlay();
|
|
2730
|
+
const wantAlt = topOverlay?.options?.fullscreen === true;
|
|
2731
|
+
const wantMouseTracking = wantAlt && topOverlay.options?.mouseTracking !== false;
|
|
2709
2732
|
if (wantAlt && !this.#altActive) {
|
|
2710
2733
|
// Enhanced keyboard modes can be buffer-local: re-push the active
|
|
2711
2734
|
// modified-key reporting sequence on the freshly entered alternate
|
|
2712
2735
|
// screen, or Esc/modified keys revert to legacy encoding inside
|
|
2713
2736
|
// fullscreen overlays (Ghostty/kitty/iTerm2).
|
|
2714
|
-
|
|
2737
|
+
const mouseEnter = wantMouseTracking ? MOUSE_TRACKING_ON : "";
|
|
2738
|
+
this.terminal.write(`\x1b[?1049h${this.#keyboardEnhancementEnter()}${mouseEnter}`);
|
|
2715
2739
|
setAltScreenActive(true);
|
|
2716
2740
|
this.terminal.hideCursor();
|
|
2717
2741
|
this.#forgetHardwareCursorState();
|
|
2718
2742
|
this.#recordHardwareCursorHidden();
|
|
2719
2743
|
this.#altActive = true;
|
|
2744
|
+
this.#altMouseTrackingActive = wantMouseTracking;
|
|
2720
2745
|
this.#altPreviousLines = [];
|
|
2721
2746
|
this.#altEnterWidth = width;
|
|
2722
2747
|
this.#altEnterHeight = height;
|
|
2723
2748
|
} else if (!wantAlt && this.#altActive) {
|
|
2749
|
+
const mouseExit = this.#altMouseTrackingActive ? MOUSE_TRACKING_OFF : "";
|
|
2724
2750
|
const enhancementExit = this.#keyboardEnhancementExit();
|
|
2725
|
-
const exitSequence = `${
|
|
2751
|
+
const exitSequence = `${mouseExit}${enhancementExit}\x1b[?1049l`;
|
|
2726
2752
|
// Session replacement can finish while a fullscreen selector is still
|
|
2727
2753
|
// covering the old normal buffer. Keep the overlay visible until the
|
|
2728
2754
|
// replacement is ready, then fuse the buffer restore into that full paint;
|
|
@@ -2734,6 +2760,7 @@ export class TUI extends Container {
|
|
|
2734
2760
|
setAltScreenActive(false);
|
|
2735
2761
|
this.#forgetHardwareCursorState();
|
|
2736
2762
|
this.#altActive = false;
|
|
2763
|
+
this.#altMouseTrackingActive = false;
|
|
2737
2764
|
this.#altPreviousLines = [];
|
|
2738
2765
|
// A resize while on the alt buffer reflowed the terminal's saved
|
|
2739
2766
|
// normal screen; it no longer matches our accounting, so force the
|
|
@@ -2741,6 +2768,9 @@ export class TUI extends Container {
|
|
|
2741
2768
|
if (width !== this.#altEnterWidth || height !== this.#altEnterHeight) {
|
|
2742
2769
|
this.#resizeEventPending = true;
|
|
2743
2770
|
}
|
|
2771
|
+
} else if (wantMouseTracking !== this.#altMouseTrackingActive) {
|
|
2772
|
+
this.terminal.write(wantMouseTracking ? MOUSE_TRACKING_ON : MOUSE_TRACKING_OFF);
|
|
2773
|
+
this.#altMouseTrackingActive = wantMouseTracking;
|
|
2744
2774
|
}
|
|
2745
2775
|
if (this.#altActive) {
|
|
2746
2776
|
this.#componentRenderTargets.clear();
|
|
@@ -3478,7 +3508,7 @@ export class TUI extends Container {
|
|
|
3478
3508
|
paintCursorPos = paint.cursorPos;
|
|
3479
3509
|
}
|
|
3480
3510
|
}
|
|
3481
|
-
let buffer = this.#paintBeginSequence + options.leadingSequence + purgeSequence;
|
|
3511
|
+
let buffer = this.#paintBeginSequence + this.#leaveResizeAltSequence() + options.leadingSequence + purgeSequence;
|
|
3482
3512
|
if (options.clearScrollback) {
|
|
3483
3513
|
// Clear native history without blanking the live viewport first. The
|
|
3484
3514
|
// replay below rewrites every visible row from home, including blanks,
|
|
@@ -3678,15 +3708,42 @@ export class TUI extends Container {
|
|
|
3678
3708
|
return this.terminal.kittyEnableSequence ? "\x1b[<u" : "";
|
|
3679
3709
|
}
|
|
3680
3710
|
|
|
3711
|
+
#enterResizeAltSequence(): string {
|
|
3712
|
+
if (this.#resizeAltActive || this.#altActive) return "";
|
|
3713
|
+
this.#resizeAltActive = true;
|
|
3714
|
+
setAltScreenActive(true);
|
|
3715
|
+
this.#forgetHardwareCursorState();
|
|
3716
|
+
this.#recordHardwareCursorHidden();
|
|
3717
|
+
return `${ALT_SCREEN_ENTER}${this.#keyboardEnhancementEnter()}`;
|
|
3718
|
+
}
|
|
3719
|
+
|
|
3720
|
+
#leaveResizeAltSequence(): string {
|
|
3721
|
+
if (!this.#resizeAltActive) return "";
|
|
3722
|
+
const enhancementExit = this.#keyboardEnhancementExit();
|
|
3723
|
+
this.#resizeAltActive = false;
|
|
3724
|
+
setAltScreenActive(false);
|
|
3725
|
+
this.#forgetHardwareCursorState();
|
|
3726
|
+
return `${enhancementExit}${ALT_SCREEN_EXIT}`;
|
|
3727
|
+
}
|
|
3728
|
+
|
|
3681
3729
|
/**
|
|
3682
|
-
* Emit a throwaway viewport repaint for the resize fast path as
|
|
3683
|
-
*
|
|
3684
|
-
* the
|
|
3685
|
-
*
|
|
3686
|
-
*
|
|
3730
|
+
* Emit a throwaway viewport repaint for the resize fast path as a per-row
|
|
3731
|
+
* overwrite. A width change can make the terminal's normal buffer reflow
|
|
3732
|
+
* full-width rows before the app repaints, so a width drag borrows the
|
|
3733
|
+
* alternate screen: transient resizes truncate the viewport instead of
|
|
3734
|
+
* pushing wrapped fragments into native scrollback. A height-only resize
|
|
3735
|
+
* reflows nothing, so it repaints the normal screen in place — borrowing the
|
|
3736
|
+
* alt buffer there is pure flicker, and on terminals that re-report their
|
|
3737
|
+
* size when the alt buffer toggles it is self-sustaining: leaving a
|
|
3738
|
+
* fullscreen overlay's alt screen fires a height-only SIGWINCH echo, which
|
|
3739
|
+
* would otherwise re-borrow the alt buffer for one frame (the settings-exit
|
|
3740
|
+
* flash, #5854). Normal-screen history is rebuilt once at settle via
|
|
3741
|
+
* `#emitFullPaint`.
|
|
3687
3742
|
*/
|
|
3688
3743
|
#emitResizeViewport(window: readonly string[], height: number, contentRows: number, width: number): void {
|
|
3689
|
-
|
|
3744
|
+
const widthChanged = this.#previousWidth > 0 && this.#previousWidth !== width;
|
|
3745
|
+
const altEnter = widthChanged ? this.#enterResizeAltSequence() : "";
|
|
3746
|
+
let buffer = `${this.#paintBeginSequence + altEnter}\x1b[H`;
|
|
3690
3747
|
for (let r = 0; r < height; r++) {
|
|
3691
3748
|
if (r > 0) buffer += "\r\n";
|
|
3692
3749
|
buffer += this.#lineRewriteSequence(window[r] ?? "", width);
|
|
@@ -3701,16 +3758,6 @@ export class TUI extends Container {
|
|
|
3701
3758
|
this.terminal.write(buffer);
|
|
3702
3759
|
}
|
|
3703
3760
|
|
|
3704
|
-
/** Topmost visible overlay requests the alternate-screen buffer. */
|
|
3705
|
-
#wantsAltScreen(): boolean {
|
|
3706
|
-
for (let i = this.overlayStack.length - 1; i >= 0; i--) {
|
|
3707
|
-
const entry = this.overlayStack[i]!;
|
|
3708
|
-
if (!this.#isOverlayVisible(entry)) continue;
|
|
3709
|
-
return entry.options?.fullscreen === true;
|
|
3710
|
-
}
|
|
3711
|
-
return false;
|
|
3712
|
-
}
|
|
3713
|
-
|
|
3714
3761
|
/**
|
|
3715
3762
|
* Compose and paint a single fullscreen overlay frame on the alt buffer.
|
|
3716
3763
|
* Cursor markers are stripped (the modal draws its own in-band caret and
|