@oh-my-pi/pi-tui 17.0.1 → 17.0.2
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 +14 -0
- package/dist/types/components/editor.d.ts +2 -0
- package/dist/types/terminal.d.ts +19 -0
- 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 +36 -1
- package/src/terminal.ts +23 -0
- package/src/tui.ts +64 -27
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [17.0.2] - 2026-07-17
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added a fullscreen overlay mouse-tracking opt-out to allow selection-first dialogs to preserve native terminal text selection.
|
|
10
|
+
- Added `Terminal.refreshAppearance()` to allow consumers to manually trigger a refresh of the detected dark/light terminal appearance without periodic polling.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- 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.
|
|
15
|
+
- 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.
|
|
16
|
+
- 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.
|
|
17
|
+
- 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.
|
|
18
|
+
|
|
5
19
|
## [17.0.1] - 2026-07-16
|
|
6
20
|
|
|
7
21
|
### Added
|
|
@@ -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
|
@@ -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,6 +103,15 @@ 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
116
|
start(onInput: (data: string) => void, onResize: () => void): void;
|
|
98
117
|
drainInput(maxMs?: number, idleMs?: number): Promise<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.2",
|
|
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.2",
|
|
41
|
+
"@oh-my-pi/pi-utils": "17.0.2",
|
|
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
|
package/src/terminal.ts
CHANGED
|
@@ -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
|
/**
|
|
@@ -550,6 +560,19 @@ export class ProcessTerminal implements Terminal {
|
|
|
550
560
|
}
|
|
551
561
|
}
|
|
552
562
|
|
|
563
|
+
/**
|
|
564
|
+
* Re-query the terminal background via a single OSC 11 probe. Reuses the
|
|
565
|
+
* startup query path — same DA1-sentinel FIFO, pending/queued gating, parsing,
|
|
566
|
+
* dedup, and appearance callbacks — so a light/dark switch is picked up
|
|
567
|
+
* without a restart on terminals lacking end-to-end Mode 2031 notifications.
|
|
568
|
+
* Bounded to one probe per call; no timers are armed. Suppressed while headless
|
|
569
|
+
* or after the terminal is torn down.
|
|
570
|
+
*/
|
|
571
|
+
refreshAppearance(): void {
|
|
572
|
+
if (this.#headless || this.#dead) return;
|
|
573
|
+
this.#queryBackgroundColor();
|
|
574
|
+
}
|
|
575
|
+
|
|
553
576
|
onPrivateModeReport(callback: (mode: number, supported: boolean, confirmed?: boolean) => void): void {
|
|
554
577
|
this.#privateModeCallbacks.push(callback);
|
|
555
578
|
}
|
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;
|
|
@@ -1741,12 +1754,18 @@ export class TUI extends Container {
|
|
|
1741
1754
|
}
|
|
1742
1755
|
|
|
1743
1756
|
stop(): void {
|
|
1757
|
+
// Leave the resize alt buffer first so the teardown cursor math below runs
|
|
1758
|
+
// against the restored normal screen (which #previousLines still describes).
|
|
1759
|
+
if (this.#resizeAltActive) {
|
|
1760
|
+
this.terminal.write(this.#leaveResizeAltSequence());
|
|
1761
|
+
}
|
|
1744
1762
|
if (this.#altActive || this.#pendingAltExit) {
|
|
1745
|
-
const
|
|
1746
|
-
|
|
1763
|
+
const mouseExit = this.#altMouseTrackingActive ? MOUSE_TRACKING_OFF : "";
|
|
1764
|
+
const exitSequence = this.#pendingAltExit || `${mouseExit}${this.#keyboardEnhancementExit()}\x1b[?1049l`;
|
|
1747
1765
|
this.terminal.write(exitSequence);
|
|
1748
1766
|
setAltScreenActive(false);
|
|
1749
1767
|
this.#altActive = false;
|
|
1768
|
+
this.#altMouseTrackingActive = false;
|
|
1750
1769
|
this.#altPreviousLines = [];
|
|
1751
1770
|
this.#pendingAltExit = "";
|
|
1752
1771
|
}
|
|
@@ -2705,24 +2724,29 @@ export class TUI extends Container {
|
|
|
2705
2724
|
// requests it, borrow the terminal's alternate buffer and paint only the
|
|
2706
2725
|
// modal there; the normal screen and all accounting stay untouched.
|
|
2707
2726
|
let deferredAltExit = this.#pendingAltExit;
|
|
2708
|
-
const
|
|
2727
|
+
const topOverlay = this.#getTopmostVisibleOverlay();
|
|
2728
|
+
const wantAlt = topOverlay?.options?.fullscreen === true;
|
|
2729
|
+
const wantMouseTracking = wantAlt && topOverlay.options?.mouseTracking !== false;
|
|
2709
2730
|
if (wantAlt && !this.#altActive) {
|
|
2710
2731
|
// Enhanced keyboard modes can be buffer-local: re-push the active
|
|
2711
2732
|
// modified-key reporting sequence on the freshly entered alternate
|
|
2712
2733
|
// screen, or Esc/modified keys revert to legacy encoding inside
|
|
2713
2734
|
// fullscreen overlays (Ghostty/kitty/iTerm2).
|
|
2714
|
-
|
|
2735
|
+
const mouseEnter = wantMouseTracking ? MOUSE_TRACKING_ON : "";
|
|
2736
|
+
this.terminal.write(`\x1b[?1049h${this.#keyboardEnhancementEnter()}${mouseEnter}`);
|
|
2715
2737
|
setAltScreenActive(true);
|
|
2716
2738
|
this.terminal.hideCursor();
|
|
2717
2739
|
this.#forgetHardwareCursorState();
|
|
2718
2740
|
this.#recordHardwareCursorHidden();
|
|
2719
2741
|
this.#altActive = true;
|
|
2742
|
+
this.#altMouseTrackingActive = wantMouseTracking;
|
|
2720
2743
|
this.#altPreviousLines = [];
|
|
2721
2744
|
this.#altEnterWidth = width;
|
|
2722
2745
|
this.#altEnterHeight = height;
|
|
2723
2746
|
} else if (!wantAlt && this.#altActive) {
|
|
2747
|
+
const mouseExit = this.#altMouseTrackingActive ? MOUSE_TRACKING_OFF : "";
|
|
2724
2748
|
const enhancementExit = this.#keyboardEnhancementExit();
|
|
2725
|
-
const exitSequence = `${
|
|
2749
|
+
const exitSequence = `${mouseExit}${enhancementExit}\x1b[?1049l`;
|
|
2726
2750
|
// Session replacement can finish while a fullscreen selector is still
|
|
2727
2751
|
// covering the old normal buffer. Keep the overlay visible until the
|
|
2728
2752
|
// replacement is ready, then fuse the buffer restore into that full paint;
|
|
@@ -2734,6 +2758,7 @@ export class TUI extends Container {
|
|
|
2734
2758
|
setAltScreenActive(false);
|
|
2735
2759
|
this.#forgetHardwareCursorState();
|
|
2736
2760
|
this.#altActive = false;
|
|
2761
|
+
this.#altMouseTrackingActive = false;
|
|
2737
2762
|
this.#altPreviousLines = [];
|
|
2738
2763
|
// A resize while on the alt buffer reflowed the terminal's saved
|
|
2739
2764
|
// normal screen; it no longer matches our accounting, so force the
|
|
@@ -2741,6 +2766,9 @@ export class TUI extends Container {
|
|
|
2741
2766
|
if (width !== this.#altEnterWidth || height !== this.#altEnterHeight) {
|
|
2742
2767
|
this.#resizeEventPending = true;
|
|
2743
2768
|
}
|
|
2769
|
+
} else if (wantMouseTracking !== this.#altMouseTrackingActive) {
|
|
2770
|
+
this.terminal.write(wantMouseTracking ? MOUSE_TRACKING_ON : MOUSE_TRACKING_OFF);
|
|
2771
|
+
this.#altMouseTrackingActive = wantMouseTracking;
|
|
2744
2772
|
}
|
|
2745
2773
|
if (this.#altActive) {
|
|
2746
2774
|
this.#componentRenderTargets.clear();
|
|
@@ -3478,7 +3506,7 @@ export class TUI extends Container {
|
|
|
3478
3506
|
paintCursorPos = paint.cursorPos;
|
|
3479
3507
|
}
|
|
3480
3508
|
}
|
|
3481
|
-
let buffer = this.#paintBeginSequence + options.leadingSequence + purgeSequence;
|
|
3509
|
+
let buffer = this.#paintBeginSequence + this.#leaveResizeAltSequence() + options.leadingSequence + purgeSequence;
|
|
3482
3510
|
if (options.clearScrollback) {
|
|
3483
3511
|
// Clear native history without blanking the live viewport first. The
|
|
3484
3512
|
// replay below rewrites every visible row from home, including blanks,
|
|
@@ -3678,15 +3706,34 @@ export class TUI extends Container {
|
|
|
3678
3706
|
return this.terminal.kittyEnableSequence ? "\x1b[<u" : "";
|
|
3679
3707
|
}
|
|
3680
3708
|
|
|
3709
|
+
#enterResizeAltSequence(): string {
|
|
3710
|
+
if (this.#resizeAltActive || this.#altActive) return "";
|
|
3711
|
+
this.#resizeAltActive = true;
|
|
3712
|
+
setAltScreenActive(true);
|
|
3713
|
+
this.#forgetHardwareCursorState();
|
|
3714
|
+
this.#recordHardwareCursorHidden();
|
|
3715
|
+
return `${ALT_SCREEN_ENTER}${this.#keyboardEnhancementEnter()}`;
|
|
3716
|
+
}
|
|
3717
|
+
|
|
3718
|
+
#leaveResizeAltSequence(): string {
|
|
3719
|
+
if (!this.#resizeAltActive) return "";
|
|
3720
|
+
const enhancementExit = this.#keyboardEnhancementExit();
|
|
3721
|
+
this.#resizeAltActive = false;
|
|
3722
|
+
setAltScreenActive(false);
|
|
3723
|
+
this.#forgetHardwareCursorState();
|
|
3724
|
+
return `${enhancementExit}${ALT_SCREEN_EXIT}`;
|
|
3725
|
+
}
|
|
3726
|
+
|
|
3681
3727
|
/**
|
|
3682
|
-
* Emit a throwaway viewport repaint for the resize fast path as an
|
|
3683
|
-
* per-row overwrite.
|
|
3684
|
-
* the
|
|
3685
|
-
*
|
|
3686
|
-
*
|
|
3728
|
+
* Emit a throwaway viewport repaint for the resize fast path as an alternate-
|
|
3729
|
+
* screen per-row overwrite. The normal buffer may reflow full-width rows on a
|
|
3730
|
+
* width change before the app can repaint; keeping the drag on the alternate
|
|
3731
|
+
* screen makes those transient resizes truncate instead of pushing wrapped
|
|
3732
|
+
* fragments into native scrollback. Normal-screen history is rebuilt once at
|
|
3733
|
+
* settle via `#emitFullPaint`.
|
|
3687
3734
|
*/
|
|
3688
3735
|
#emitResizeViewport(window: readonly string[], height: number, contentRows: number, width: number): void {
|
|
3689
|
-
let buffer = `${this.#paintBeginSequence}\x1b[H`;
|
|
3736
|
+
let buffer = `${this.#paintBeginSequence + this.#enterResizeAltSequence()}\x1b[H`;
|
|
3690
3737
|
for (let r = 0; r < height; r++) {
|
|
3691
3738
|
if (r > 0) buffer += "\r\n";
|
|
3692
3739
|
buffer += this.#lineRewriteSequence(window[r] ?? "", width);
|
|
@@ -3701,16 +3748,6 @@ export class TUI extends Container {
|
|
|
3701
3748
|
this.terminal.write(buffer);
|
|
3702
3749
|
}
|
|
3703
3750
|
|
|
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
3751
|
/**
|
|
3715
3752
|
* Compose and paint a single fullscreen overlay frame on the alt buffer.
|
|
3716
3753
|
* Cursor markers are stripped (the modal draws its own in-band caret and
|