@sayknow-cli/tui 0.3.5 → 0.3.7
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 +8 -0
- package/dist/types/components/editor.d.ts +5 -0
- package/dist/types/components/settings-list.d.ts +1 -1
- package/dist/types/terminal.d.ts +8 -0
- package/dist/types/tui.d.ts +15 -0
- package/package.json +3 -3
- package/src/components/editor.ts +58 -13
- package/src/components/input.ts +2 -1
- package/src/components/select-list.ts +8 -1
- package/src/components/settings-list.ts +12 -0
- package/src/terminal.ts +86 -10
- package/src/tui.ts +189 -6
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.7.8] - 2026-06-30
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- The render loop now isolates a component whose `render()` throws: the failure is logged once and replaced with a `[render error: <Name>]` fallback line instead of escaping the frame and tripping the process-level fail-fast `uncaughtException` exit. Previously any unguarded renderer fault (e.g. a tool renderer fed an undefined field) crashed the whole app on whatever triggered the next frame — a keystroke, resize, or command such as `/background` (#1291).
|
|
10
|
+
|
|
11
|
+
- Resolved terminal dimensions from the live TTY window size before stream defaults so wide Windows Terminal/PowerShell sessions render against the actual viewport width (#1239).
|
|
12
|
+
|
|
5
13
|
## [0.7.4] - 2026-06-27
|
|
6
14
|
|
|
7
15
|
### Fixed
|
|
@@ -39,6 +39,11 @@ export declare class Editor implements Component, Focusable {
|
|
|
39
39
|
onChange?: (text: string) => void;
|
|
40
40
|
onAutocompleteCancel?: () => void;
|
|
41
41
|
onTabDeclined?: (text: string) => void;
|
|
42
|
+
/**
|
|
43
|
+
* Called before Tab opens/applies autocomplete. Return true to consume Tab
|
|
44
|
+
* for app-level behavior (for example, queueing a draft while a turn runs).
|
|
45
|
+
*/
|
|
46
|
+
onTab?: (text: string) => boolean | undefined;
|
|
42
47
|
disableSubmit: boolean;
|
|
43
48
|
constructor(theme: EditorTheme);
|
|
44
49
|
setAutocompleteProvider(provider: AutocompleteProvider): void;
|
|
@@ -22,7 +22,7 @@ export interface SettingsListTheme {
|
|
|
22
22
|
}
|
|
23
23
|
export declare class SettingsList implements Component {
|
|
24
24
|
#private;
|
|
25
|
-
constructor(items: SettingItem[], maxVisible: number, theme: SettingsListTheme, onChange: (id: string, newValue: string) => void, onCancel: () => void);
|
|
25
|
+
constructor(items: SettingItem[], maxVisible: number, theme: SettingsListTheme, onChange: (id: string, newValue: string) => void, onCancel: () => void, onSelectionChange?: (item: SettingItem | undefined) => void);
|
|
26
26
|
/** Update an item's currentValue */
|
|
27
27
|
updateValue(id: string, newValue: string): void;
|
|
28
28
|
/**
|
package/dist/types/terminal.d.ts
CHANGED
|
@@ -52,6 +52,13 @@ export interface Terminal {
|
|
|
52
52
|
/** The last detected terminal appearance, or undefined if not yet known. */
|
|
53
53
|
get appearance(): TerminalAppearance | undefined;
|
|
54
54
|
}
|
|
55
|
+
interface TerminalSizeStream {
|
|
56
|
+
columns?: number;
|
|
57
|
+
rows?: number;
|
|
58
|
+
getWindowSize?: () => [number, number] | number[];
|
|
59
|
+
}
|
|
60
|
+
export declare function resolveTerminalColumns(stream?: TerminalSizeStream, envColumns?: string | undefined): number;
|
|
61
|
+
export declare function resolveTerminalRows(stream?: TerminalSizeStream, envRows?: string | undefined): number;
|
|
55
62
|
/**
|
|
56
63
|
* Real terminal using process.stdin/stdout
|
|
57
64
|
*/
|
|
@@ -78,3 +85,4 @@ export declare class ProcessTerminal implements Terminal {
|
|
|
78
85
|
setTitle(title: string): void;
|
|
79
86
|
setProgress(active: boolean): void;
|
|
80
87
|
}
|
|
88
|
+
export {};
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -157,6 +157,8 @@ export declare class TUI extends Container {
|
|
|
157
157
|
setClearOnShrink(enabled: boolean): void;
|
|
158
158
|
setFocus(component: Component | null): void;
|
|
159
159
|
setBottomPinnedComponent(component: Component | null): void;
|
|
160
|
+
scrollViewportPages(direction: -1 | 1): boolean;
|
|
161
|
+
followLiveViewport(): boolean;
|
|
160
162
|
/**
|
|
161
163
|
* Show an overlay component with configurable positioning and sizing.
|
|
162
164
|
* Returns a handle to control the overlay's visibility.
|
|
@@ -172,6 +174,19 @@ export declare class TUI extends Container {
|
|
|
172
174
|
addInputListener(listener: InputListener): () => void;
|
|
173
175
|
removeInputListener(listener: InputListener): void;
|
|
174
176
|
stop(): void;
|
|
177
|
+
/**
|
|
178
|
+
* Multiplexer-aware resize render request.
|
|
179
|
+
*
|
|
180
|
+
* A forced full redraw (`requestRender(true)`) resets `#previousWidth`/`#previousHeight`
|
|
181
|
+
* to -1, which makes `#doRender` treat the frame as a width change and fall into the
|
|
182
|
+
* `fullRender` path. In terminal multiplexers that path skips the scrollback-clearing
|
|
183
|
+
* `3J` escape (users navigate scrollback history), so replaying every transcript line
|
|
184
|
+
* piles it back on top of scrollback — the "top of screen scrolls down to the prompt at
|
|
185
|
+
* high speed" resize storm. Here we keep force off in multiplexers so `#doRender`'s
|
|
186
|
+
* height-change branch takes the viewport-only `multiplexerViewportRepaint` path instead.
|
|
187
|
+
* Set `PI_TUI_LEGACY_MULTIPLEXER_FULL_RENDER=1` to restore the legacy forced redraw.
|
|
188
|
+
*/
|
|
189
|
+
requestResizeRender(): void;
|
|
175
190
|
requestRender(force?: boolean, source?: string): void;
|
|
176
191
|
getLineRenderCacheStats(): {
|
|
177
192
|
normalizationSize: number;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@sayknow-cli/tui",
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.7",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://github.com/jaybeyond/Sayknow_CLI",
|
|
7
7
|
"author": "jaybeyond",
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
"fmt": "biome format --write ."
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@sayknow-cli/natives": "0.3.
|
|
42
|
-
"@sayknow-cli/utils": "0.3.
|
|
41
|
+
"@sayknow-cli/natives": "0.3.7",
|
|
42
|
+
"@sayknow-cli/utils": "0.3.7",
|
|
43
43
|
"lru-cache": "11.3.6",
|
|
44
44
|
"marked": "^18.0.3"
|
|
45
45
|
},
|
package/src/components/editor.ts
CHANGED
|
@@ -450,6 +450,11 @@ export class Editor implements Component, Focusable {
|
|
|
450
450
|
onChange?: (text: string) => void;
|
|
451
451
|
onAutocompleteCancel?: () => void;
|
|
452
452
|
onTabDeclined?: (text: string) => void;
|
|
453
|
+
/**
|
|
454
|
+
* Called before Tab opens/applies autocomplete. Return true to consume Tab
|
|
455
|
+
* for app-level behavior (for example, queueing a draft while a turn runs).
|
|
456
|
+
*/
|
|
457
|
+
onTab?: (text: string) => boolean | undefined;
|
|
453
458
|
disableSubmit: boolean = false;
|
|
454
459
|
|
|
455
460
|
// Custom top border (for status line integration)
|
|
@@ -1080,6 +1085,10 @@ export class Editor implements Component, Focusable {
|
|
|
1080
1085
|
return;
|
|
1081
1086
|
}
|
|
1082
1087
|
|
|
1088
|
+
if (kb.matches(data, "tui.input.tab") && this.onTab?.(this.getText())) {
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1083
1092
|
// Handle autocomplete special keys first (but don't block other input)
|
|
1084
1093
|
if (this.#autocompleteState && this.#autocompleteList) {
|
|
1085
1094
|
// Escape - cancel autocomplete
|
|
@@ -1147,7 +1156,7 @@ export class Editor implements Component, Focusable {
|
|
|
1147
1156
|
// Check for stale autocomplete state due to debounce
|
|
1148
1157
|
const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
|
|
1149
1158
|
const currentTextBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
1150
|
-
if (currentTextBeforeCursor
|
|
1159
|
+
if (!currentTextBeforeCursor.endsWith(this.#autocompletePrefix)) {
|
|
1151
1160
|
// Autocomplete is stale - cancel and fall through to normal submission
|
|
1152
1161
|
this.#cancelAutocomplete();
|
|
1153
1162
|
} else {
|
|
@@ -1285,8 +1294,8 @@ export class Editor implements Component, Focusable {
|
|
|
1285
1294
|
const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
|
|
1286
1295
|
const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
1287
1296
|
if (
|
|
1288
|
-
|
|
1289
|
-
|
|
1297
|
+
(this.#isInSubmittedSlashCommandContext() ||
|
|
1298
|
+
this.#getSlashTokenBeforeCursor()?.startsWith("/skill") === true) &&
|
|
1290
1299
|
this.#autocompleteProvider?.trySyncSlashCompletion
|
|
1291
1300
|
) {
|
|
1292
1301
|
const syncResult = this.#autocompleteProvider.trySyncSlashCompletion(textBeforeCursor);
|
|
@@ -1509,10 +1518,34 @@ export class Editor implements Component, Focusable {
|
|
|
1509
1518
|
}
|
|
1510
1519
|
|
|
1511
1520
|
if (hasCursorInChunk) {
|
|
1521
|
+
let displayChunkText = chunk.text;
|
|
1522
|
+
let displayCursorPos = adjustedCursorPos;
|
|
1523
|
+
if (displayCursorPos > displayChunkText.length) {
|
|
1524
|
+
let hiddenWhitespaceWidth = displayCursorPos - displayChunkText.length;
|
|
1525
|
+
const displayChunkWidth = visibleWidth(displayChunkText);
|
|
1526
|
+
if (displayChunkWidth + hiddenWhitespaceWidth <= contentWidth) {
|
|
1527
|
+
displayChunkText += padding(hiddenWhitespaceWidth);
|
|
1528
|
+
} else {
|
|
1529
|
+
layoutLines.push({
|
|
1530
|
+
text: displayChunkText,
|
|
1531
|
+
hasCursor: false,
|
|
1532
|
+
});
|
|
1533
|
+
hiddenWhitespaceWidth -= Math.max(0, contentWidth - displayChunkWidth);
|
|
1534
|
+
while (hiddenWhitespaceWidth > contentWidth) {
|
|
1535
|
+
layoutLines.push({
|
|
1536
|
+
text: padding(contentWidth),
|
|
1537
|
+
hasCursor: false,
|
|
1538
|
+
});
|
|
1539
|
+
hiddenWhitespaceWidth -= contentWidth;
|
|
1540
|
+
}
|
|
1541
|
+
displayChunkText = padding(hiddenWhitespaceWidth);
|
|
1542
|
+
displayCursorPos = hiddenWhitespaceWidth;
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1512
1545
|
layoutLines.push({
|
|
1513
|
-
text:
|
|
1546
|
+
text: displayChunkText,
|
|
1514
1547
|
hasCursor: true,
|
|
1515
|
-
cursorPos:
|
|
1548
|
+
cursorPos: displayCursorPos,
|
|
1516
1549
|
});
|
|
1517
1550
|
} else {
|
|
1518
1551
|
layoutLines.push({
|
|
@@ -1699,7 +1732,8 @@ export class Editor implements Component, Focusable {
|
|
|
1699
1732
|
|
|
1700
1733
|
// Check if we should trigger or update autocomplete
|
|
1701
1734
|
if (!this.#autocompleteState) {
|
|
1702
|
-
// Auto-trigger for "/" at the start of a
|
|
1735
|
+
// Auto-trigger for "/" at the start of a submitted command.
|
|
1736
|
+
// Inline skill autocomplete starts after the token becomes "/skill...".
|
|
1703
1737
|
if (char === "/" && this.#isAtStartOfSubmittedMessage()) {
|
|
1704
1738
|
this.#tryTriggerAutocomplete();
|
|
1705
1739
|
}
|
|
@@ -1721,8 +1755,8 @@ export class Editor implements Component, Focusable {
|
|
|
1721
1755
|
else if (/[a-zA-Z0-9.\-_/]/.test(char)) {
|
|
1722
1756
|
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
1723
1757
|
const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
1724
|
-
// Check if we're in a slash command
|
|
1725
|
-
if (this.#isInSubmittedSlashCommandContext()) {
|
|
1758
|
+
// Check if we're in a slash command or inline slash token
|
|
1759
|
+
if (this.#isInSubmittedSlashCommandContext() || this.#isInSlashTokenContext()) {
|
|
1726
1760
|
this.#tryTriggerAutocomplete();
|
|
1727
1761
|
}
|
|
1728
1762
|
// Check if we're in an @ file reference context
|
|
@@ -1917,8 +1951,8 @@ export class Editor implements Component, Focusable {
|
|
|
1917
1951
|
// If autocomplete was cancelled (no matches), re-trigger if we're in a completable context
|
|
1918
1952
|
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
1919
1953
|
const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
1920
|
-
// Slash command context
|
|
1921
|
-
if (this.#isInSubmittedSlashCommandContext()) {
|
|
1954
|
+
// Slash command or inline slash token context
|
|
1955
|
+
if (this.#isInSubmittedSlashCommandContext() || this.#isInSlashTokenContext()) {
|
|
1922
1956
|
this.#tryTriggerAutocomplete();
|
|
1923
1957
|
}
|
|
1924
1958
|
// @ file reference context
|
|
@@ -2074,7 +2108,7 @@ export class Editor implements Component, Focusable {
|
|
|
2074
2108
|
} else {
|
|
2075
2109
|
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
2076
2110
|
const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
2077
|
-
if (this.#isInSubmittedSlashCommandContext()) {
|
|
2111
|
+
if (this.#isInSubmittedSlashCommandContext() || this.#isInSlashTokenContext()) {
|
|
2078
2112
|
this.#tryTriggerAutocomplete();
|
|
2079
2113
|
} else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
|
|
2080
2114
|
this.#tryTriggerAutocomplete();
|
|
@@ -2388,8 +2422,8 @@ export class Editor implements Component, Focusable {
|
|
|
2388
2422
|
} else {
|
|
2389
2423
|
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
2390
2424
|
const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
2391
|
-
// Slash command context
|
|
2392
|
-
if (this.#isInSubmittedSlashCommandContext()) {
|
|
2425
|
+
// Slash command or inline slash token context
|
|
2426
|
+
if (this.#isInSubmittedSlashCommandContext() || this.#isInSlashTokenContext()) {
|
|
2393
2427
|
this.#tryTriggerAutocomplete();
|
|
2394
2428
|
}
|
|
2395
2429
|
// @ file reference context
|
|
@@ -2618,6 +2652,17 @@ export class Editor implements Component, Focusable {
|
|
|
2618
2652
|
return this.#hasOnlyWhitespaceBeforeCursorLine() && beforeCursor.trimStart().startsWith("/");
|
|
2619
2653
|
}
|
|
2620
2654
|
|
|
2655
|
+
#getSlashTokenBeforeCursor(): string | null {
|
|
2656
|
+
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
2657
|
+
const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
2658
|
+
const match = beforeCursor.match(/(?:^|\s)(\/[^\s]*)$/);
|
|
2659
|
+
return match?.[1] ?? null;
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
#isInSlashTokenContext(): boolean {
|
|
2663
|
+
return this.#getSlashTokenBeforeCursor()?.startsWith("/skill") === true;
|
|
2664
|
+
}
|
|
2665
|
+
|
|
2621
2666
|
#isSlashCommandNameAutocompleteSelection(): boolean {
|
|
2622
2667
|
if (this.#autocompleteState !== "regular") {
|
|
2623
2668
|
return false;
|
package/src/components/input.ts
CHANGED
|
@@ -58,7 +58,8 @@ export class Input implements Component, Focusable {
|
|
|
58
58
|
setValue(value: string): void {
|
|
59
59
|
const normalized = value.normalize("NFC");
|
|
60
60
|
this.#value = normalized;
|
|
61
|
-
this.#cursor =
|
|
61
|
+
this.#cursor = normalized.length;
|
|
62
|
+
this.#lastAction = null;
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
handleInput(data: string): void {
|
|
@@ -117,8 +117,15 @@ export class SelectList implements Component {
|
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
handleInput(keyData: string): void {
|
|
120
|
-
if (this.#filteredItems.length === 0) return;
|
|
121
120
|
const kb = getKeybindings();
|
|
121
|
+
if (this.#filteredItems.length === 0) {
|
|
122
|
+
if (kb.matches(keyData, "tui.select.cancel")) {
|
|
123
|
+
if (this.onCancel) {
|
|
124
|
+
this.onCancel();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
122
129
|
// Up arrow - wrap to bottom when at top
|
|
123
130
|
if (kb.matches(keyData, "tui.select.up")) {
|
|
124
131
|
this.#selectedIndex = this.#selectedIndex === 0 ? this.#filteredItems.length - 1 : this.#selectedIndex - 1;
|
|
@@ -32,6 +32,7 @@ export class SettingsList implements Component {
|
|
|
32
32
|
#maxVisible: number;
|
|
33
33
|
#onChange: (id: string, newValue: string) => void;
|
|
34
34
|
#onCancel: () => void;
|
|
35
|
+
#onSelectionChange?: (item: SettingItem | undefined) => void;
|
|
35
36
|
|
|
36
37
|
// Submenu state
|
|
37
38
|
#submenuComponent: Component | null = null;
|
|
@@ -43,12 +44,15 @@ export class SettingsList implements Component {
|
|
|
43
44
|
theme: SettingsListTheme,
|
|
44
45
|
onChange: (id: string, newValue: string) => void,
|
|
45
46
|
onCancel: () => void,
|
|
47
|
+
onSelectionChange?: (item: SettingItem | undefined) => void,
|
|
46
48
|
) {
|
|
47
49
|
this.#items = items;
|
|
48
50
|
this.#maxVisible = maxVisible;
|
|
49
51
|
this.#theme = theme;
|
|
50
52
|
this.#onChange = onChange;
|
|
51
53
|
this.#onCancel = onCancel;
|
|
54
|
+
this.#onSelectionChange = onSelectionChange;
|
|
55
|
+
this.#notifySelectionChange();
|
|
52
56
|
}
|
|
53
57
|
|
|
54
58
|
/** Update an item's currentValue */
|
|
@@ -73,12 +77,17 @@ export class SettingsList implements Component {
|
|
|
73
77
|
} else if (this.#selectedIndex >= this.#items.length) {
|
|
74
78
|
this.#selectedIndex = this.#items.length - 1;
|
|
75
79
|
}
|
|
80
|
+
this.#notifySelectionChange();
|
|
76
81
|
}
|
|
77
82
|
|
|
78
83
|
invalidate(): void {
|
|
79
84
|
this.#submenuComponent?.invalidate?.();
|
|
80
85
|
}
|
|
81
86
|
|
|
87
|
+
#notifySelectionChange(): void {
|
|
88
|
+
this.#onSelectionChange?.(this.#items[this.#selectedIndex]);
|
|
89
|
+
}
|
|
90
|
+
|
|
82
91
|
render(width: number): string[] {
|
|
83
92
|
// If submenu is active, render it instead
|
|
84
93
|
if (this.#submenuComponent) {
|
|
@@ -167,8 +176,10 @@ export class SettingsList implements Component {
|
|
|
167
176
|
const kb = getKeybindings();
|
|
168
177
|
if (kb.matches(data, "tui.select.up")) {
|
|
169
178
|
this.#selectedIndex = this.#selectedIndex === 0 ? this.#items.length - 1 : this.#selectedIndex - 1;
|
|
179
|
+
this.#notifySelectionChange();
|
|
170
180
|
} else if (kb.matches(data, "tui.select.down")) {
|
|
171
181
|
this.#selectedIndex = this.#selectedIndex === this.#items.length - 1 ? 0 : this.#selectedIndex + 1;
|
|
182
|
+
this.#notifySelectionChange();
|
|
172
183
|
} else if (kb.matches(data, "tui.select.confirm") || data === " " || data === "\n") {
|
|
173
184
|
this.#activateItem();
|
|
174
185
|
} else if (kb.matches(data, "tui.select.cancel")) {
|
|
@@ -206,6 +217,7 @@ export class SettingsList implements Component {
|
|
|
206
217
|
if (this.#submenuItemIndex !== null) {
|
|
207
218
|
this.#selectedIndex = this.#submenuItemIndex;
|
|
208
219
|
this.#submenuItemIndex = null;
|
|
220
|
+
this.#notifySelectionChange();
|
|
209
221
|
}
|
|
210
222
|
}
|
|
211
223
|
}
|
package/src/terminal.ts
CHANGED
|
@@ -164,9 +164,50 @@ export interface Terminal {
|
|
|
164
164
|
get appearance(): TerminalAppearance | undefined;
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
interface TerminalSizeStream {
|
|
168
|
+
columns?: number;
|
|
169
|
+
rows?: number;
|
|
170
|
+
getWindowSize?: () => [number, number] | number[];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function positiveDimension(value: unknown): number | undefined {
|
|
174
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
|
|
175
|
+
const dimension = Math.trunc(value);
|
|
176
|
+
return dimension > 0 ? dimension : undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function resolveTerminalColumns(
|
|
180
|
+
stream: TerminalSizeStream = process.stdout,
|
|
181
|
+
envColumns: string | undefined = Bun.env.COLUMNS,
|
|
182
|
+
): number {
|
|
183
|
+
try {
|
|
184
|
+
const windowSize = stream.getWindowSize?.();
|
|
185
|
+
const liveColumns = positiveDimension(windowSize?.[0]);
|
|
186
|
+
if (liveColumns !== undefined) return liveColumns;
|
|
187
|
+
} catch {
|
|
188
|
+
// Fall back below when the stream cannot report a live TTY size.
|
|
189
|
+
}
|
|
190
|
+
return positiveDimension(stream.columns) ?? positiveDimension(Number(envColumns)) ?? 80;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function resolveTerminalRows(
|
|
194
|
+
stream: TerminalSizeStream = process.stdout,
|
|
195
|
+
envRows: string | undefined = Bun.env.LINES,
|
|
196
|
+
): number {
|
|
197
|
+
try {
|
|
198
|
+
const windowSize = stream.getWindowSize?.();
|
|
199
|
+
const liveRows = positiveDimension(windowSize?.[1]);
|
|
200
|
+
if (liveRows !== undefined) return liveRows;
|
|
201
|
+
} catch {
|
|
202
|
+
// Fall back below when the stream cannot report a live TTY size.
|
|
203
|
+
}
|
|
204
|
+
return positiveDimension(stream.rows) ?? positiveDimension(Number(envRows)) ?? 24;
|
|
205
|
+
}
|
|
206
|
+
|
|
167
207
|
function isWindowsSubsystemForLinux(): boolean {
|
|
168
208
|
return process.platform === "linux" && (!!$env.WSL_DISTRO_NAME || !!$env.WSL_INTEROP);
|
|
169
209
|
}
|
|
210
|
+
const STDOUT_ERROR_HANDLER_GRACE_MS = 250;
|
|
170
211
|
|
|
171
212
|
/**
|
|
172
213
|
* Real terminal using process.stdin/stdout
|
|
@@ -185,6 +226,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
185
226
|
#detachLogPath = $env.PI_TUI_TERMINAL_DETACH_LOG || "";
|
|
186
227
|
#windowsVTInputRestore?: () => void;
|
|
187
228
|
#stdoutErrorHandler?: (err: Error) => void;
|
|
229
|
+
#stdoutErrorHandlerCleanupTimer?: Timer;
|
|
188
230
|
#appearanceCallbacks: Array<(appearance: TerminalAppearance) => void> = [];
|
|
189
231
|
#appearance: TerminalAppearance | undefined;
|
|
190
232
|
#osc11Pending = false;
|
|
@@ -237,10 +279,16 @@ export class ProcessTerminal implements Terminal {
|
|
|
237
279
|
|
|
238
280
|
// Set up resize handler immediately
|
|
239
281
|
process.stdout.on("resize", this.#resizeHandler);
|
|
240
|
-
this.#
|
|
241
|
-
this.#
|
|
242
|
-
|
|
243
|
-
|
|
282
|
+
if (this.#stdoutErrorHandlerCleanupTimer) {
|
|
283
|
+
clearTimeout(this.#stdoutErrorHandlerCleanupTimer);
|
|
284
|
+
this.#stdoutErrorHandlerCleanupTimer = undefined;
|
|
285
|
+
}
|
|
286
|
+
if (!this.#stdoutErrorHandler) {
|
|
287
|
+
this.#stdoutErrorHandler = (err: Error) => {
|
|
288
|
+
this.#markUnavailable(err, "stdout-error");
|
|
289
|
+
};
|
|
290
|
+
process.stdout.on("error", this.#stdoutErrorHandler);
|
|
291
|
+
}
|
|
244
292
|
|
|
245
293
|
// Refresh terminal dimensions - they may be stale after suspend/resume
|
|
246
294
|
// (SIGWINCH is lost while process is stopped). Unix only.
|
|
@@ -582,6 +630,20 @@ export class ProcessTerminal implements Terminal {
|
|
|
582
630
|
return;
|
|
583
631
|
}
|
|
584
632
|
this.#safeWrite("\x1b[?u");
|
|
633
|
+
// Windows Terminal and conhost do not implement the Kitty keyboard
|
|
634
|
+
// protocol, so the query above never activates it there. They do honor the
|
|
635
|
+
// modifyOtherKeys fallback below — but that mode breaks Windows CJK/Hangul
|
|
636
|
+
// IME composition: Alt+Enter (and other chords) bypass the IME commit, so
|
|
637
|
+
// the syllable still being composed is never delivered to the app and the
|
|
638
|
+
// action fires on empty text (e.g. queue-message no-ops unless the user
|
|
639
|
+
// types a trailing space to force a commit first). Skip the fallback on
|
|
640
|
+
// win32; legacy encodings still deliver Alt+Enter (ESC CR) and the newline
|
|
641
|
+
// chords, and IME composition works again. Opt back in with
|
|
642
|
+
// SKC_TUI_KEYBOARD_PROTOCOL=0 disabling all enhancement, or force-enable
|
|
643
|
+
// elsewhere if a Kitty-capable Windows terminal appears.
|
|
644
|
+
if (process.platform === "win32") {
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
585
647
|
this.#modifyOtherKeysTimeout = setTimeout(() => {
|
|
586
648
|
this.#modifyOtherKeysTimeout = undefined;
|
|
587
649
|
if (this.#kittyProtocolActive || this.#modifyOtherKeysActive) {
|
|
@@ -696,10 +758,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
696
758
|
process.stdout.removeListener("resize", this.#resizeHandler);
|
|
697
759
|
this.#resizeHandler = undefined;
|
|
698
760
|
}
|
|
699
|
-
|
|
700
|
-
process.stdout.removeListener("error", this.#stdoutErrorHandler);
|
|
701
|
-
this.#stdoutErrorHandler = undefined;
|
|
702
|
-
}
|
|
761
|
+
this.#scheduleStdoutErrorHandlerCleanup();
|
|
703
762
|
|
|
704
763
|
// Pause stdin to prevent any buffered input (e.g., Ctrl+D) from being
|
|
705
764
|
// re-interpreted after raw mode is disabled. This fixes a race condition
|
|
@@ -712,6 +771,23 @@ export class ProcessTerminal implements Terminal {
|
|
|
712
771
|
}
|
|
713
772
|
}
|
|
714
773
|
|
|
774
|
+
#scheduleStdoutErrorHandlerCleanup(): void {
|
|
775
|
+
if (!this.#stdoutErrorHandler) return;
|
|
776
|
+
if (this.#stdoutErrorHandlerCleanupTimer) clearTimeout(this.#stdoutErrorHandlerCleanupTimer);
|
|
777
|
+
// Terminal restore writes above can fail asynchronously after stop() returns
|
|
778
|
+
// when an SSH/Windows Terminal PTY disappears. Keep the stdout error listener
|
|
779
|
+
// armed briefly so late EIO/EPIPE events mark the terminal unavailable instead
|
|
780
|
+
// of surfacing as uncaught exceptions that kill the tmux pane.
|
|
781
|
+
this.#stdoutErrorHandlerCleanupTimer = setTimeout(() => {
|
|
782
|
+
if (this.#stdoutErrorHandler) {
|
|
783
|
+
process.stdout.removeListener("error", this.#stdoutErrorHandler);
|
|
784
|
+
this.#stdoutErrorHandler = undefined;
|
|
785
|
+
}
|
|
786
|
+
this.#stdoutErrorHandlerCleanupTimer = undefined;
|
|
787
|
+
}, STDOUT_ERROR_HANDLER_GRACE_MS);
|
|
788
|
+
this.#stdoutErrorHandlerCleanupTimer.unref?.();
|
|
789
|
+
}
|
|
790
|
+
|
|
715
791
|
write(data: string): void {
|
|
716
792
|
this.#safeWrite(data);
|
|
717
793
|
if (this.#writeLogPath) {
|
|
@@ -789,11 +865,11 @@ export class ProcessTerminal implements Terminal {
|
|
|
789
865
|
}
|
|
790
866
|
|
|
791
867
|
get columns(): number {
|
|
792
|
-
return
|
|
868
|
+
return resolveTerminalColumns();
|
|
793
869
|
}
|
|
794
870
|
|
|
795
871
|
get rows(): number {
|
|
796
|
-
return
|
|
872
|
+
return resolveTerminalRows();
|
|
797
873
|
}
|
|
798
874
|
|
|
799
875
|
moveBy(lines: number): void {
|
package/src/tui.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import * as fs from "node:fs";
|
|
5
5
|
import * as path from "node:path";
|
|
6
6
|
import { performance } from "node:perf_hooks";
|
|
7
|
-
import { $flag, getDebugLogPath } from "@sayknow-cli/utils";
|
|
7
|
+
import { $flag, getDebugLogPath, logger } from "@sayknow-cli/utils";
|
|
8
8
|
import { getKeybindings } from "./keybindings";
|
|
9
9
|
import { isKeyRelease } from "./keys";
|
|
10
10
|
import { renderMetrics } from "./metrics";
|
|
@@ -257,7 +257,7 @@ export class Container implements Component {
|
|
|
257
257
|
width = Math.max(1, width);
|
|
258
258
|
const lines: string[] = [];
|
|
259
259
|
for (const child of this.children) {
|
|
260
|
-
const childLines = child
|
|
260
|
+
const childLines = safeRenderComponent(child, width, "container-child");
|
|
261
261
|
for (let i = 0; i < childLines.length; i++) {
|
|
262
262
|
lines.push(childLines[i]);
|
|
263
263
|
}
|
|
@@ -266,6 +266,40 @@ export class Container implements Component {
|
|
|
266
266
|
}
|
|
267
267
|
}
|
|
268
268
|
|
|
269
|
+
const MAX_REPORTED_RENDER_ERRORS = 200;
|
|
270
|
+
const reportedRenderErrors = new Set<string>();
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Render a component's lines without letting a thrown error escape the frame.
|
|
274
|
+
*
|
|
275
|
+
* The TUI render loop ({@link TUI.#doRender}) runs inside a `nextTick`/`setTimeout`
|
|
276
|
+
* with no try/catch, and the process installs a fail-fast `uncaughtException`
|
|
277
|
+
* handler that exits. So a single component whose `render()` throws (e.g. a tool
|
|
278
|
+
* renderer fed an optional/undefined field) used to take down the whole app —
|
|
279
|
+
* fatal on whatever happened to trigger the frame (a keystroke, resize, or a
|
|
280
|
+
* command such as `/background`). Isolate the failure: log it once, emit a
|
|
281
|
+
* visible fallback line, and keep rendering the rest of the tree.
|
|
282
|
+
*/
|
|
283
|
+
function safeRenderComponent(component: Component, width: number, where: string): string[] {
|
|
284
|
+
try {
|
|
285
|
+
return component.render(width);
|
|
286
|
+
} catch (err) {
|
|
287
|
+
const name = component?.constructor?.name ?? "Component";
|
|
288
|
+
const key = `${where}:${name}:${err instanceof Error ? err.message : String(err)}`;
|
|
289
|
+
if (!reportedRenderErrors.has(key)) {
|
|
290
|
+
if (reportedRenderErrors.size >= MAX_REPORTED_RENDER_ERRORS) reportedRenderErrors.clear();
|
|
291
|
+
reportedRenderErrors.add(key);
|
|
292
|
+
logger.error("Component render failed; emitting fallback line", {
|
|
293
|
+
where,
|
|
294
|
+
component: name,
|
|
295
|
+
error: err instanceof Error ? err.message : String(err),
|
|
296
|
+
stack: err instanceof Error ? err.stack : undefined,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
return [`[render error: ${name}]`];
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
269
303
|
type LineNormalizationCacheEntry = {
|
|
270
304
|
normalized: string;
|
|
271
305
|
terminated: string;
|
|
@@ -307,6 +341,8 @@ export class TUI extends Container {
|
|
|
307
341
|
#cursorRow = 0; // Logical cursor row (end of rendered content)
|
|
308
342
|
#hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
|
|
309
343
|
#viewportTopRow = 0; // Content row currently mapped to screen row 0
|
|
344
|
+
#manualViewportTop: number | undefined;
|
|
345
|
+
#lastCursorPosition: { row: number; col: number } | null = null;
|
|
310
346
|
#sixelProbePendingDa = false;
|
|
311
347
|
#sixelProbePendingGraphics = false;
|
|
312
348
|
#sixelProbeBuffer = "";
|
|
@@ -393,6 +429,47 @@ export class TUI extends Container {
|
|
|
393
429
|
this.#bottomPinnedComponent = component;
|
|
394
430
|
this.requestRender();
|
|
395
431
|
}
|
|
432
|
+
scrollViewportPages(direction: -1 | 1): boolean {
|
|
433
|
+
const height = this.terminal.rows;
|
|
434
|
+
const width = this.terminal.columns;
|
|
435
|
+
if (height <= 0 || width <= 0 || this.#previousLines.length === 0) return false;
|
|
436
|
+
const maxViewportTop = Math.max(0, this.#previousLines.length - height);
|
|
437
|
+
const currentViewportTop = Math.max(0, Math.min(maxViewportTop, this.#manualViewportTop ?? this.#viewportTopRow));
|
|
438
|
+
const pageStep = Math.max(1, height - 1);
|
|
439
|
+
const targetViewportTop = Math.max(0, Math.min(maxViewportTop, currentViewportTop + direction * pageStep));
|
|
440
|
+
|
|
441
|
+
if (targetViewportTop >= maxViewportTop) {
|
|
442
|
+
this.#manualViewportTop = undefined;
|
|
443
|
+
} else {
|
|
444
|
+
this.#manualViewportTop = targetViewportTop;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const cursorPos = this.#manualViewportTop === undefined ? this.#lastCursorPosition : null;
|
|
448
|
+
return this.#repaintViewportFromLines(
|
|
449
|
+
this.#previousLines,
|
|
450
|
+
width,
|
|
451
|
+
height,
|
|
452
|
+
targetViewportTop,
|
|
453
|
+
cursorPos,
|
|
454
|
+
"manual viewport scroll",
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
followLiveViewport(): boolean {
|
|
459
|
+
if (this.#manualViewportTop === undefined) return false;
|
|
460
|
+
const height = this.terminal.rows;
|
|
461
|
+
const width = this.terminal.columns;
|
|
462
|
+
const liveViewportTop = Math.max(0, this.#previousLines.length - height);
|
|
463
|
+
this.#manualViewportTop = undefined;
|
|
464
|
+
return this.#repaintViewportFromLines(
|
|
465
|
+
this.#previousLines,
|
|
466
|
+
width,
|
|
467
|
+
height,
|
|
468
|
+
liveViewportTop,
|
|
469
|
+
this.#lastCursorPosition,
|
|
470
|
+
"manual viewport follow live",
|
|
471
|
+
);
|
|
472
|
+
}
|
|
396
473
|
|
|
397
474
|
/**
|
|
398
475
|
* Show an overlay component with configurable positioning and sizing.
|
|
@@ -492,7 +569,7 @@ export class TUI extends Container {
|
|
|
492
569
|
data => this.#handleInput(data),
|
|
493
570
|
() => {
|
|
494
571
|
this.invalidate();
|
|
495
|
-
this.
|
|
572
|
+
this.requestResizeRender();
|
|
496
573
|
},
|
|
497
574
|
);
|
|
498
575
|
this.#hideCursor();
|
|
@@ -736,6 +813,22 @@ export class TUI extends Container {
|
|
|
736
813
|
this.#previousHeight = 0;
|
|
737
814
|
}
|
|
738
815
|
|
|
816
|
+
/**
|
|
817
|
+
* Multiplexer-aware resize render request.
|
|
818
|
+
*
|
|
819
|
+
* A forced full redraw (`requestRender(true)`) resets `#previousWidth`/`#previousHeight`
|
|
820
|
+
* to -1, which makes `#doRender` treat the frame as a width change and fall into the
|
|
821
|
+
* `fullRender` path. In terminal multiplexers that path skips the scrollback-clearing
|
|
822
|
+
* `3J` escape (users navigate scrollback history), so replaying every transcript line
|
|
823
|
+
* piles it back on top of scrollback — the "top of screen scrolls down to the prompt at
|
|
824
|
+
* high speed" resize storm. Here we keep force off in multiplexers so `#doRender`'s
|
|
825
|
+
* height-change branch takes the viewport-only `multiplexerViewportRepaint` path instead.
|
|
826
|
+
* Set `PI_TUI_LEGACY_MULTIPLEXER_FULL_RENDER=1` to restore the legacy forced redraw.
|
|
827
|
+
*/
|
|
828
|
+
requestResizeRender(): void {
|
|
829
|
+
this.requestRender(!(isMultiplexerSession() && !useLegacyMultiplexerFullRender()), "resize");
|
|
830
|
+
}
|
|
831
|
+
|
|
739
832
|
requestRender(force = false, source = "unknown"): void {
|
|
740
833
|
if (!this.terminalAvailable) {
|
|
741
834
|
this.#markTerminalUnavailable();
|
|
@@ -1071,7 +1164,7 @@ export class TUI extends Container {
|
|
|
1071
1164
|
const { width, maxHeight } = this.#resolveOverlayLayout(options, 0, termWidth, termHeight);
|
|
1072
1165
|
|
|
1073
1166
|
// Render component at calculated width
|
|
1074
|
-
let overlayLines = component
|
|
1167
|
+
let overlayLines = safeRenderComponent(component, width, "overlay");
|
|
1075
1168
|
|
|
1076
1169
|
// Apply maxHeight if specified
|
|
1077
1170
|
if (maxHeight !== undefined && overlayLines.length > maxHeight) {
|
|
@@ -1302,7 +1395,7 @@ export class TUI extends Container {
|
|
|
1302
1395
|
|
|
1303
1396
|
let pinnedLineCount = 0;
|
|
1304
1397
|
for (let i = pinnedStart; i < this.children.length; i++) {
|
|
1305
|
-
pinnedLineCount += this.children[i]
|
|
1398
|
+
pinnedLineCount += safeRenderComponent(this.children[i], this.terminal.columns, "pinned").length;
|
|
1306
1399
|
}
|
|
1307
1400
|
|
|
1308
1401
|
const blankRows = height - lines.length;
|
|
@@ -1311,6 +1404,64 @@ export class TUI extends Container {
|
|
|
1311
1404
|
padded.splice(insertAt, 0, ...Array.from({ length: blankRows }, () => ""));
|
|
1312
1405
|
return padded;
|
|
1313
1406
|
}
|
|
1407
|
+
#repaintViewportFromLines(
|
|
1408
|
+
lines: string[],
|
|
1409
|
+
width: number,
|
|
1410
|
+
height: number,
|
|
1411
|
+
viewportTop: number,
|
|
1412
|
+
cursorPos: { row: number; col: number } | null,
|
|
1413
|
+
reason: string,
|
|
1414
|
+
): boolean {
|
|
1415
|
+
if (height <= 0 || width <= 0) return false;
|
|
1416
|
+
const maxViewportTop = Math.max(0, lines.length - height);
|
|
1417
|
+
const nextViewportTop = Math.max(0, Math.min(maxViewportTop, viewportTop));
|
|
1418
|
+
const currentScreenRow = Math.max(0, Math.min(height - 1, this.#hardwareCursorRow - this.#viewportTopRow));
|
|
1419
|
+
let buffer = "\x1b[?2026h";
|
|
1420
|
+
if (currentScreenRow > 0) {
|
|
1421
|
+
buffer += `\x1b[${currentScreenRow}A`;
|
|
1422
|
+
}
|
|
1423
|
+
buffer += "\r";
|
|
1424
|
+
|
|
1425
|
+
for (let screenRow = 0; screenRow < height; screenRow++) {
|
|
1426
|
+
if (screenRow > 0) buffer += "\r\n";
|
|
1427
|
+
buffer += "\x1b[2K";
|
|
1428
|
+
const lineIndex = nextViewportTop + screenRow;
|
|
1429
|
+
if (lineIndex >= lines.length) continue;
|
|
1430
|
+
const line = lines[lineIndex];
|
|
1431
|
+
const isImage = TERMINAL.isImageLine(line);
|
|
1432
|
+
if (!isImage && visibleWidth(line) > width) {
|
|
1433
|
+
let truncatedLine = truncateToWidth(line, width, Ellipsis.Omit);
|
|
1434
|
+
truncatedLine += truncatedLine.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET;
|
|
1435
|
+
buffer += truncatedLine;
|
|
1436
|
+
} else {
|
|
1437
|
+
buffer += line;
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
const finalPhysicalRow = nextViewportTop + Math.max(0, height - 1);
|
|
1442
|
+
let cursorSeq = "\x1b[?25l";
|
|
1443
|
+
let cursorToRow = finalPhysicalRow;
|
|
1444
|
+
if (cursorPos && cursorPos.row >= nextViewportTop && cursorPos.row < nextViewportTop + height) {
|
|
1445
|
+
const cursor = this.#cursorControlSequence(cursorPos, lines.length, finalPhysicalRow);
|
|
1446
|
+
cursorSeq = cursor.seq;
|
|
1447
|
+
cursorToRow = cursor.toRow;
|
|
1448
|
+
}
|
|
1449
|
+
this.#hardwareCursorRow = cursorToRow;
|
|
1450
|
+
buffer += cursorSeq;
|
|
1451
|
+
buffer += "\x1b[?2026l";
|
|
1452
|
+
if (!this.#writeTerminal(buffer)) return false;
|
|
1453
|
+
|
|
1454
|
+
if ($flag("PI_DEBUG_REDRAW")) {
|
|
1455
|
+
const logPath = getDebugLogPath();
|
|
1456
|
+
const msg = `[${new Date().toISOString()}] viewportRepaint: ${reason} (lines=${lines.length}, height=${height}, viewportTop=${nextViewportTop})\n`;
|
|
1457
|
+
fs.appendFileSync(logPath, msg);
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
this.#cursorRow = Math.max(0, lines.length - 1);
|
|
1461
|
+
this.#maxLinesRendered = lines.length;
|
|
1462
|
+
this.#viewportTopRow = nextViewportTop;
|
|
1463
|
+
return true;
|
|
1464
|
+
}
|
|
1314
1465
|
|
|
1315
1466
|
#doRender(): void {
|
|
1316
1467
|
if (this.#stopped || !this.terminalAvailable) return;
|
|
@@ -1341,6 +1492,7 @@ export class TUI extends Container {
|
|
|
1341
1492
|
|
|
1342
1493
|
// Extract cursor position (marker must be found before diff comparison)
|
|
1343
1494
|
const cursorPos = this.#extractCursorPosition(newLines, height);
|
|
1495
|
+
this.#lastCursorPosition = cursorPos;
|
|
1344
1496
|
|
|
1345
1497
|
// Terminate every non-image line so #previousLines mirrors emitted bytes
|
|
1346
1498
|
// (closes SGR + OSC 8 hyperlink state). Must run after cursor extraction
|
|
@@ -1401,6 +1553,28 @@ export class TUI extends Container {
|
|
|
1401
1553
|
if (usedWindowNormalize) renderMetrics.recordLineCount("offscreenScan", diffStart);
|
|
1402
1554
|
}
|
|
1403
1555
|
|
|
1556
|
+
if (this.#manualViewportTop !== undefined) {
|
|
1557
|
+
const maxViewportTop = Math.max(0, newLines.length - height);
|
|
1558
|
+
const nextViewportTop = Math.max(0, Math.min(maxViewportTop, this.#manualViewportTop));
|
|
1559
|
+
const followingLive = nextViewportTop >= maxViewportTop;
|
|
1560
|
+
this.#manualViewportTop = followingLive ? undefined : nextViewportTop;
|
|
1561
|
+
const repaintCursorPos = followingLive ? cursorPos : null;
|
|
1562
|
+
if (
|
|
1563
|
+
this.#repaintViewportFromLines(
|
|
1564
|
+
newLines,
|
|
1565
|
+
width,
|
|
1566
|
+
height,
|
|
1567
|
+
nextViewportTop,
|
|
1568
|
+
repaintCursorPos,
|
|
1569
|
+
"manual viewport render",
|
|
1570
|
+
)
|
|
1571
|
+
) {
|
|
1572
|
+
this.#previousLines = newLines;
|
|
1573
|
+
this.#previousWidth = width;
|
|
1574
|
+
this.#previousHeight = height;
|
|
1575
|
+
}
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1404
1578
|
// Helper to clear scrollback and viewport and render all new lines
|
|
1405
1579
|
const fullRender = (clear: boolean, reason = "full render"): void => {
|
|
1406
1580
|
this.#fullRedrawCount += 1;
|
|
@@ -1506,7 +1680,16 @@ export class TUI extends Container {
|
|
|
1506
1680
|
// Width changes always need a full re-render because wrapping changes.
|
|
1507
1681
|
if (widthChanged) {
|
|
1508
1682
|
logRedraw(`terminal width changed (${this.#previousWidth} -> ${width})`);
|
|
1509
|
-
|
|
1683
|
+
if (isMultiplexerSession() && !useLegacyMultiplexerFullRender()) {
|
|
1684
|
+
// In multiplexers a full replay piles the whole transcript back onto
|
|
1685
|
+
// scrollback (3J is intentionally skipped). Repaint the viewport only,
|
|
1686
|
+
// mirroring the height-change branch. This also neutralizes the fake
|
|
1687
|
+
// width change that requestRender(true) injects via #previousWidth = -1,
|
|
1688
|
+
// so every force-render call site is safe in multiplexers too.
|
|
1689
|
+
multiplexerViewportRepaint(`terminal width changed (${this.#previousWidth} -> ${width})`);
|
|
1690
|
+
} else {
|
|
1691
|
+
fullRender(true, "terminal width changed");
|
|
1692
|
+
}
|
|
1510
1693
|
return;
|
|
1511
1694
|
}
|
|
1512
1695
|
|