@oh-my-pi/pi-tui 17.0.0 → 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 +28 -0
- package/dist/types/components/editor.d.ts +2 -0
- package/dist/types/components/markdown.d.ts +10 -2
- package/dist/types/terminal.d.ts +24 -3
- package/dist/types/tui.d.ts +16 -1
- package/package.json +3 -3
- package/src/autocomplete.ts +15 -17
- package/src/components/editor.ts +39 -8
- package/src/components/markdown.ts +304 -32
- package/src/terminal-capabilities.ts +33 -0
- package/src/terminal.ts +39 -13
- package/src/tui.ts +85 -32
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,34 @@
|
|
|
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
|
+
|
|
19
|
+
## [17.0.1] - 2026-07-16
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- Added native cmux notification delivery targeted to the current terminal surface.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- Fixed a tmux regression where every non-Kitty pane was forced into legacy keyboard input, collapsing Ctrl+H into Backspace and Shift+Enter into Enter even with `extended-keys on`; the xterm modifyOtherKeys fallback is requested again so tmux honors or ignores it per its own `extended-keys` setting ([#5620](https://github.com/can1357/oh-my-pi/issues/5620)).
|
|
28
|
+
- Fixed `@` file-reference and path completion falling through incorrectly inside slash command arguments when command-specific argument completion has no matches ([#5580](https://github.com/can1357/oh-my-pi/issues/5580)).
|
|
29
|
+
- Fixed streamed Markdown tables reflowing rows already written to native scrollback when later cells widen a column.
|
|
30
|
+
- Fixed fullscreen session-replacement overlays and resize drags exposing stale normal-buffer frames on terminals without effective DEC 2026: asynchronous replacements now keep their overlay visible until the rebuilt transcript is ready, overlay exit is fused into the destructive paint, and resize viewport frames rewrite the normal buffer without alternate-screen switches. Inconclusive DECRQM probes also no longer disable statically detected synchronized output ([#5319](https://github.com/can1357/oh-my-pi/issues/5319)).
|
|
31
|
+
- Fixed autocomplete popups moving Windows Terminal IME candidate windows away from the prompt by keeping the terminal cursor anchored at the text insertion point ([#4760](https://github.com/can1357/oh-my-pi/issues/4760)).
|
|
32
|
+
|
|
5
33
|
## [17.0.0] - 2026-07-15
|
|
6
34
|
|
|
7
35
|
### 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;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { SymbolTheme } from "../symbols.js";
|
|
2
|
-
import type { Component } from "../tui.js";
|
|
2
|
+
import type { Component, NativeScrollbackCommittedRows, NativeScrollbackReplay } from "../tui.js";
|
|
3
3
|
/** Drop all L2 cache entries. Call on theme change to prevent stale styled output. */
|
|
4
4
|
export declare function clearRenderCache(): void;
|
|
5
5
|
/**
|
|
@@ -47,7 +47,7 @@ export interface MarkdownTheme {
|
|
|
47
47
|
resolveMermaidAscii?: (source: string, maxWidth?: number) => string | null;
|
|
48
48
|
symbols: SymbolTheme;
|
|
49
49
|
}
|
|
50
|
-
export declare class Markdown implements Component {
|
|
50
|
+
export declare class Markdown implements Component, NativeScrollbackCommittedRows, NativeScrollbackReplay {
|
|
51
51
|
#private;
|
|
52
52
|
setIgnoreTight(ignore: boolean): this;
|
|
53
53
|
constructor(text: string, paddingX: number, paddingY: number, theme: MarkdownTheme, defaultTextStyle?: DefaultTextStyle, codeBlockIndent?: number);
|
|
@@ -65,6 +65,14 @@ export declare class Markdown implements Component {
|
|
|
65
65
|
* lineage), and on cache-served non-streaming renders.
|
|
66
66
|
*/
|
|
67
67
|
getLastRenderSettledRows(): number;
|
|
68
|
+
/**
|
|
69
|
+
* Freeze every table whose first physical row is already part of the native
|
|
70
|
+
* scrollback prefix. The recorded widths came from the exact frame that was
|
|
71
|
+
* just emitted, so the next streamed delta cannot retroactively widen it.
|
|
72
|
+
*/
|
|
73
|
+
setNativeScrollbackCommittedRows(rows: number): void;
|
|
74
|
+
/** A destructive replay removes the immutable tape this layout was guarding. */
|
|
75
|
+
prepareNativeScrollbackReplay(): void;
|
|
68
76
|
render(width: number): readonly string[];
|
|
69
77
|
}
|
|
70
78
|
/**
|
package/dist/types/terminal.d.ts
CHANGED
|
@@ -63,13 +63,25 @@ 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
|
/**
|
|
69
79
|
* Register a callback fired once per DEC private mode when its DECRQM support
|
|
70
|
-
* status resolves.
|
|
80
|
+
* status resolves. `confirmed` is false when the terminal answered the DA1
|
|
81
|
+
* sentinel without answering DECRQM, which proves only that querying support
|
|
82
|
+
* is unavailable — not that the private mode itself is unsupported.
|
|
71
83
|
*/
|
|
72
|
-
onPrivateModeReport?(callback: (mode: number, supported: boolean) => void): void;
|
|
84
|
+
onPrivateModeReport?(callback: (mode: number, supported: boolean, confirmed?: boolean) => void): void;
|
|
73
85
|
}
|
|
74
86
|
/**
|
|
75
87
|
* True when stdout flows through a ConPTY pseudo-console (native win32, or
|
|
@@ -91,7 +103,16 @@ export declare class ProcessTerminal implements Terminal {
|
|
|
91
103
|
get keyboardEnhancementExitSequence(): string | null;
|
|
92
104
|
get appearance(): TerminalAppearance | undefined;
|
|
93
105
|
onAppearanceChange(callback: (appearance: TerminalAppearance) => void): void;
|
|
94
|
-
|
|
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;
|
|
115
|
+
onPrivateModeReport(callback: (mode: number, supported: boolean, confirmed?: boolean) => void): void;
|
|
95
116
|
start(onInput: (data: string) => void, onResize: () => void): void;
|
|
96
117
|
drainInput(maxMs?: number, idleMs?: number): Promise<void>;
|
|
97
118
|
stop(): 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
|
|
@@ -251,7 +256,7 @@ export interface OverlayHandle {
|
|
|
251
256
|
/**
|
|
252
257
|
* Container - a component that contains other components
|
|
253
258
|
*/
|
|
254
|
-
export declare class Container implements Component {
|
|
259
|
+
export declare class Container implements Component, NativeScrollbackCommittedRows, NativeScrollbackReplay {
|
|
255
260
|
#private;
|
|
256
261
|
children: Component[];
|
|
257
262
|
setIgnoreTight(ignore: boolean): this;
|
|
@@ -267,6 +272,16 @@ export declare class Container implements Component {
|
|
|
267
272
|
* {@link clear} for that). Idempotent per child via each child's own dispose.
|
|
268
273
|
*/
|
|
269
274
|
dispose(): void;
|
|
275
|
+
/**
|
|
276
|
+
* Split the committed prefix from the container's most recently rendered
|
|
277
|
+
* rows across its children. The memoized child arrays are the exact geometry
|
|
278
|
+
* that produced that frame; when the child list was invalidated or rebuilt,
|
|
279
|
+
* there is no safe old-to-new coordinate mapping, so propagation waits for
|
|
280
|
+
* the next render/post-emit publication.
|
|
281
|
+
*/
|
|
282
|
+
setNativeScrollbackCommittedRows(rows: number): void;
|
|
283
|
+
/** Recursively discard layout locks that are meaningful only to the old tape. */
|
|
284
|
+
prepareNativeScrollbackReplay(): void;
|
|
270
285
|
render(width: number): readonly string[];
|
|
271
286
|
}
|
|
272
287
|
/**
|
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
|
}
|
|
@@ -464,10 +464,9 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
464
464
|
// path (`/tmp/fo` at prompt start, `see /tmp` mid-prompt); fall
|
|
465
465
|
// through to file-path completion.
|
|
466
466
|
} else if (!isMidPromptSkillLookup) {
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
// completions because submit treats them as normal prompt text.
|
|
467
|
+
// Give matched commands first chance to complete arguments, then
|
|
468
|
+
// fall through to prompt-composer file completion when they have
|
|
469
|
+
// no argument provider or it has no matches.
|
|
471
470
|
const commandName = commandText.slice(1, spaceIndex); // Command without "/"
|
|
472
471
|
const argumentText = commandText.slice(spaceIndex + 1); // Text after space
|
|
473
472
|
|
|
@@ -475,20 +474,19 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
475
474
|
if (command && "allowArgs" in command && command.allowArgs === false && !/\S/.test(argumentText)) {
|
|
476
475
|
return null;
|
|
477
476
|
}
|
|
478
|
-
if (
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
477
|
+
if (
|
|
478
|
+
command &&
|
|
479
|
+
(!("allowArgs" in command) || command.allowArgs !== false) &&
|
|
480
|
+
"getArgumentCompletions" in command &&
|
|
481
|
+
command.getArgumentCompletions
|
|
482
|
+
) {
|
|
483
483
|
const argumentSuggestions = await command.getArgumentCompletions(argumentText);
|
|
484
|
-
if (
|
|
485
|
-
return
|
|
484
|
+
if (Array.isArray(argumentSuggestions) && argumentSuggestions.length > 0) {
|
|
485
|
+
return {
|
|
486
|
+
items: argumentSuggestions,
|
|
487
|
+
prefix: argumentText,
|
|
488
|
+
};
|
|
486
489
|
}
|
|
487
|
-
|
|
488
|
-
return {
|
|
489
|
-
items: argumentSuggestions,
|
|
490
|
-
prefix: argumentText,
|
|
491
|
-
};
|
|
492
490
|
}
|
|
493
491
|
}
|
|
494
492
|
}
|
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] ────────────────╮
|
|
@@ -858,8 +883,9 @@ export class Editor implements Component, Focusable {
|
|
|
858
883
|
}
|
|
859
884
|
|
|
860
885
|
// Render each layout line
|
|
861
|
-
//
|
|
862
|
-
|
|
886
|
+
// Keep the hardware cursor at the text insertion point while autocomplete
|
|
887
|
+
// rows render below it; terminals use that position to anchor IME candidates.
|
|
888
|
+
const emitCursorMarker = this.focused;
|
|
863
889
|
const lineContentWidth = contentAreaWidth;
|
|
864
890
|
|
|
865
891
|
// Compute inline hint text (dim ghost text after cursor)
|
|
@@ -1053,7 +1079,11 @@ export class Editor implements Component, Focusable {
|
|
|
1053
1079
|
result.push(`${bottomLeft}${displayText}${linePad}${bottomRightAdjusted}`);
|
|
1054
1080
|
} else {
|
|
1055
1081
|
const leftBorder = this.borderColor(`${box.vertical}${padding(paddingX)}`);
|
|
1056
|
-
|
|
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}`);
|
|
1057
1087
|
result.push(leftBorder + displayText + linePad + rightBorder);
|
|
1058
1088
|
}
|
|
1059
1089
|
}
|
|
@@ -1188,11 +1218,12 @@ export class Editor implements Component, Focusable {
|
|
|
1188
1218
|
return;
|
|
1189
1219
|
}
|
|
1190
1220
|
|
|
1191
|
-
// If Enter was pressed on a slash command (not an absolute-path
|
|
1192
|
-
// 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.
|
|
1193
1223
|
if (
|
|
1194
1224
|
(kb.matches(data, "tui.input.submit") || data === "\n") &&
|
|
1195
1225
|
findLeadingSlashCommandStart(this.#autocompletePrefix) !== null &&
|
|
1226
|
+
this.#isInSubmittedSlashCommandContext() &&
|
|
1196
1227
|
!this.#selectedCompletionIsPath()
|
|
1197
1228
|
) {
|
|
1198
1229
|
const selected = this.#autocompleteList.getSelectedItem();
|
|
@@ -1221,7 +1252,7 @@ export class Editor implements Component, Focusable {
|
|
|
1221
1252
|
}
|
|
1222
1253
|
// Don't return - fall through to submission logic
|
|
1223
1254
|
}
|
|
1224
|
-
//
|
|
1255
|
+
// Otherwise, apply the completion without submitting the surrounding draft.
|
|
1225
1256
|
else if (kb.matches(data, "tui.input.submit") || data === "\n") {
|
|
1226
1257
|
const selected = this.#autocompleteList.getSelectedItem();
|
|
1227
1258
|
// Check for stale autocomplete state due to buffer edits since last refresh.
|
|
@@ -2914,8 +2945,8 @@ export class Editor implements Component, Focusable {
|
|
|
2914
2945
|
* via the no-command-match fall-through) share the leading-slash prefix shape
|
|
2915
2946
|
* but must use the live-suffix path rule so the apply slice stays anchored.
|
|
2916
2947
|
* - Mid-prompt skill branch re-anchors when the popup item is a skill and the
|
|
2917
|
-
* current text still ends in a trailing slash token,
|
|
2918
|
-
*
|
|
2948
|
+
* current text still ends in a matching trailing slash token, preventing a
|
|
2949
|
+
* stale selection from replacing a newer skill prefix.
|
|
2919
2950
|
* - `@`-file branch re-anchors via `#extractAtPrefix`; safe when the current text
|
|
2920
2951
|
* still ends in a whitespace-anchored `@<token>`.
|
|
2921
2952
|
* - Everything else is stale — accepting it would corrupt the buffer (issue #4295).
|
|
@@ -4,7 +4,7 @@ import { latexToBlock } from "../latex-block";
|
|
|
4
4
|
import { inlineMathSpanEnd, isBareMathEnvironment, latexToUnicode } from "../latex-to-unicode";
|
|
5
5
|
import type { SymbolTheme } from "../symbols";
|
|
6
6
|
import { TERMINAL } from "../terminal-capabilities";
|
|
7
|
-
import type { Component } from "../tui";
|
|
7
|
+
import type { Component, NativeScrollbackCommittedRows, NativeScrollbackReplay } from "../tui";
|
|
8
8
|
import {
|
|
9
9
|
applyBackgroundToLine,
|
|
10
10
|
Ellipsis,
|
|
@@ -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
|
|
@@ -607,11 +642,17 @@ const RENDER_CACHE_MAX = 256; // sane cap: ~256 distinct message × width combos
|
|
|
607
642
|
const RENDER_CACHE_MAX_SIZE = 512 * 1024;
|
|
608
643
|
const RENDER_CACHE_MAX_ENTRY_SIZE = 32 * 1024;
|
|
609
644
|
const EMPTY_RENDER_LINES: readonly string[] = [];
|
|
610
|
-
|
|
645
|
+
|
|
646
|
+
interface RenderCacheEntry {
|
|
647
|
+
lines: readonly string[];
|
|
648
|
+
tables: readonly RenderedTableLayout[];
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const renderCache = new LRUCache<string, RenderCacheEntry>({
|
|
611
652
|
max: RENDER_CACHE_MAX,
|
|
612
653
|
maxSize: RENDER_CACHE_MAX_SIZE,
|
|
613
654
|
maxEntrySize: RENDER_CACHE_MAX_ENTRY_SIZE,
|
|
614
|
-
sizeCalculation:
|
|
655
|
+
sizeCalculation: renderCacheEntrySize,
|
|
615
656
|
});
|
|
616
657
|
|
|
617
658
|
function renderedLinesCacheSize(lines: readonly string[]): number {
|
|
@@ -620,6 +661,12 @@ function renderedLinesCacheSize(lines: readonly string[]): number {
|
|
|
620
661
|
return Math.max(1, size);
|
|
621
662
|
}
|
|
622
663
|
|
|
664
|
+
function renderCacheEntrySize(entry: RenderCacheEntry): number {
|
|
665
|
+
let size = renderedLinesCacheSize(entry.lines);
|
|
666
|
+
for (const table of entry.tables) size += table.key.length + table.columnWidths.length + 4;
|
|
667
|
+
return size;
|
|
668
|
+
}
|
|
669
|
+
|
|
623
670
|
// A reference-link definition (`[label]: dest`) resolves across the whole
|
|
624
671
|
// document, so a split lex cannot reproduce it — disable the streaming fast path
|
|
625
672
|
// when one is present (rare in streamed output). The label may contain
|
|
@@ -951,6 +998,7 @@ interface StreamPrefixLineCache extends RenderSignature {
|
|
|
951
998
|
text: string;
|
|
952
999
|
tokenCount: number;
|
|
953
1000
|
lines: readonly string[];
|
|
1001
|
+
tables: readonly TableRenderSpec[];
|
|
954
1002
|
}
|
|
955
1003
|
interface StreamingDiffLineCache extends RenderSignature {
|
|
956
1004
|
lang: string | undefined;
|
|
@@ -958,7 +1006,25 @@ interface StreamingDiffLineCache extends RenderSignature {
|
|
|
958
1006
|
lines: readonly string[];
|
|
959
1007
|
}
|
|
960
1008
|
|
|
961
|
-
|
|
1009
|
+
interface TableLayoutLock {
|
|
1010
|
+
availableWidth: number;
|
|
1011
|
+
columnWidths: readonly number[];
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
interface TableRenderSpec extends TableLayoutLock {
|
|
1015
|
+
key: string;
|
|
1016
|
+
lineCount: number;
|
|
1017
|
+
startRow: number;
|
|
1018
|
+
endRow: number;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
interface RenderedTableLayout extends TableLayoutLock {
|
|
1022
|
+
key: string;
|
|
1023
|
+
startRow: number;
|
|
1024
|
+
endRow: number;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
export class Markdown implements Component, NativeScrollbackCommittedRows, NativeScrollbackReplay {
|
|
962
1028
|
#text: string;
|
|
963
1029
|
#paddingX: number; // Left/right padding
|
|
964
1030
|
#paddingY: number; // Top/bottom padding
|
|
@@ -1005,10 +1071,19 @@ export class Markdown implements Component {
|
|
|
1005
1071
|
#renderingFrozenPrefix = false;
|
|
1006
1072
|
#streamingDiffLineCache?: StreamingDiffLineCache;
|
|
1007
1073
|
#activeRenderSignature?: RenderSignature;
|
|
1074
|
+
// Streaming tables may grow naturally while wholly repaintable. Once any
|
|
1075
|
+
// physical row of a table enters native scrollback, its current column widths
|
|
1076
|
+
// are locked for the rest of this append-only text lineage: future wider cells
|
|
1077
|
+
// wrap inside those columns instead of reflowing immutable history above.
|
|
1078
|
+
#tableLayoutWidth?: number;
|
|
1079
|
+
#lockedTableLayouts = new Map<string, TableLayoutLock>();
|
|
1080
|
+
#lastRenderedTableLayouts: RenderedTableLayout[] = [];
|
|
1081
|
+
#activeTableRenderSpecs?: TableRenderSpec[];
|
|
1008
1082
|
|
|
1009
1083
|
#ignoreTight = false;
|
|
1010
1084
|
|
|
1011
1085
|
setIgnoreTight(ignore: boolean): this {
|
|
1086
|
+
if (this.#ignoreTight !== ignore) this.#clearTableLayouts();
|
|
1012
1087
|
this.#ignoreTight = ignore;
|
|
1013
1088
|
this.invalidate();
|
|
1014
1089
|
return this;
|
|
@@ -1037,6 +1112,7 @@ export class Markdown implements Component {
|
|
|
1037
1112
|
// full lex + wrap runs per re-emit — one of the top CPU hotspots during
|
|
1038
1113
|
// streaming (issue #4353). Mirrors `Text.setText`'s guard.
|
|
1039
1114
|
if (text === this.#text) return false;
|
|
1115
|
+
if (!text.startsWith(this.#text)) this.#clearTableLayouts();
|
|
1040
1116
|
this.#text = text;
|
|
1041
1117
|
if (!text.trim()) {
|
|
1042
1118
|
// Blank replacement: render() early-returns before #lexTokens can see
|
|
@@ -1080,6 +1156,41 @@ export class Markdown implements Component {
|
|
|
1080
1156
|
return this.#lastRenderSettledRows;
|
|
1081
1157
|
}
|
|
1082
1158
|
|
|
1159
|
+
/**
|
|
1160
|
+
* Freeze every table whose first physical row is already part of the native
|
|
1161
|
+
* scrollback prefix. The recorded widths came from the exact frame that was
|
|
1162
|
+
* just emitted, so the next streamed delta cannot retroactively widen it.
|
|
1163
|
+
*/
|
|
1164
|
+
setNativeScrollbackCommittedRows(rows: number): void {
|
|
1165
|
+
const committed = Number.isFinite(rows) ? Math.max(0, Math.trunc(rows)) : 0;
|
|
1166
|
+
let changed = false;
|
|
1167
|
+
for (const table of this.#lastRenderedTableLayouts) {
|
|
1168
|
+
if (table.startRow >= committed || this.#lockedTableLayouts.has(table.key)) continue;
|
|
1169
|
+
this.#lockedTableLayouts.set(table.key, {
|
|
1170
|
+
availableWidth: table.availableWidth,
|
|
1171
|
+
columnWidths: table.columnWidths.slice(),
|
|
1172
|
+
});
|
|
1173
|
+
changed = true;
|
|
1174
|
+
}
|
|
1175
|
+
if (changed) this.invalidate();
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/** A destructive replay removes the immutable tape this layout was guarding. */
|
|
1179
|
+
prepareNativeScrollbackReplay(): void {
|
|
1180
|
+
this.#clearTableLayouts();
|
|
1181
|
+
this.#tableLayoutWidth = undefined;
|
|
1182
|
+
this.invalidate();
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
#clearTableLayouts(): void {
|
|
1186
|
+
this.#lockedTableLayouts.clear();
|
|
1187
|
+
this.#lastRenderedTableLayouts = [];
|
|
1188
|
+
this.#activeTableRenderSpecs = undefined;
|
|
1189
|
+
// Same-width replay/non-append rewrites could otherwise reuse physical
|
|
1190
|
+
// prefix lines rendered with the retired locked widths.
|
|
1191
|
+
this.#streamPrefixLineCache = undefined;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1083
1194
|
// Lex `text` into block tokens, reusing the frozen stable prefix when the text
|
|
1084
1195
|
// only grew (the streaming path). Falls back to a full lex whenever the prefix
|
|
1085
1196
|
// is no longer a prefix (non-append edit), the text carries reference-link
|
|
@@ -1159,6 +1270,11 @@ export class Markdown implements Component {
|
|
|
1159
1270
|
}
|
|
1160
1271
|
|
|
1161
1272
|
render(width: number): readonly string[] {
|
|
1273
|
+
if (this.#tableLayoutWidth !== undefined && this.#tableLayoutWidth !== width) {
|
|
1274
|
+
this.#clearTableLayouts();
|
|
1275
|
+
this.invalidate();
|
|
1276
|
+
}
|
|
1277
|
+
this.#tableLayoutWidth = width;
|
|
1162
1278
|
// L1: per-instance cache — fastest path for repeated renders of the same
|
|
1163
1279
|
// instance at the same width (e.g. resize debounce, repeated redraws).
|
|
1164
1280
|
// Returning the cached reference is load-bearing: parents memoize their
|
|
@@ -1200,29 +1316,40 @@ export class Markdown implements Component {
|
|
|
1200
1316
|
// theme.heading is used as the representative theme probe — it's required
|
|
1201
1317
|
// by MarkdownTheme and is one of the most styling-sensitive entries.
|
|
1202
1318
|
let cacheKey: string | undefined;
|
|
1203
|
-
if (!this.transientRenderCache) {
|
|
1319
|
+
if (!this.transientRenderCache && this.#lockedTableLayouts.size === 0) {
|
|
1204
1320
|
cacheKey = this.#renderCacheKey(normalizedText, signature);
|
|
1205
1321
|
const cached = renderCache.get(cacheKey);
|
|
1206
1322
|
if (cached !== undefined) {
|
|
1323
|
+
// Restore both the rendered rows and the geometry metadata that produced
|
|
1324
|
+
// them. A later scrollback publication must never lock widths from an
|
|
1325
|
+
// older transient frame against rows served from this cache entry.
|
|
1326
|
+
this.#lastRenderedTableLayouts = cached.tables.map(table => ({
|
|
1327
|
+
...table,
|
|
1328
|
+
columnWidths: table.columnWidths.slice(),
|
|
1329
|
+
}));
|
|
1207
1330
|
// Populate L1 so subsequent calls from this instance are O(1) map lookup.
|
|
1208
1331
|
this.#cachedText = this.#text;
|
|
1209
1332
|
this.#cachedWidth = width;
|
|
1210
|
-
this.#cachedLines = cached;
|
|
1211
|
-
return cached;
|
|
1333
|
+
this.#cachedLines = cached.lines;
|
|
1334
|
+
return cached.lines;
|
|
1212
1335
|
}
|
|
1213
1336
|
}
|
|
1214
1337
|
|
|
1215
1338
|
// Parse markdown to HTML-like tokens
|
|
1216
1339
|
const tokens = this.#lexTokens(normalizedText);
|
|
1217
1340
|
let contentLines: string[];
|
|
1341
|
+
const tableRenderSpecs: TableRenderSpec[] = [];
|
|
1342
|
+
this.#activeTableRenderSpecs = tableRenderSpecs;
|
|
1218
1343
|
this.#activeRenderSignature = signature;
|
|
1219
1344
|
try {
|
|
1220
1345
|
contentLines = this.transientRenderCache
|
|
1221
1346
|
? this.#renderStreamingContentLines(tokens, normalizedText, signature, contentWidth)
|
|
1222
|
-
: this.#renderContentLines(tokens, 0, tokens.length, contentWidth, signature);
|
|
1347
|
+
: this.#renderContentLines(tokens, 0, tokens.length, contentWidth, signature, 0, 0);
|
|
1223
1348
|
} finally {
|
|
1224
1349
|
this.#activeRenderSignature = undefined;
|
|
1350
|
+
this.#activeTableRenderSpecs = undefined;
|
|
1225
1351
|
}
|
|
1352
|
+
this.#lastRenderedTableLayouts = this.#resolveRenderedTableLayouts(tableRenderSpecs, signature.paddingY);
|
|
1226
1353
|
const emptyLines = this.#renderEmptyPaddingLines(signature);
|
|
1227
1354
|
|
|
1228
1355
|
// Combine top padding, content, and bottom padding
|
|
@@ -1239,7 +1366,13 @@ export class Markdown implements Component {
|
|
|
1239
1366
|
// Update L2 module-level LRU so future instances with the same key skip
|
|
1240
1367
|
// the marked.lexer + highlightCode (Rust FFI) work entirely.
|
|
1241
1368
|
if (cacheKey !== undefined) {
|
|
1242
|
-
renderCache.set(cacheKey,
|
|
1369
|
+
renderCache.set(cacheKey, {
|
|
1370
|
+
lines: result,
|
|
1371
|
+
tables: this.#lastRenderedTableLayouts.map(table => ({
|
|
1372
|
+
...table,
|
|
1373
|
+
columnWidths: table.columnWidths.slice(),
|
|
1374
|
+
})),
|
|
1375
|
+
});
|
|
1243
1376
|
}
|
|
1244
1377
|
|
|
1245
1378
|
return result;
|
|
@@ -1276,15 +1409,18 @@ export class Markdown implements Component {
|
|
|
1276
1409
|
const frozenText = this.#streamPrefixText;
|
|
1277
1410
|
const frozenTokenCount = this.#streamPrefixTokens?.length ?? 0;
|
|
1278
1411
|
if (frozenText === undefined || frozenTokenCount === 0 || !normalizedText.startsWith(frozenText)) {
|
|
1279
|
-
return this.#renderContentLines(tokens, 0, tokens.length, contentWidth, signature);
|
|
1412
|
+
return this.#renderContentLines(tokens, 0, tokens.length, contentWidth, signature, 0, 0);
|
|
1280
1413
|
}
|
|
1281
1414
|
|
|
1282
1415
|
const contentLines: string[] = [];
|
|
1283
1416
|
const reusablePrefix = this.#matchingStreamPrefixLineCache(normalizedText, frozenText, signature);
|
|
1284
1417
|
let renderedUntil = 0;
|
|
1418
|
+
let renderedSourceOffset = 0;
|
|
1285
1419
|
if (reusablePrefix && reusablePrefix.tokenCount <= frozenTokenCount) {
|
|
1286
1420
|
contentLines.push(...reusablePrefix.lines);
|
|
1421
|
+
this.#activeTableRenderSpecs?.push(...reusablePrefix.tables);
|
|
1287
1422
|
renderedUntil = reusablePrefix.tokenCount;
|
|
1423
|
+
renderedSourceOffset = reusablePrefix.text.length;
|
|
1288
1424
|
}
|
|
1289
1425
|
|
|
1290
1426
|
if (renderedUntil < frozenTokenCount) {
|
|
@@ -1293,7 +1429,15 @@ export class Markdown implements Component {
|
|
|
1293
1429
|
this.#renderingFrozenPrefix = true;
|
|
1294
1430
|
try {
|
|
1295
1431
|
contentLines.push(
|
|
1296
|
-
...this.#renderContentLines(
|
|
1432
|
+
...this.#renderContentLines(
|
|
1433
|
+
tokens,
|
|
1434
|
+
renderedUntil,
|
|
1435
|
+
frozenTokenCount,
|
|
1436
|
+
contentWidth,
|
|
1437
|
+
signature,
|
|
1438
|
+
contentLines.length,
|
|
1439
|
+
renderedSourceOffset,
|
|
1440
|
+
),
|
|
1297
1441
|
);
|
|
1298
1442
|
} finally {
|
|
1299
1443
|
this.#renderingFrozenPrefix = false;
|
|
@@ -1306,6 +1450,7 @@ export class Markdown implements Component {
|
|
|
1306
1450
|
text: frozenText,
|
|
1307
1451
|
tokenCount: frozenTokenCount,
|
|
1308
1452
|
lines: contentLines.slice(),
|
|
1453
|
+
tables: this.#activeTableRenderSpecs?.slice() ?? [],
|
|
1309
1454
|
};
|
|
1310
1455
|
|
|
1311
1456
|
// Settled exposure (hard-monotone): these rows are declared final to
|
|
@@ -1322,7 +1467,17 @@ export class Markdown implements Component {
|
|
|
1322
1467
|
}
|
|
1323
1468
|
|
|
1324
1469
|
if (renderedUntil < tokens.length) {
|
|
1325
|
-
contentLines.push(
|
|
1470
|
+
contentLines.push(
|
|
1471
|
+
...this.#renderContentLines(
|
|
1472
|
+
tokens,
|
|
1473
|
+
renderedUntil,
|
|
1474
|
+
tokens.length,
|
|
1475
|
+
contentWidth,
|
|
1476
|
+
signature,
|
|
1477
|
+
contentLines.length,
|
|
1478
|
+
frozenText.length,
|
|
1479
|
+
),
|
|
1480
|
+
);
|
|
1326
1481
|
}
|
|
1327
1482
|
|
|
1328
1483
|
return contentLines;
|
|
@@ -1356,23 +1511,57 @@ export class Markdown implements Component {
|
|
|
1356
1511
|
end: number,
|
|
1357
1512
|
contentWidth: number,
|
|
1358
1513
|
signature: RenderSignature,
|
|
1514
|
+
rowOffset: number,
|
|
1515
|
+
startingSourceOffset: number,
|
|
1359
1516
|
): string[] {
|
|
1360
|
-
const
|
|
1517
|
+
const wrappedLines: string[] = [];
|
|
1518
|
+
let sourceOffset = startingSourceOffset;
|
|
1361
1519
|
for (let i = start; i < end; i++) {
|
|
1362
1520
|
const token = tokens[i];
|
|
1363
1521
|
const nextToken = tokens[i + 1];
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1522
|
+
const tableSpecStart = this.#activeTableRenderSpecs?.length ?? 0;
|
|
1523
|
+
const tokenWrappedRowStart = wrappedLines.length;
|
|
1524
|
+
const tokenRowStart = rowOffset + tokenWrappedRowStart;
|
|
1525
|
+
const renderedTokenLines = this.#renderToken(
|
|
1526
|
+
token,
|
|
1527
|
+
contentWidth,
|
|
1528
|
+
nextToken?.type,
|
|
1529
|
+
undefined,
|
|
1530
|
+
`offset:${sourceOffset}`,
|
|
1531
|
+
);
|
|
1532
|
+
const tokenLineOffsets = [0];
|
|
1533
|
+
for (const line of renderedTokenLines) {
|
|
1534
|
+
// Skip wrapping for image protocol lines and OSC 66 sized headings
|
|
1535
|
+
// (would corrupt escape sequences / split the indivisible sized span).
|
|
1536
|
+
if (TERMINAL.isImageLine(line) || isOsc66Line(line)) {
|
|
1537
|
+
wrappedLines.push(line);
|
|
1538
|
+
} else {
|
|
1539
|
+
wrappedLines.push(...wrapTextWithAnsi(line, contentWidth));
|
|
1540
|
+
}
|
|
1541
|
+
tokenLineOffsets.push(wrappedLines.length - tokenWrappedRowStart);
|
|
1542
|
+
}
|
|
1543
|
+
const tableSpecs = this.#activeTableRenderSpecs;
|
|
1544
|
+
if (tableSpecs !== undefined) {
|
|
1545
|
+
for (let specIndex = tableSpecStart; specIndex < tableSpecs.length; specIndex++) {
|
|
1546
|
+
const spec = tableSpecs[specIndex]!;
|
|
1547
|
+
let relativeStart: number;
|
|
1548
|
+
let relativeEnd: number;
|
|
1549
|
+
if (token.type === "table") {
|
|
1550
|
+
// Exclude the optional inter-block blank from a top-level table's span.
|
|
1551
|
+
relativeStart = 0;
|
|
1552
|
+
relativeEnd = Math.min(renderedTokenLines.length, spec.lineCount);
|
|
1553
|
+
} else {
|
|
1554
|
+
// Container renderers express nested table spans relative to their
|
|
1555
|
+
// returned lines. Preserve that exact span through this final wrap.
|
|
1556
|
+
if (spec.startRow < 0 || spec.endRow <= spec.startRow) continue;
|
|
1557
|
+
relativeStart = Math.min(renderedTokenLines.length, spec.startRow);
|
|
1558
|
+
relativeEnd = Math.min(renderedTokenLines.length, spec.endRow);
|
|
1559
|
+
}
|
|
1560
|
+
spec.startRow = tokenRowStart + tokenLineOffsets[relativeStart]!;
|
|
1561
|
+
spec.endRow = tokenRowStart + tokenLineOffsets[relativeEnd]!;
|
|
1562
|
+
}
|
|
1375
1563
|
}
|
|
1564
|
+
sourceOffset += token.raw.length;
|
|
1376
1565
|
}
|
|
1377
1566
|
|
|
1378
1567
|
const leftMargin = padding(signature.paddingX);
|
|
@@ -1416,6 +1605,21 @@ export class Markdown implements Component {
|
|
|
1416
1605
|
return contentLines;
|
|
1417
1606
|
}
|
|
1418
1607
|
|
|
1608
|
+
#resolveRenderedTableLayouts(specs: readonly TableRenderSpec[], topPadding: number): RenderedTableLayout[] {
|
|
1609
|
+
const layouts: RenderedTableLayout[] = [];
|
|
1610
|
+
for (const spec of specs) {
|
|
1611
|
+
if (spec.startRow < 0 || spec.endRow <= spec.startRow) continue;
|
|
1612
|
+
layouts.push({
|
|
1613
|
+
key: spec.key,
|
|
1614
|
+
availableWidth: spec.availableWidth,
|
|
1615
|
+
columnWidths: spec.columnWidths.slice(),
|
|
1616
|
+
startRow: topPadding + spec.startRow,
|
|
1617
|
+
endRow: topPadding + spec.endRow,
|
|
1618
|
+
});
|
|
1619
|
+
}
|
|
1620
|
+
return layouts;
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1419
1623
|
#renderCodeBodyLines(token: Token, codeIndent: string): string[] {
|
|
1420
1624
|
const bodyLines: string[] = [];
|
|
1421
1625
|
const tokenText = "text" in token && typeof token.text === "string" ? token.text : "";
|
|
@@ -1626,7 +1830,13 @@ export class Markdown implements Component {
|
|
|
1626
1830
|
};
|
|
1627
1831
|
}
|
|
1628
1832
|
|
|
1629
|
-
#renderToken(
|
|
1833
|
+
#renderToken(
|
|
1834
|
+
token: Token,
|
|
1835
|
+
width: number,
|
|
1836
|
+
nextTokenType?: string,
|
|
1837
|
+
styleContext?: InlineStyleContext,
|
|
1838
|
+
tokenKey = "root",
|
|
1839
|
+
): string[] {
|
|
1630
1840
|
const lines: string[] = [];
|
|
1631
1841
|
|
|
1632
1842
|
// Display math block (own-line `$$…$$` / `\[…\]`): stack `\frac` vertically
|
|
@@ -1728,7 +1938,7 @@ export class Markdown implements Component {
|
|
|
1728
1938
|
}
|
|
1729
1939
|
|
|
1730
1940
|
case "table": {
|
|
1731
|
-
const tableLines = this.#renderTable(token as TableToken, width, nextTokenType, styleContext);
|
|
1941
|
+
const tableLines = this.#renderTable(token as TableToken, width, nextTokenType, styleContext, tokenKey);
|
|
1732
1942
|
lines.push(...tableLines);
|
|
1733
1943
|
break;
|
|
1734
1944
|
}
|
|
@@ -1741,20 +1951,59 @@ export class Markdown implements Component {
|
|
|
1741
1951
|
const quoteContentWidth = Math.max(1, width - 2);
|
|
1742
1952
|
const quoteTokens = token.tokens || [];
|
|
1743
1953
|
const renderedQuoteLines: string[] = [];
|
|
1954
|
+
const blockquoteSpecStart = this.#activeTableRenderSpecs?.length ?? 0;
|
|
1744
1955
|
|
|
1745
1956
|
for (let i = 0; i < quoteTokens.length; i++) {
|
|
1746
1957
|
const quoteToken = quoteTokens[i];
|
|
1747
1958
|
const nextQuoteToken = quoteTokens[i + 1];
|
|
1748
|
-
renderedQuoteLines.
|
|
1749
|
-
|
|
1959
|
+
const quoteTokenRowStart = renderedQuoteLines.length;
|
|
1960
|
+
const quoteSpecStart = this.#activeTableRenderSpecs?.length ?? 0;
|
|
1961
|
+
const quoteTokenLines = this.#renderToken(
|
|
1962
|
+
quoteToken,
|
|
1963
|
+
quoteContentWidth,
|
|
1964
|
+
nextQuoteToken?.type,
|
|
1965
|
+
quoteInlineStyleContext,
|
|
1966
|
+
`${tokenKey}/quote:${i}`,
|
|
1750
1967
|
);
|
|
1968
|
+
renderedQuoteLines.push(...quoteTokenLines);
|
|
1969
|
+
|
|
1970
|
+
const tableSpecs = this.#activeTableRenderSpecs;
|
|
1971
|
+
if (tableSpecs !== undefined) {
|
|
1972
|
+
for (let specIndex = quoteSpecStart; specIndex < tableSpecs.length; specIndex++) {
|
|
1973
|
+
const spec = tableSpecs[specIndex]!;
|
|
1974
|
+
if (spec.startRow < 0) {
|
|
1975
|
+
// Direct child tables initially have no row coordinates. Their
|
|
1976
|
+
// structural line count excludes any inter-block blank.
|
|
1977
|
+
spec.startRow = quoteTokenRowStart;
|
|
1978
|
+
spec.endRow = quoteTokenRowStart + Math.min(quoteTokenLines.length, spec.lineCount);
|
|
1979
|
+
} else {
|
|
1980
|
+
// A nested blockquote already mapped the table into its own
|
|
1981
|
+
// returned rows; translate those rows into this quote's input.
|
|
1982
|
+
spec.startRow += quoteTokenRowStart;
|
|
1983
|
+
spec.endRow += quoteTokenRowStart;
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1751
1987
|
}
|
|
1752
1988
|
|
|
1753
1989
|
while (renderedQuoteLines.length > 0 && renderedQuoteLines[renderedQuoteLines.length - 1] === "") {
|
|
1754
1990
|
renderedQuoteLines.pop();
|
|
1755
1991
|
}
|
|
1756
1992
|
|
|
1757
|
-
|
|
1993
|
+
const quoteRowOffsets: number[] = [];
|
|
1994
|
+
const borderedQuoteLines = this.#applyQuoteBorder(renderedQuoteLines, width, quoteRowOffsets);
|
|
1995
|
+
const tableSpecs = this.#activeTableRenderSpecs;
|
|
1996
|
+
if (tableSpecs !== undefined) {
|
|
1997
|
+
for (let specIndex = blockquoteSpecStart; specIndex < tableSpecs.length; specIndex++) {
|
|
1998
|
+
const spec = tableSpecs[specIndex]!;
|
|
1999
|
+
if (spec.startRow < 0 || spec.endRow <= spec.startRow) continue;
|
|
2000
|
+
const relativeStart = Math.min(renderedQuoteLines.length, spec.startRow);
|
|
2001
|
+
const relativeEnd = Math.min(renderedQuoteLines.length, spec.endRow);
|
|
2002
|
+
spec.startRow = quoteRowOffsets[relativeStart]!;
|
|
2003
|
+
spec.endRow = quoteRowOffsets[relativeEnd]!;
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
lines.push(...borderedQuoteLines);
|
|
1758
2007
|
if (nextTokenType && nextTokenType !== "space") {
|
|
1759
2008
|
lines.push(""); // Add spacing after blockquotes (unless space token follows)
|
|
1760
2009
|
}
|
|
@@ -1801,7 +2050,7 @@ export class Markdown implements Component {
|
|
|
1801
2050
|
* Wrap already-rendered lines in the blockquote border and quote styling.
|
|
1802
2051
|
* `width` is the full content width; the border reserves two cells.
|
|
1803
2052
|
*/
|
|
1804
|
-
#applyQuoteBorder(renderedLines: string[], width: number): string[] {
|
|
2053
|
+
#applyQuoteBorder(renderedLines: string[], width: number, sourceRowOffsets?: number[]): string[] {
|
|
1805
2054
|
const quoteStyle = (text: string) => this.#theme.quote(this.#theme.italic(text));
|
|
1806
2055
|
const quoteStylePrefix = this.#getStylePrefix(quoteStyle);
|
|
1807
2056
|
const applyQuoteStyle = (line: string): string => {
|
|
@@ -1813,11 +2062,13 @@ export class Markdown implements Component {
|
|
|
1813
2062
|
};
|
|
1814
2063
|
const quoteContentWidth = Math.max(1, width - 2);
|
|
1815
2064
|
const lines: string[] = [];
|
|
2065
|
+
sourceRowOffsets?.push(0);
|
|
1816
2066
|
for (const quoteLine of renderedLines) {
|
|
1817
2067
|
const styledLine = applyQuoteStyle(quoteLine);
|
|
1818
2068
|
for (const wrappedLine of wrapTextWithAnsi(styledLine, quoteContentWidth)) {
|
|
1819
2069
|
lines.push(this.#theme.quoteBorder(`${this.#theme.symbols.quoteBorder} `) + wrappedLine);
|
|
1820
2070
|
}
|
|
2071
|
+
sourceRowOffsets?.push(lines.length);
|
|
1821
2072
|
}
|
|
1822
2073
|
return lines;
|
|
1823
2074
|
}
|
|
@@ -2149,6 +2400,7 @@ export class Markdown implements Component {
|
|
|
2149
2400
|
availableWidth: number,
|
|
2150
2401
|
nextTokenType?: string,
|
|
2151
2402
|
styleContext?: InlineStyleContext,
|
|
2403
|
+
tableKey = "table",
|
|
2152
2404
|
): string[] {
|
|
2153
2405
|
const lines: string[] = [];
|
|
2154
2406
|
const numCols = token.header.length;
|
|
@@ -2263,6 +2515,17 @@ export class Markdown implements Component {
|
|
|
2263
2515
|
}
|
|
2264
2516
|
}
|
|
2265
2517
|
|
|
2518
|
+
const lockedLayout = this.#lockedTableLayouts.get(tableKey);
|
|
2519
|
+
if (
|
|
2520
|
+
lockedLayout !== undefined &&
|
|
2521
|
+
lockedLayout.availableWidth === availableWidth &&
|
|
2522
|
+
lockedLayout.columnWidths.length === numCols &&
|
|
2523
|
+
lockedLayout.columnWidths.every(width => Number.isFinite(width) && width >= 1) &&
|
|
2524
|
+
lockedLayout.columnWidths.reduce((total, width) => total + width, borderOverhead) <= availableWidth
|
|
2525
|
+
) {
|
|
2526
|
+
columnWidths = lockedLayout.columnWidths.slice();
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2266
2529
|
const t = this.#theme.symbols.table;
|
|
2267
2530
|
const h = t.horizontal;
|
|
2268
2531
|
const v = t.vertical;
|
|
@@ -2316,7 +2579,16 @@ export class Markdown implements Component {
|
|
|
2316
2579
|
|
|
2317
2580
|
// Render bottom border
|
|
2318
2581
|
const bottomBorderCells = columnWidths.map(w => h.repeat(w));
|
|
2319
|
-
|
|
2582
|
+
const bottomBorder = `${t.bottomLeft}${h}${bottomBorderCells.join(`${h}${t.teeUp}${h}`)}${h}${t.bottomRight}`;
|
|
2583
|
+
lines.push(bottomBorder);
|
|
2584
|
+
this.#activeTableRenderSpecs?.push({
|
|
2585
|
+
key: tableKey,
|
|
2586
|
+
availableWidth,
|
|
2587
|
+
columnWidths: columnWidths.slice(),
|
|
2588
|
+
lineCount: lines.length,
|
|
2589
|
+
startRow: -1,
|
|
2590
|
+
endRow: -1,
|
|
2591
|
+
});
|
|
2320
2592
|
|
|
2321
2593
|
if (nextTokenType && nextTokenType !== "space") {
|
|
2322
2594
|
lines.push(""); // Add spacing after table
|
|
@@ -36,6 +36,38 @@ export type TerminalId =
|
|
|
36
36
|
| "base"
|
|
37
37
|
| "trueColor";
|
|
38
38
|
|
|
39
|
+
const CMUX_NOTIFICATION_TITLE = "Oh My Pi";
|
|
40
|
+
const CMUX_SURFACE_ID_PATTERN = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/iu;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Route a notification through cmux when the process belongs to a concrete
|
|
44
|
+
* surface. Workspace/socket state alone is not enough: only the injected
|
|
45
|
+
* surface UUID identifies the pane that should receive the notification.
|
|
46
|
+
* Returns whether cmux owns delivery so the caller can preserve every existing
|
|
47
|
+
* terminal fallback unchanged when no valid surface is present.
|
|
48
|
+
*/
|
|
49
|
+
function sendCmuxNotification(message: string | TerminalNotification, env: NodeJS.ProcessEnv = Bun.env): boolean {
|
|
50
|
+
const surfaceId = env.CMUX_SURFACE_ID?.trim();
|
|
51
|
+
if (!surfaceId || !CMUX_SURFACE_ID_PATTERN.test(surfaceId)) return false;
|
|
52
|
+
|
|
53
|
+
const title =
|
|
54
|
+
typeof message === "string" ? CMUX_NOTIFICATION_TITLE : message.title?.trim() || CMUX_NOTIFICATION_TITLE;
|
|
55
|
+
const body = typeof message === "string" ? message : (message.body ?? "");
|
|
56
|
+
try {
|
|
57
|
+
const child = Bun.spawn({
|
|
58
|
+
cmd: ["cmux", "notify", "--surface", surfaceId, "--title", title, "--body", body],
|
|
59
|
+
stdin: "ignore",
|
|
60
|
+
stdout: "ignore",
|
|
61
|
+
stderr: "ignore",
|
|
62
|
+
});
|
|
63
|
+
child.unref();
|
|
64
|
+
} catch {
|
|
65
|
+
// A missing cmux binary leaves delivery to the existing terminal fallback.
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
39
71
|
function hasNeedleBefore(line: string, needle: string, limit: number): boolean {
|
|
40
72
|
const index = line.indexOf(needle);
|
|
41
73
|
return index !== -1 && index + needle.length <= limit;
|
|
@@ -111,6 +143,7 @@ export class TerminalInfo {
|
|
|
111
143
|
|
|
112
144
|
sendNotification(message: string | TerminalNotification): void {
|
|
113
145
|
if (isNotificationSuppressed() || isTerminalHeadless()) return;
|
|
146
|
+
if (sendCmuxNotification(message)) return;
|
|
114
147
|
const formatted = this.formatNotification(message);
|
|
115
148
|
// Under tmux, terminals whose notify protocol is OSC 9 / OSC 99 would
|
|
116
149
|
// otherwise lose the notification entirely: tmux does not forward bare
|
package/src/terminal.ts
CHANGED
|
@@ -13,7 +13,6 @@ import { setKittyProtocolActive } from "./keys";
|
|
|
13
13
|
import { StdinBuffer } from "./stdin-buffer";
|
|
14
14
|
import {
|
|
15
15
|
isInsideTerminalMultiplexer,
|
|
16
|
-
isInsideTmux,
|
|
17
16
|
NotifyProtocol,
|
|
18
17
|
setCellDimensions,
|
|
19
18
|
setOsc99Supported,
|
|
@@ -45,7 +44,6 @@ export function resolveHangulCompatibilityJamoWidthFromTerminalIdentity(
|
|
|
45
44
|
}
|
|
46
45
|
|
|
47
46
|
function shouldEnableModifyOtherKeysFallback(env: NodeJS.ProcessEnv = Bun.env): boolean {
|
|
48
|
-
if (isInsideTmux(env)) return false;
|
|
49
47
|
if (!env.SSH_CONNECTION && !env.SSH_TTY && !env.SSH_CLIENT) return true;
|
|
50
48
|
return TERMINAL.id !== "base" && TERMINAL.id !== "trueColor";
|
|
51
49
|
}
|
|
@@ -404,13 +402,25 @@ export interface Terminal {
|
|
|
404
402
|
* already-detected appearance so late subscribers never miss it.
|
|
405
403
|
*/
|
|
406
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;
|
|
407
415
|
/** The last detected terminal appearance, or undefined if not yet known. */
|
|
408
416
|
get appearance(): TerminalAppearance | undefined;
|
|
409
417
|
/**
|
|
410
418
|
* Register a callback fired once per DEC private mode when its DECRQM support
|
|
411
|
-
* status resolves.
|
|
419
|
+
* status resolves. `confirmed` is false when the terminal answered the DA1
|
|
420
|
+
* sentinel without answering DECRQM, which proves only that querying support
|
|
421
|
+
* is unavailable — not that the private mode itself is unsupported.
|
|
412
422
|
*/
|
|
413
|
-
onPrivateModeReport?(callback: (mode: number, supported: boolean) => void): void;
|
|
423
|
+
onPrivateModeReport?(callback: (mode: number, supported: boolean, confirmed?: boolean) => void): void;
|
|
414
424
|
}
|
|
415
425
|
|
|
416
426
|
/**
|
|
@@ -498,7 +508,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
498
508
|
#da1SentinelOwners: Da1SentinelOwner[] = [];
|
|
499
509
|
/** Resolved DECRQM support per private mode (mode → supported). */
|
|
500
510
|
#privateModeSupport = new Map<number, boolean>();
|
|
501
|
-
#privateModeCallbacks: Array<(mode: number, supported: boolean) => void> = [];
|
|
511
|
+
#privateModeCallbacks: Array<(mode: number, supported: boolean, confirmed: boolean) => void> = [];
|
|
502
512
|
/** Whether DEC 2048 in-band resize notifications are currently enabled. */
|
|
503
513
|
#inBandResizeActive = false;
|
|
504
514
|
/** Reassembly buffer for a DEC 2048 in-band resize report split across stdin reads. */
|
|
@@ -550,7 +560,20 @@ export class ProcessTerminal implements Terminal {
|
|
|
550
560
|
}
|
|
551
561
|
}
|
|
552
562
|
|
|
553
|
-
|
|
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
|
+
|
|
576
|
+
onPrivateModeReport(callback: (mode: number, supported: boolean, confirmed?: boolean) => void): void {
|
|
554
577
|
this.#privateModeCallbacks.push(callback);
|
|
555
578
|
}
|
|
556
579
|
|
|
@@ -891,8 +914,10 @@ export class ProcessTerminal implements Terminal {
|
|
|
891
914
|
break;
|
|
892
915
|
}
|
|
893
916
|
case "privateMode": {
|
|
894
|
-
// DA1 beat the DECRPM reply
|
|
895
|
-
|
|
917
|
+
// DA1 beat the DECRPM reply. The terminal cannot report this
|
|
918
|
+
// capability, but may still implement it; keep that distinction
|
|
919
|
+
// so static terminal detection is not incorrectly downgraded.
|
|
920
|
+
this.#resolvePrivateMode(owner.mode, false, false);
|
|
896
921
|
break;
|
|
897
922
|
}
|
|
898
923
|
case "keyboard": {
|
|
@@ -1158,7 +1183,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
1158
1183
|
}
|
|
1159
1184
|
|
|
1160
1185
|
#handlePrivateModeReport(mode: number, status: string): void {
|
|
1161
|
-
this.#resolvePrivateMode(mode, isPrivateModeSupported(status));
|
|
1186
|
+
this.#resolvePrivateMode(mode, isPrivateModeSupported(status), true);
|
|
1162
1187
|
if (isXtermScrollToBottomMode(mode) && isPrivateModeSet(status)) {
|
|
1163
1188
|
this.#disableXtermScrollToBottomMode(mode);
|
|
1164
1189
|
}
|
|
@@ -1166,15 +1191,16 @@ export class ProcessTerminal implements Terminal {
|
|
|
1166
1191
|
|
|
1167
1192
|
/**
|
|
1168
1193
|
* Record DECRQM support for a private mode (idempotent — first result wins)
|
|
1169
|
-
* and notify subscribers.
|
|
1170
|
-
*
|
|
1194
|
+
* and notify subscribers. `confirmed` distinguishes an explicit DECRPM
|
|
1195
|
+
* unsupported response from an absent response followed by the DA1 sentinel.
|
|
1196
|
+
* Enables DEC 2048 in-band resize only after positive confirmation.
|
|
1171
1197
|
*/
|
|
1172
|
-
#resolvePrivateMode(mode: number, supported: boolean): void {
|
|
1198
|
+
#resolvePrivateMode(mode: number, supported: boolean, confirmed: boolean): void {
|
|
1173
1199
|
if (this.#privateModeSupport.has(mode)) return;
|
|
1174
1200
|
this.#privateModeSupport.set(mode, supported);
|
|
1175
1201
|
for (const cb of this.#privateModeCallbacks) {
|
|
1176
1202
|
try {
|
|
1177
|
-
cb(mode, supported);
|
|
1203
|
+
cb(mode, supported, confirmed);
|
|
1178
1204
|
} catch {
|
|
1179
1205
|
// Ignore subscriber errors — capability reporting must not crash input.
|
|
1180
1206
|
}
|
package/src/tui.ts
CHANGED
|
@@ -80,11 +80,11 @@ 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
90
|
const ALT_SCREEN_ENTER = "\x1b[?1049h";
|
|
@@ -456,6 +456,11 @@ export interface OverlayOptions {
|
|
|
456
456
|
* unchanged and still draw over the transcript on the normal screen.
|
|
457
457
|
*/
|
|
458
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;
|
|
459
464
|
}
|
|
460
465
|
|
|
461
466
|
/**
|
|
@@ -473,7 +478,7 @@ export interface OverlayHandle {
|
|
|
473
478
|
/**
|
|
474
479
|
* Container - a component that contains other components
|
|
475
480
|
*/
|
|
476
|
-
export class Container implements Component {
|
|
481
|
+
export class Container implements Component, NativeScrollbackCommittedRows, NativeScrollbackReplay {
|
|
477
482
|
children: Component[] = [];
|
|
478
483
|
|
|
479
484
|
// Memoized concatenation of the children's latest renders. Children are
|
|
@@ -543,6 +548,34 @@ export class Container implements Component {
|
|
|
543
548
|
}
|
|
544
549
|
}
|
|
545
550
|
|
|
551
|
+
/**
|
|
552
|
+
* Split the committed prefix from the container's most recently rendered
|
|
553
|
+
* rows across its children. The memoized child arrays are the exact geometry
|
|
554
|
+
* that produced that frame; when the child list was invalidated or rebuilt,
|
|
555
|
+
* there is no safe old-to-new coordinate mapping, so propagation waits for
|
|
556
|
+
* the next render/post-emit publication.
|
|
557
|
+
*/
|
|
558
|
+
setNativeScrollbackCommittedRows(rows: number): void {
|
|
559
|
+
const refs = this.#memoChildLines;
|
|
560
|
+
if (this.#memoLines === undefined || refs.length !== this.children.length) return;
|
|
561
|
+
const committed = Number.isFinite(rows) ? Math.max(0, Math.trunc(rows)) : 0;
|
|
562
|
+
let offset = 0;
|
|
563
|
+
for (let i = 0; i < this.children.length; i++) {
|
|
564
|
+
const childRows = refs[i];
|
|
565
|
+
if (childRows === undefined) return;
|
|
566
|
+
setNativeScrollbackCommittedRows(
|
|
567
|
+
this.children[i]!,
|
|
568
|
+
Math.min(childRows.length, Math.max(0, committed - offset)),
|
|
569
|
+
);
|
|
570
|
+
offset += childRows.length;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** Recursively discard layout locks that are meaningful only to the old tape. */
|
|
575
|
+
prepareNativeScrollbackReplay(): void {
|
|
576
|
+
for (const child of this.children) prepareNativeScrollbackReplay(child);
|
|
577
|
+
}
|
|
578
|
+
|
|
546
579
|
render(width: number): readonly string[] {
|
|
547
580
|
width = Math.max(1, width);
|
|
548
581
|
const children = this.children;
|
|
@@ -1065,9 +1098,13 @@ export class TUI extends Container {
|
|
|
1065
1098
|
// untouched, so exiting reconciles cleanly against the terminal-restored
|
|
1066
1099
|
// normal screen. #altPreviousLines is the last alt frame, for repaint-skip.
|
|
1067
1100
|
#altActive = false;
|
|
1101
|
+
#altMouseTrackingActive = false;
|
|
1068
1102
|
#altPreviousLines: string[] = [];
|
|
1069
1103
|
#altEnterWidth = 0;
|
|
1070
1104
|
#altEnterHeight = 0;
|
|
1105
|
+
// Holds an alternate-screen exit until its replacement full paint can emit it
|
|
1106
|
+
// atomically. It must survive a deferred Ghostty image frame.
|
|
1107
|
+
#pendingAltExit = "";
|
|
1071
1108
|
|
|
1072
1109
|
// Persistent composed frame. The render override splices only rows at/after
|
|
1073
1110
|
// the stable prefix each frame; cursor markers are stripped at ingestion so
|
|
@@ -1491,13 +1528,15 @@ export class TUI extends Container {
|
|
|
1491
1528
|
this.#watchdog.start();
|
|
1492
1529
|
this.#ghosttyInitialImageDelayDone = false;
|
|
1493
1530
|
this.#ghosttyImageReadyAtMs = this.#renderScheduler.now() + TUI.#GHOSTTY_INITIAL_IMAGE_DELAY_MS;
|
|
1494
|
-
// A
|
|
1495
|
-
// output when the terminal reports support
|
|
1496
|
-
//
|
|
1497
|
-
//
|
|
1498
|
-
//
|
|
1499
|
-
|
|
1500
|
-
|
|
1531
|
+
// A confirmed DECRPM report for mode 2026 is authoritative: enable
|
|
1532
|
+
// synchronized output when the terminal reports support and disable it for
|
|
1533
|
+
// an explicit unsupported status. A DA1 sentinel without a DECRPM reply is
|
|
1534
|
+
// inconclusive: many terminals implement synchronized output without
|
|
1535
|
+
// implementing DECRQM, so retain the statically detected default instead of
|
|
1536
|
+
// exposing destructive full paints. An explicit user opt-out/force still
|
|
1537
|
+
// wins, so skip every probe result in that case.
|
|
1538
|
+
this.terminal.onPrivateModeReport?.((mode, supported, confirmed = true) => {
|
|
1539
|
+
if (mode !== 2026 || !confirmed) return;
|
|
1501
1540
|
if (synchronizedOutputUserOverride() !== null) return;
|
|
1502
1541
|
this.#setSynchronizedOutput(supported);
|
|
1503
1542
|
});
|
|
@@ -1715,17 +1754,20 @@ export class TUI extends Container {
|
|
|
1715
1754
|
}
|
|
1716
1755
|
|
|
1717
1756
|
stop(): void {
|
|
1718
|
-
// Leave the alt buffer first so the teardown cursor math below runs
|
|
1719
|
-
// the restored normal screen (which #previousLines still describes).
|
|
1757
|
+
// Leave the resize alt buffer first so the teardown cursor math below runs
|
|
1758
|
+
// against the restored normal screen (which #previousLines still describes).
|
|
1720
1759
|
if (this.#resizeAltActive) {
|
|
1721
1760
|
this.terminal.write(this.#leaveResizeAltSequence());
|
|
1722
1761
|
}
|
|
1723
|
-
if (this.#altActive) {
|
|
1724
|
-
const
|
|
1725
|
-
this
|
|
1762
|
+
if (this.#altActive || this.#pendingAltExit) {
|
|
1763
|
+
const mouseExit = this.#altMouseTrackingActive ? MOUSE_TRACKING_OFF : "";
|
|
1764
|
+
const exitSequence = this.#pendingAltExit || `${mouseExit}${this.#keyboardEnhancementExit()}\x1b[?1049l`;
|
|
1765
|
+
this.terminal.write(exitSequence);
|
|
1726
1766
|
setAltScreenActive(false);
|
|
1727
1767
|
this.#altActive = false;
|
|
1768
|
+
this.#altMouseTrackingActive = false;
|
|
1728
1769
|
this.#altPreviousLines = [];
|
|
1770
|
+
this.#pendingAltExit = "";
|
|
1729
1771
|
}
|
|
1730
1772
|
if (TERMINAL.imageProtocol === ImageProtocol.Kitty) {
|
|
1731
1773
|
for (const id of this.#imageBudget.takeAllTransmittedIds()) {
|
|
@@ -2681,27 +2723,42 @@ export class TUI extends Container {
|
|
|
2681
2723
|
// Fullscreen alt-screen short-circuit. While the topmost visible overlay
|
|
2682
2724
|
// requests it, borrow the terminal's alternate buffer and paint only the
|
|
2683
2725
|
// modal there; the normal screen and all accounting stay untouched.
|
|
2684
|
-
|
|
2726
|
+
let deferredAltExit = this.#pendingAltExit;
|
|
2727
|
+
const topOverlay = this.#getTopmostVisibleOverlay();
|
|
2728
|
+
const wantAlt = topOverlay?.options?.fullscreen === true;
|
|
2729
|
+
const wantMouseTracking = wantAlt && topOverlay.options?.mouseTracking !== false;
|
|
2685
2730
|
if (wantAlt && !this.#altActive) {
|
|
2686
2731
|
// Enhanced keyboard modes can be buffer-local: re-push the active
|
|
2687
2732
|
// modified-key reporting sequence on the freshly entered alternate
|
|
2688
2733
|
// screen, or Esc/modified keys revert to legacy encoding inside
|
|
2689
2734
|
// fullscreen overlays (Ghostty/kitty/iTerm2).
|
|
2690
|
-
|
|
2735
|
+
const mouseEnter = wantMouseTracking ? MOUSE_TRACKING_ON : "";
|
|
2736
|
+
this.terminal.write(`\x1b[?1049h${this.#keyboardEnhancementEnter()}${mouseEnter}`);
|
|
2691
2737
|
setAltScreenActive(true);
|
|
2692
2738
|
this.terminal.hideCursor();
|
|
2693
2739
|
this.#forgetHardwareCursorState();
|
|
2694
2740
|
this.#recordHardwareCursorHidden();
|
|
2695
2741
|
this.#altActive = true;
|
|
2742
|
+
this.#altMouseTrackingActive = wantMouseTracking;
|
|
2696
2743
|
this.#altPreviousLines = [];
|
|
2697
2744
|
this.#altEnterWidth = width;
|
|
2698
2745
|
this.#altEnterHeight = height;
|
|
2699
2746
|
} else if (!wantAlt && this.#altActive) {
|
|
2747
|
+
const mouseExit = this.#altMouseTrackingActive ? MOUSE_TRACKING_OFF : "";
|
|
2700
2748
|
const enhancementExit = this.#keyboardEnhancementExit();
|
|
2701
|
-
|
|
2749
|
+
const exitSequence = `${mouseExit}${enhancementExit}\x1b[?1049l`;
|
|
2750
|
+
// Session replacement can finish while a fullscreen selector is still
|
|
2751
|
+
// covering the old normal buffer. Keep the overlay visible until the
|
|
2752
|
+
// replacement is ready, then fuse the buffer restore into that full paint;
|
|
2753
|
+
// a standalone exit exposes the stale session for one terminal frame.
|
|
2754
|
+
if (this.#clearScrollbackOnNextRender) {
|
|
2755
|
+
this.#pendingAltExit = exitSequence;
|
|
2756
|
+
deferredAltExit = exitSequence;
|
|
2757
|
+
} else this.terminal.write(exitSequence);
|
|
2702
2758
|
setAltScreenActive(false);
|
|
2703
2759
|
this.#forgetHardwareCursorState();
|
|
2704
2760
|
this.#altActive = false;
|
|
2761
|
+
this.#altMouseTrackingActive = false;
|
|
2705
2762
|
this.#altPreviousLines = [];
|
|
2706
2763
|
// A resize while on the alt buffer reflowed the terminal's saved
|
|
2707
2764
|
// normal screen; it no longer matches our accounting, so force the
|
|
@@ -2709,6 +2766,9 @@ export class TUI extends Container {
|
|
|
2709
2766
|
if (width !== this.#altEnterWidth || height !== this.#altEnterHeight) {
|
|
2710
2767
|
this.#resizeEventPending = true;
|
|
2711
2768
|
}
|
|
2769
|
+
} else if (wantMouseTracking !== this.#altMouseTrackingActive) {
|
|
2770
|
+
this.terminal.write(wantMouseTracking ? MOUSE_TRACKING_ON : MOUSE_TRACKING_OFF);
|
|
2771
|
+
this.#altMouseTrackingActive = wantMouseTracking;
|
|
2712
2772
|
}
|
|
2713
2773
|
if (this.#altActive) {
|
|
2714
2774
|
this.#componentRenderTargets.clear();
|
|
@@ -3027,7 +3087,9 @@ export class TUI extends Container {
|
|
|
3027
3087
|
chunkTo,
|
|
3028
3088
|
windowTop,
|
|
3029
3089
|
cursorTrackingLineCount,
|
|
3090
|
+
leadingSequence: deferredAltExit,
|
|
3030
3091
|
});
|
|
3092
|
+
this.#pendingAltExit = "";
|
|
3031
3093
|
this.#committedPrefix = rawFrame.slice(0, chunkTo);
|
|
3032
3094
|
this.#committedPrefixAuditRows = Math.min(chunkTo, finalBoundary);
|
|
3033
3095
|
this.#clearScrollbackOnNextRender = false;
|
|
@@ -3406,6 +3468,7 @@ export class TUI extends Container {
|
|
|
3406
3468
|
chunkTo: number;
|
|
3407
3469
|
windowTop: number;
|
|
3408
3470
|
cursorTrackingLineCount: number;
|
|
3471
|
+
leadingSequence: string;
|
|
3409
3472
|
},
|
|
3410
3473
|
): void {
|
|
3411
3474
|
this.#fullRedrawCount += 1;
|
|
@@ -3443,7 +3506,7 @@ export class TUI extends Container {
|
|
|
3443
3506
|
paintCursorPos = paint.cursorPos;
|
|
3444
3507
|
}
|
|
3445
3508
|
}
|
|
3446
|
-
let buffer = this.#paintBeginSequence + this.#leaveResizeAltSequence() + purgeSequence;
|
|
3509
|
+
let buffer = this.#paintBeginSequence + this.#leaveResizeAltSequence() + options.leadingSequence + purgeSequence;
|
|
3447
3510
|
if (options.clearScrollback) {
|
|
3448
3511
|
// Clear native history without blanking the live viewport first. The
|
|
3449
3512
|
// replay below rewrites every visible row from home, including blanks,
|
|
@@ -3685,16 +3748,6 @@ export class TUI extends Container {
|
|
|
3685
3748
|
this.terminal.write(buffer);
|
|
3686
3749
|
}
|
|
3687
3750
|
|
|
3688
|
-
/** Topmost visible overlay requests the alternate-screen buffer. */
|
|
3689
|
-
#wantsAltScreen(): boolean {
|
|
3690
|
-
for (let i = this.overlayStack.length - 1; i >= 0; i--) {
|
|
3691
|
-
const entry = this.overlayStack[i]!;
|
|
3692
|
-
if (!this.#isOverlayVisible(entry)) continue;
|
|
3693
|
-
return entry.options?.fullscreen === true;
|
|
3694
|
-
}
|
|
3695
|
-
return false;
|
|
3696
|
-
}
|
|
3697
|
-
|
|
3698
3751
|
/**
|
|
3699
3752
|
* Compose and paint a single fullscreen overlay frame on the alt buffer.
|
|
3700
3753
|
* Cursor markers are stripped (the modal draws its own in-band caret and
|