@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 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;
@@ -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.7.11",
4
+ "version": "0.8.1",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
- "homepage": "https://gaebal-gajae.dev",
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/gajae-ai/gajae-code.git",
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/gajae-ai/gajae-code/issues"
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.7.11",
42
- "@gajae-code/utils": "0.7.11",
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
  },
@@ -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 !== this.#autocompletePrefix) {
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
- textBeforeCursor.startsWith("/") &&
1285
- this.#isInSubmittedSlashCommandContext() &&
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: chunk.text,
1542
+ text: displayChunkText,
1510
1543
  hasCursor: true,
1511
- cursorPos: adjustedCursorPos,
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 line (slash commands)
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 (with or without space for arguments)
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
- if (this.#items.length === 0) {
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.requestRender(!(isMultiplexerSession() && !useLegacyMultiplexerFullRender()), "resize");
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
- fullRender(true, "terminal width changed");
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