@gajae-code/tui 0.7.10 → 0.8.0
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 +16 -0
- package/dist/types/components/editor.d.ts +5 -0
- package/dist/types/tui.d.ts +15 -0
- package/package.json +6 -6
- package/src/components/editor.ts +35 -2
- package/src/components/input.ts +2 -1
- package/src/components/select-list.ts +8 -1
- package/src/components/settings-list.ts +17 -5
- package/src/terminal.ts +44 -8
- package/src/tui.ts +151 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.8.0] - 2026-07-04
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Added manual transcript viewport paging support so applications can repaint older terminal output with PageUp/PageDown without reusing editor history navigation.
|
|
10
|
+
|
|
11
|
+
- Empty select/settings lists now still honor cancel while preserving populated-list keybinding precedence, and settings lists keep selection indices clamped when items disappear or submenus close after list shrinkage.
|
|
12
|
+
|
|
13
|
+
- Fixed Windows Hangul/CJK IME composition breaking the queue-message shortcut (Alt+Enter/Alt+Q): the app no longer enables the xterm `modifyOtherKeys` fallback on win32, where Windows Terminal/conhost do not support the Kitty keyboard protocol anyway. `modifyOtherKeys` made modified-key chords bypass the IME commit, so a syllable still being composed was dropped and the queue action fired on empty text unless the user typed a trailing space first. Legacy encodings still deliver Alt+Enter and the newline chords; opt back into the enhancement with `GJC_TUI_KEYBOARD_PROTOCOL`.
|
|
14
|
+
|
|
15
|
+
## [0.7.11] - 2026-07-03
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- Kept the terminal stdout error handler armed briefly after TUI shutdown so late `EIO`/closed-PTY errors from SSH or Windows Terminal detach do not crash tmux-backed GJC panes.
|
|
20
|
+
|
|
5
21
|
## [0.7.9] - 2026-07-01
|
|
6
22
|
|
|
7
23
|
### 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;
|
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,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@gajae-code/tui",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.8.0",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
|
-
"homepage": "https://
|
|
6
|
+
"homepage": "https://gajae-code.com",
|
|
7
7
|
"author": "Yeachan-Heo",
|
|
8
8
|
"contributors": [
|
|
9
9
|
"Mario Zechner"
|
|
@@ -11,11 +11,11 @@
|
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"repository": {
|
|
13
13
|
"type": "git",
|
|
14
|
-
"url": "git+https://github.com/
|
|
14
|
+
"url": "git+https://github.com/Yeachan-Heo/gajae-code.git",
|
|
15
15
|
"directory": "packages/tui"
|
|
16
16
|
},
|
|
17
17
|
"bugs": {
|
|
18
|
-
"url": "https://github.com/
|
|
18
|
+
"url": "https://github.com/Yeachan-Heo/gajae-code/issues"
|
|
19
19
|
},
|
|
20
20
|
"keywords": [
|
|
21
21
|
"tui",
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
"fmt": "biome format --write ."
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@gajae-code/natives": "0.
|
|
42
|
-
"@gajae-code/utils": "0.
|
|
41
|
+
"@gajae-code/natives": "0.8.0",
|
|
42
|
+
"@gajae-code/utils": "0.8.0",
|
|
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)
|
|
@@ -1076,6 +1081,10 @@ export class Editor implements Component, Focusable {
|
|
|
1076
1081
|
return;
|
|
1077
1082
|
}
|
|
1078
1083
|
|
|
1084
|
+
if (kb.matches(data, "tui.input.tab") && this.onTab?.(this.getText())) {
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1079
1088
|
// Handle autocomplete special keys first (but don't block other input)
|
|
1080
1089
|
if (this.#autocompleteState && this.#autocompleteList) {
|
|
1081
1090
|
// Escape - cancel autocomplete
|
|
@@ -1505,10 +1514,34 @@ export class Editor implements Component, Focusable {
|
|
|
1505
1514
|
}
|
|
1506
1515
|
|
|
1507
1516
|
if (hasCursorInChunk) {
|
|
1517
|
+
let displayChunkText = chunk.text;
|
|
1518
|
+
let displayCursorPos = adjustedCursorPos;
|
|
1519
|
+
if (displayCursorPos > displayChunkText.length) {
|
|
1520
|
+
let hiddenWhitespaceWidth = displayCursorPos - displayChunkText.length;
|
|
1521
|
+
const displayChunkWidth = visibleWidth(displayChunkText);
|
|
1522
|
+
if (displayChunkWidth + hiddenWhitespaceWidth <= contentWidth) {
|
|
1523
|
+
displayChunkText += padding(hiddenWhitespaceWidth);
|
|
1524
|
+
} else {
|
|
1525
|
+
layoutLines.push({
|
|
1526
|
+
text: displayChunkText,
|
|
1527
|
+
hasCursor: false,
|
|
1528
|
+
});
|
|
1529
|
+
hiddenWhitespaceWidth -= Math.max(0, contentWidth - displayChunkWidth);
|
|
1530
|
+
while (hiddenWhitespaceWidth > contentWidth) {
|
|
1531
|
+
layoutLines.push({
|
|
1532
|
+
text: padding(contentWidth),
|
|
1533
|
+
hasCursor: false,
|
|
1534
|
+
});
|
|
1535
|
+
hiddenWhitespaceWidth -= contentWidth;
|
|
1536
|
+
}
|
|
1537
|
+
displayChunkText = padding(hiddenWhitespaceWidth);
|
|
1538
|
+
displayCursorPos = hiddenWhitespaceWidth;
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1508
1541
|
layoutLines.push({
|
|
1509
|
-
text:
|
|
1542
|
+
text: displayChunkText,
|
|
1510
1543
|
hasCursor: true,
|
|
1511
|
-
cursorPos:
|
|
1544
|
+
cursorPos: displayCursorPos,
|
|
1512
1545
|
});
|
|
1513
1546
|
} else {
|
|
1514
1547
|
layoutLines.push({
|
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;
|
|
@@ -58,6 +58,14 @@ export class SettingsList implements Component {
|
|
|
58
58
|
this.#notifySelectionChange();
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
#clampSelectedIndex(): void {
|
|
62
|
+
if (this.#items.length === 0) {
|
|
63
|
+
this.#selectedIndex = 0;
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
this.#selectedIndex = Math.max(0, Math.min(this.#selectedIndex, this.#items.length - 1));
|
|
67
|
+
}
|
|
68
|
+
|
|
61
69
|
/** Update an item's currentValue */
|
|
62
70
|
updateValue(id: string, newValue: string): void {
|
|
63
71
|
const item = this.#items.find(i => i.id === id);
|
|
@@ -75,11 +83,7 @@ export class SettingsList implements Component {
|
|
|
75
83
|
*/
|
|
76
84
|
setItems(items: SettingItem[]): void {
|
|
77
85
|
this.#items = items;
|
|
78
|
-
|
|
79
|
-
this.#selectedIndex = 0;
|
|
80
|
-
} else if (this.#selectedIndex >= this.#items.length) {
|
|
81
|
-
this.#selectedIndex = this.#items.length - 1;
|
|
82
|
-
}
|
|
86
|
+
this.#clampSelectedIndex();
|
|
83
87
|
this.#notifySelectionChange();
|
|
84
88
|
}
|
|
85
89
|
|
|
@@ -186,6 +190,13 @@ export class SettingsList implements Component {
|
|
|
186
190
|
|
|
187
191
|
// Main list input handling
|
|
188
192
|
const kb = getKeybindings();
|
|
193
|
+
if (this.#items.length === 0) {
|
|
194
|
+
if (kb.matches(data, "tui.select.cancel")) {
|
|
195
|
+
this.#onCancel();
|
|
196
|
+
}
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
189
200
|
if (kb.matches(data, "tui.select.up")) {
|
|
190
201
|
this.#selectedIndex = this.#selectedIndex === 0 ? this.#items.length - 1 : this.#selectedIndex - 1;
|
|
191
202
|
this.#notifySelectionChange();
|
|
@@ -228,6 +239,7 @@ export class SettingsList implements Component {
|
|
|
228
239
|
// Restore selection to the item that opened the submenu
|
|
229
240
|
if (this.#submenuItemIndex !== null) {
|
|
230
241
|
this.#selectedIndex = this.#submenuItemIndex;
|
|
242
|
+
this.#clampSelectedIndex();
|
|
231
243
|
this.#submenuItemIndex = null;
|
|
232
244
|
this.#notifySelectionChange();
|
|
233
245
|
}
|
package/src/terminal.ts
CHANGED
|
@@ -167,6 +167,7 @@ export function resolveTerminalRows(
|
|
|
167
167
|
function isWindowsSubsystemForLinux(): boolean {
|
|
168
168
|
return process.platform === "linux" && (!!$env.WSL_DISTRO_NAME || !!$env.WSL_INTEROP);
|
|
169
169
|
}
|
|
170
|
+
const STDOUT_ERROR_HANDLER_GRACE_MS = 250;
|
|
170
171
|
|
|
171
172
|
/**
|
|
172
173
|
* Real terminal using process.stdin/stdout
|
|
@@ -185,6 +186,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
185
186
|
#detachLogPath = $env.PI_TUI_TERMINAL_DETACH_LOG || "";
|
|
186
187
|
#windowsVTInputRestore?: () => void;
|
|
187
188
|
#stdoutErrorHandler?: (err: Error) => void;
|
|
189
|
+
#stdoutErrorHandlerCleanupTimer?: Timer;
|
|
188
190
|
#appearanceCallbacks: Array<(appearance: TerminalAppearance) => void> = [];
|
|
189
191
|
#appearance: TerminalAppearance | undefined;
|
|
190
192
|
#osc11Pending = false;
|
|
@@ -233,10 +235,16 @@ export class ProcessTerminal implements Terminal {
|
|
|
233
235
|
|
|
234
236
|
// Set up resize handler immediately
|
|
235
237
|
process.stdout.on("resize", this.#resizeHandler);
|
|
236
|
-
this.#
|
|
237
|
-
this.#
|
|
238
|
-
|
|
239
|
-
|
|
238
|
+
if (this.#stdoutErrorHandlerCleanupTimer) {
|
|
239
|
+
clearTimeout(this.#stdoutErrorHandlerCleanupTimer);
|
|
240
|
+
this.#stdoutErrorHandlerCleanupTimer = undefined;
|
|
241
|
+
}
|
|
242
|
+
if (!this.#stdoutErrorHandler) {
|
|
243
|
+
this.#stdoutErrorHandler = (err: Error) => {
|
|
244
|
+
this.#markUnavailable(err, "stdout-error");
|
|
245
|
+
};
|
|
246
|
+
process.stdout.on("error", this.#stdoutErrorHandler);
|
|
247
|
+
}
|
|
240
248
|
|
|
241
249
|
// Refresh terminal dimensions - they may be stale after suspend/resume
|
|
242
250
|
// (SIGWINCH is lost while process is stopped). Unix only.
|
|
@@ -578,6 +586,20 @@ export class ProcessTerminal implements Terminal {
|
|
|
578
586
|
return;
|
|
579
587
|
}
|
|
580
588
|
this.#safeWrite("\x1b[?u");
|
|
589
|
+
// Windows Terminal and conhost do not implement the Kitty keyboard
|
|
590
|
+
// protocol, so the query above never activates it there. They do honor the
|
|
591
|
+
// modifyOtherKeys fallback below — but that mode breaks Windows CJK/Hangul
|
|
592
|
+
// IME composition: Alt+Enter (and other chords) bypass the IME commit, so
|
|
593
|
+
// the syllable still being composed is never delivered to the app and the
|
|
594
|
+
// action fires on empty text (e.g. queue-message no-ops unless the user
|
|
595
|
+
// types a trailing space to force a commit first). Skip the fallback on
|
|
596
|
+
// win32; legacy encodings still deliver Alt+Enter (ESC CR) and the newline
|
|
597
|
+
// chords, and IME composition works again. Opt back in with
|
|
598
|
+
// GJC_TUI_KEYBOARD_PROTOCOL=0 disabling all enhancement, or force-enable
|
|
599
|
+
// elsewhere if a Kitty-capable Windows terminal appears.
|
|
600
|
+
if (process.platform === "win32") {
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
581
603
|
this.#modifyOtherKeysTimeout = setTimeout(() => {
|
|
582
604
|
this.#modifyOtherKeysTimeout = undefined;
|
|
583
605
|
if (this.#kittyProtocolActive || this.#modifyOtherKeysActive) {
|
|
@@ -692,10 +714,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
692
714
|
process.stdout.removeListener("resize", this.#resizeHandler);
|
|
693
715
|
this.#resizeHandler = undefined;
|
|
694
716
|
}
|
|
695
|
-
|
|
696
|
-
process.stdout.removeListener("error", this.#stdoutErrorHandler);
|
|
697
|
-
this.#stdoutErrorHandler = undefined;
|
|
698
|
-
}
|
|
717
|
+
this.#scheduleStdoutErrorHandlerCleanup();
|
|
699
718
|
|
|
700
719
|
// Pause stdin to prevent any buffered input (e.g., Ctrl+D) from being
|
|
701
720
|
// re-interpreted after raw mode is disabled. This fixes a race condition
|
|
@@ -708,6 +727,23 @@ export class ProcessTerminal implements Terminal {
|
|
|
708
727
|
}
|
|
709
728
|
}
|
|
710
729
|
|
|
730
|
+
#scheduleStdoutErrorHandlerCleanup(): void {
|
|
731
|
+
if (!this.#stdoutErrorHandler) return;
|
|
732
|
+
if (this.#stdoutErrorHandlerCleanupTimer) clearTimeout(this.#stdoutErrorHandlerCleanupTimer);
|
|
733
|
+
// Terminal restore writes above can fail asynchronously after stop() returns
|
|
734
|
+
// when an SSH/Windows Terminal PTY disappears. Keep the stdout error listener
|
|
735
|
+
// armed briefly so late EIO/EPIPE events mark the terminal unavailable instead
|
|
736
|
+
// of surfacing as uncaught exceptions that kill the tmux pane.
|
|
737
|
+
this.#stdoutErrorHandlerCleanupTimer = setTimeout(() => {
|
|
738
|
+
if (this.#stdoutErrorHandler) {
|
|
739
|
+
process.stdout.removeListener("error", this.#stdoutErrorHandler);
|
|
740
|
+
this.#stdoutErrorHandler = undefined;
|
|
741
|
+
}
|
|
742
|
+
this.#stdoutErrorHandlerCleanupTimer = undefined;
|
|
743
|
+
}, STDOUT_ERROR_HANDLER_GRACE_MS);
|
|
744
|
+
this.#stdoutErrorHandlerCleanupTimer.unref?.();
|
|
745
|
+
}
|
|
746
|
+
|
|
711
747
|
write(data: string): void {
|
|
712
748
|
this.#safeWrite(data);
|
|
713
749
|
if (this.#writeLogPath) {
|
package/src/tui.ts
CHANGED
|
@@ -341,6 +341,8 @@ export class TUI extends Container {
|
|
|
341
341
|
#cursorRow = 0; // Logical cursor row (end of rendered content)
|
|
342
342
|
#hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
|
|
343
343
|
#viewportTopRow = 0; // Content row currently mapped to screen row 0
|
|
344
|
+
#manualViewportTop: number | undefined;
|
|
345
|
+
#lastCursorPosition: { row: number; col: number } | null = null;
|
|
344
346
|
#sixelProbePendingDa = false;
|
|
345
347
|
#sixelProbePendingGraphics = false;
|
|
346
348
|
#sixelProbeBuffer = "";
|
|
@@ -427,6 +429,47 @@ export class TUI extends Container {
|
|
|
427
429
|
this.#bottomPinnedComponent = component;
|
|
428
430
|
this.requestRender();
|
|
429
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
|
+
}
|
|
430
473
|
|
|
431
474
|
/**
|
|
432
475
|
* Show an overlay component with configurable positioning and sizing.
|
|
@@ -526,7 +569,7 @@ export class TUI extends Container {
|
|
|
526
569
|
data => this.#handleInput(data),
|
|
527
570
|
() => {
|
|
528
571
|
this.invalidate();
|
|
529
|
-
this.
|
|
572
|
+
this.requestResizeRender();
|
|
530
573
|
},
|
|
531
574
|
);
|
|
532
575
|
this.#hideCursor();
|
|
@@ -770,6 +813,22 @@ export class TUI extends Container {
|
|
|
770
813
|
this.#previousHeight = 0;
|
|
771
814
|
}
|
|
772
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
|
+
|
|
773
832
|
requestRender(force = false, source = "unknown"): void {
|
|
774
833
|
if (!this.terminalAvailable) {
|
|
775
834
|
this.#markTerminalUnavailable();
|
|
@@ -1345,6 +1404,64 @@ export class TUI extends Container {
|
|
|
1345
1404
|
padded.splice(insertAt, 0, ...Array.from({ length: blankRows }, () => ""));
|
|
1346
1405
|
return padded;
|
|
1347
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
|
+
}
|
|
1348
1465
|
|
|
1349
1466
|
#doRender(): void {
|
|
1350
1467
|
if (this.#stopped || !this.terminalAvailable) return;
|
|
@@ -1375,6 +1492,7 @@ export class TUI extends Container {
|
|
|
1375
1492
|
|
|
1376
1493
|
// Extract cursor position (marker must be found before diff comparison)
|
|
1377
1494
|
const cursorPos = this.#extractCursorPosition(newLines, height);
|
|
1495
|
+
this.#lastCursorPosition = cursorPos;
|
|
1378
1496
|
|
|
1379
1497
|
// Terminate every non-image line so #previousLines mirrors emitted bytes
|
|
1380
1498
|
// (closes SGR + OSC 8 hyperlink state). Must run after cursor extraction
|
|
@@ -1435,6 +1553,28 @@ export class TUI extends Container {
|
|
|
1435
1553
|
if (usedWindowNormalize) renderMetrics.recordLineCount("offscreenScan", diffStart);
|
|
1436
1554
|
}
|
|
1437
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
|
+
}
|
|
1438
1578
|
// Helper to clear scrollback and viewport and render all new lines
|
|
1439
1579
|
const fullRender = (clear: boolean, reason = "full render"): void => {
|
|
1440
1580
|
this.#fullRedrawCount += 1;
|
|
@@ -1540,7 +1680,16 @@ export class TUI extends Container {
|
|
|
1540
1680
|
// Width changes always need a full re-render because wrapping changes.
|
|
1541
1681
|
if (widthChanged) {
|
|
1542
1682
|
logRedraw(`terminal width changed (${this.#previousWidth} -> ${width})`);
|
|
1543
|
-
|
|
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
|
+
}
|
|
1544
1693
|
return;
|
|
1545
1694
|
}
|
|
1546
1695
|
|