@gajae-code/tui 0.7.11 → 0.8.1
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 +10 -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 +58 -13
- package/src/components/select-list.ts +8 -1
- package/src/components/settings-list.ts +17 -5
- package/src/terminal.ts +14 -0
- package/src/tui.ts +151 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
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
|
+
|
|
5
15
|
## [0.7.11] - 2026-07-03
|
|
6
16
|
|
|
7
17
|
### 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.1",
|
|
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.1",
|
|
42
|
+
"@gajae-code/utils": "0.8.1",
|
|
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
|
|
@@ -1143,7 +1152,7 @@ export class Editor implements Component, Focusable {
|
|
|
1143
1152
|
// Check for stale autocomplete state due to debounce
|
|
1144
1153
|
const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
|
|
1145
1154
|
const currentTextBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
1146
|
-
if (currentTextBeforeCursor
|
|
1155
|
+
if (!currentTextBeforeCursor.endsWith(this.#autocompletePrefix)) {
|
|
1147
1156
|
// Autocomplete is stale - cancel and fall through to normal submission
|
|
1148
1157
|
this.#cancelAutocomplete();
|
|
1149
1158
|
} else {
|
|
@@ -1281,8 +1290,8 @@ export class Editor implements Component, Focusable {
|
|
|
1281
1290
|
const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
|
|
1282
1291
|
const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
1283
1292
|
if (
|
|
1284
|
-
|
|
1285
|
-
|
|
1293
|
+
(this.#isInSubmittedSlashCommandContext() ||
|
|
1294
|
+
this.#getSlashTokenBeforeCursor()?.startsWith("/skill") === true) &&
|
|
1286
1295
|
this.#autocompleteProvider?.trySyncSlashCompletion
|
|
1287
1296
|
) {
|
|
1288
1297
|
const syncResult = this.#autocompleteProvider.trySyncSlashCompletion(textBeforeCursor);
|
|
@@ -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({
|
|
@@ -1695,7 +1728,8 @@ export class Editor implements Component, Focusable {
|
|
|
1695
1728
|
|
|
1696
1729
|
// Check if we should trigger or update autocomplete
|
|
1697
1730
|
if (!this.#autocompleteState) {
|
|
1698
|
-
// Auto-trigger for "/" at the start of a
|
|
1731
|
+
// Auto-trigger for "/" at the start of a submitted command.
|
|
1732
|
+
// Inline skill autocomplete starts after the token becomes "/skill...".
|
|
1699
1733
|
if (char === "/" && this.#isAtStartOfSubmittedMessage()) {
|
|
1700
1734
|
this.#tryTriggerAutocomplete();
|
|
1701
1735
|
}
|
|
@@ -1717,8 +1751,8 @@ export class Editor implements Component, Focusable {
|
|
|
1717
1751
|
else if (/[a-zA-Z0-9.\-_/]/.test(char)) {
|
|
1718
1752
|
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
1719
1753
|
const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
1720
|
-
// Check if we're in a slash command
|
|
1721
|
-
if (this.#isInSubmittedSlashCommandContext()) {
|
|
1754
|
+
// Check if we're in a slash command or inline slash token
|
|
1755
|
+
if (this.#isInSubmittedSlashCommandContext() || this.#isInSlashTokenContext()) {
|
|
1722
1756
|
this.#tryTriggerAutocomplete();
|
|
1723
1757
|
}
|
|
1724
1758
|
// Check if we're in an @ file reference context
|
|
@@ -1913,8 +1947,8 @@ export class Editor implements Component, Focusable {
|
|
|
1913
1947
|
// If autocomplete was cancelled (no matches), re-trigger if we're in a completable context
|
|
1914
1948
|
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
1915
1949
|
const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
1916
|
-
// Slash command context
|
|
1917
|
-
if (this.#isInSubmittedSlashCommandContext()) {
|
|
1950
|
+
// Slash command or inline slash token context
|
|
1951
|
+
if (this.#isInSubmittedSlashCommandContext() || this.#isInSlashTokenContext()) {
|
|
1918
1952
|
this.#tryTriggerAutocomplete();
|
|
1919
1953
|
}
|
|
1920
1954
|
// @ file reference context
|
|
@@ -2070,7 +2104,7 @@ export class Editor implements Component, Focusable {
|
|
|
2070
2104
|
} else {
|
|
2071
2105
|
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
2072
2106
|
const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
2073
|
-
if (this.#isInSubmittedSlashCommandContext()) {
|
|
2107
|
+
if (this.#isInSubmittedSlashCommandContext() || this.#isInSlashTokenContext()) {
|
|
2074
2108
|
this.#tryTriggerAutocomplete();
|
|
2075
2109
|
} else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
|
|
2076
2110
|
this.#tryTriggerAutocomplete();
|
|
@@ -2384,8 +2418,8 @@ export class Editor implements Component, Focusable {
|
|
|
2384
2418
|
} else {
|
|
2385
2419
|
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
2386
2420
|
const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
2387
|
-
// Slash command context
|
|
2388
|
-
if (this.#isInSubmittedSlashCommandContext()) {
|
|
2421
|
+
// Slash command or inline slash token context
|
|
2422
|
+
if (this.#isInSubmittedSlashCommandContext() || this.#isInSlashTokenContext()) {
|
|
2389
2423
|
this.#tryTriggerAutocomplete();
|
|
2390
2424
|
}
|
|
2391
2425
|
// @ file reference context
|
|
@@ -2614,6 +2648,17 @@ export class Editor implements Component, Focusable {
|
|
|
2614
2648
|
return this.#hasOnlyWhitespaceBeforeCursorLine() && beforeCursor.trimStart().startsWith("/");
|
|
2615
2649
|
}
|
|
2616
2650
|
|
|
2651
|
+
#getSlashTokenBeforeCursor(): string | null {
|
|
2652
|
+
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
2653
|
+
const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
2654
|
+
const match = beforeCursor.match(/(?:^|\s)(\/[^\s]*)$/);
|
|
2655
|
+
return match?.[1] ?? null;
|
|
2656
|
+
}
|
|
2657
|
+
|
|
2658
|
+
#isInSlashTokenContext(): boolean {
|
|
2659
|
+
return this.#getSlashTokenBeforeCursor()?.startsWith("/skill") === true;
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2617
2662
|
#isSlashCommandNameAutocompleteSelection(): boolean {
|
|
2618
2663
|
if (this.#autocompleteState !== "regular") {
|
|
2619
2664
|
return false;
|
|
@@ -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
|
@@ -586,6 +586,20 @@ export class ProcessTerminal implements Terminal {
|
|
|
586
586
|
return;
|
|
587
587
|
}
|
|
588
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
|
+
}
|
|
589
603
|
this.#modifyOtherKeysTimeout = setTimeout(() => {
|
|
590
604
|
this.#modifyOtherKeysTimeout = undefined;
|
|
591
605
|
if (this.#kittyProtocolActive || this.#modifyOtherKeysActive) {
|
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
|
|