@code-yeongyu/senpi-tui 2026.8.1 → 2026.8.3-3

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.
Files changed (78) hide show
  1. package/README.md +77 -14
  2. package/dist/TuiAltScreen.d.ts +83 -0
  3. package/dist/TuiAltScreen.d.ts.map +1 -0
  4. package/dist/TuiAltScreen.js +687 -0
  5. package/dist/TuiAltScreen.js.map +1 -0
  6. package/dist/TuiMainScreen.d.ts +5 -0
  7. package/dist/TuiMainScreen.d.ts.map +1 -0
  8. package/dist/TuiMainScreen.js +5 -0
  9. package/dist/TuiMainScreen.js.map +1 -0
  10. package/dist/components/alt-screen-flash.d.ts +13 -0
  11. package/dist/components/alt-screen-flash.d.ts.map +1 -0
  12. package/dist/components/alt-screen-flash.js +36 -0
  13. package/dist/components/alt-screen-flash.js.map +1 -0
  14. package/dist/components/editor.d.ts.map +1 -1
  15. package/dist/components/editor.js +5 -1
  16. package/dist/components/editor.js.map +1 -1
  17. package/dist/components/h-stack.d.ts +7 -0
  18. package/dist/components/h-stack.d.ts.map +1 -0
  19. package/dist/components/h-stack.js +43 -0
  20. package/dist/components/h-stack.js.map +1 -0
  21. package/dist/components/image.d.ts.map +1 -1
  22. package/dist/components/image.js +4 -4
  23. package/dist/components/image.js.map +1 -1
  24. package/dist/components/markdown.d.ts +2 -0
  25. package/dist/components/markdown.d.ts.map +1 -1
  26. package/dist/components/markdown.js +3 -2
  27. package/dist/components/markdown.js.map +1 -1
  28. package/dist/components/scroll-view.d.ts +51 -0
  29. package/dist/components/scroll-view.d.ts.map +1 -0
  30. package/dist/components/scroll-view.js +163 -0
  31. package/dist/components/scroll-view.js.map +1 -0
  32. package/dist/components/settings-list.d.ts.map +1 -1
  33. package/dist/components/settings-list.js +3 -6
  34. package/dist/components/settings-list.js.map +1 -1
  35. package/dist/components/stack.d.ts +32 -0
  36. package/dist/components/stack.d.ts.map +1 -0
  37. package/dist/components/stack.js +109 -0
  38. package/dist/components/stack.js.map +1 -0
  39. package/dist/components/v-stack.d.ts +8 -0
  40. package/dist/components/v-stack.d.ts.map +1 -0
  41. package/dist/components/v-stack.js +21 -0
  42. package/dist/components/v-stack.js.map +1 -0
  43. package/dist/index.d.ts +10 -4
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +10 -4
  46. package/dist/index.js.map +1 -1
  47. package/dist/keybindings.d.ts +30 -0
  48. package/dist/keybindings.d.ts.map +1 -1
  49. package/dist/keybindings.js +18 -0
  50. package/dist/keybindings.js.map +1 -1
  51. package/dist/layout-node.d.ts +40 -0
  52. package/dist/layout-node.d.ts.map +1 -0
  53. package/dist/layout-node.js +6 -0
  54. package/dist/layout-node.js.map +1 -0
  55. package/dist/layout.d.ts +40 -0
  56. package/dist/layout.d.ts.map +1 -0
  57. package/dist/layout.js +315 -0
  58. package/dist/layout.js.map +1 -0
  59. package/dist/slash-command-autocomplete.d.ts.map +1 -1
  60. package/dist/slash-command-autocomplete.js +36 -8
  61. package/dist/slash-command-autocomplete.js.map +1 -1
  62. package/dist/terminal-image.d.ts +10 -0
  63. package/dist/terminal-image.d.ts.map +1 -1
  64. package/dist/terminal-image.js +61 -3
  65. package/dist/terminal-image.js.map +1 -1
  66. package/dist/terminal-text.d.ts +1 -0
  67. package/dist/terminal-text.d.ts.map +1 -1
  68. package/dist/terminal-text.js +12 -2
  69. package/dist/terminal-text.js.map +1 -1
  70. package/dist/tui.d.ts +38 -16
  71. package/dist/tui.d.ts.map +1 -1
  72. package/dist/tui.js +121 -51
  73. package/dist/tui.js.map +1 -1
  74. package/dist/utils.d.ts +10 -0
  75. package/dist/utils.d.ts.map +1 -1
  76. package/dist/utils.js +95 -4
  77. package/dist/utils.js.map +1 -1
  78. package/package.json +2 -2
package/dist/tui.js CHANGED
@@ -22,20 +22,17 @@ function parseKittyImageHeader(line) {
22
22
  return undefined;
23
23
  const ids = [];
24
24
  let rows = 1;
25
- const params = line.slice(paramsStart, paramsEnd);
26
- for (const param of params.split(",")) {
25
+ for (const param of line.slice(paramsStart, paramsEnd).split(",")) {
27
26
  const [key, value] = param.split("=", 2);
28
27
  if (value === undefined)
29
28
  continue;
30
29
  const numberValue = Number(value);
31
30
  if (!Number.isInteger(numberValue) || numberValue <= 0 || numberValue > 0xffffffff)
32
31
  continue;
33
- if (key === "i") {
32
+ if (key === "i")
34
33
  ids.push(numberValue);
35
- }
36
- else if (key === "r") {
34
+ else if (key === "r")
37
35
  rows = numberValue;
38
- }
39
36
  }
40
37
  return { ids, rows };
41
38
  }
@@ -45,6 +42,9 @@ function extractKittyImageIds(line) {
45
42
  function extractKittyImageRows(line) {
46
43
  return parseKittyImageHeader(line)?.rows ?? 1;
47
44
  }
45
+ function isTermuxSession() {
46
+ return Boolean(process.env.TERMUX_VERSION);
47
+ }
48
48
  /** Type guard to check if a component implements Focusable */
49
49
  export function isFocusable(component) {
50
50
  return component !== null && "focused" in component;
@@ -56,6 +56,9 @@ export function isFocusable(component) {
56
56
  * TUI finds and strips this marker, then positions the hardware cursor there.
57
57
  */
58
58
  export const CURSOR_MARKER = "\x1b_pi:c\x07";
59
+ const FAKE_CURSOR_START = "\x1b[7m";
60
+ const FAKE_CURSOR_END = "\x1b[27m";
61
+ const FAKE_CURSOR_RESET = "\x1b[0m";
59
62
  export { visibleWidth };
60
63
  const renderErrorLoggedClasses = new Set();
61
64
  let renderErrorLogWrites = 0;
@@ -186,9 +189,6 @@ function parseSizeValue(value, referenceSize) {
186
189
  }
187
190
  return undefined;
188
191
  }
189
- function isTermuxSession() {
190
- return Boolean(process.env.TERMUX_VERSION);
191
- }
192
192
  /**
193
193
  * Container - a component that contains other components
194
194
  */
@@ -258,12 +258,47 @@ export class Container {
258
258
  /**
259
259
  * TUI - Main class for managing terminal UI with differential rendering
260
260
  */
261
- export class TUI extends Container {
261
+ const SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07";
262
+ /** Composite overlay content into a terminal line at a fixed column. */
263
+ export function compositeTuiLine(baseLine, overlayLine, startCol, overlayWidth, totalWidth) {
264
+ if (isImageLine(baseLine) && visibleWidth(baseLine) === 0)
265
+ return baseLine;
266
+ const placeholderIndex = baseLine.indexOf("\u{10eeee}");
267
+ const protocolEnd = placeholderIndex === -1 ? -1 : baseLine.lastIndexOf("\x1b\\", placeholderIndex);
268
+ const protocolPrefix = protocolEnd === -1 ? "" : baseLine.slice(0, protocolEnd + 2);
269
+ const afterStart = startCol + overlayWidth;
270
+ const base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true);
271
+ const overlay = sliceWithWidth(overlayLine, 0, overlayWidth, true);
272
+ const beforePad = Math.max(0, startCol - base.beforeWidth);
273
+ const overlayPad = Math.max(0, overlayWidth - overlay.width);
274
+ const actualBeforeWidth = Math.max(startCol, base.beforeWidth);
275
+ const actualOverlayWidth = Math.max(overlayWidth, overlay.width);
276
+ const afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth);
277
+ const afterPad = Math.max(0, afterTarget - base.afterWidth);
278
+ const result = base.before +
279
+ " ".repeat(beforePad) +
280
+ SEGMENT_RESET +
281
+ overlay.text +
282
+ " ".repeat(overlayPad) +
283
+ SEGMENT_RESET +
284
+ base.after +
285
+ " ".repeat(afterPad);
286
+ const composited = visibleWidth(result) <= totalWidth ? result : sliceByColumn(result, 0, totalWidth, true);
287
+ return protocolPrefix + composited;
288
+ }
289
+ export const VIEWPORT_TUI = Symbol.for("@earendil-works/pi-tui/viewport");
290
+ export function isViewportTUI(tui) {
291
+ return tui[VIEWPORT_TUI] === true;
292
+ }
293
+ export class TuiBase extends Container {
262
294
  /** Minimum interval between scheduled renders. Default preserves the historic ~60fps cap. */
263
295
  #minRenderIntervalMs;
264
296
  #lastCursorVisibility;
297
+ get hasOverlayEntries() {
298
+ return this.overlayStack.length > 0;
299
+ }
265
300
  #muxDetector;
266
- constructor(terminal, options) {
301
+ constructor(terminal, options, logDirectory) {
267
302
  super();
268
303
  this.previousLines = [];
269
304
  this.previousRawLines = [];
@@ -300,10 +335,16 @@ export class TUI extends Container {
300
335
  // Preserve existing positional boolean callers while allowing explicit render-policy overrides.
301
336
  const normalizedOptions = typeof options === "boolean" ? { showHardwareCursor: options } : (options ?? {});
302
337
  this.#muxDetector = normalizedOptions.muxDetector ?? isMultiplexerSession;
338
+ this.logDirectory = logDirectory ?? path.join(os.homedir(), ".senpi", "agent");
303
339
  if (normalizedOptions.showHardwareCursor !== undefined) {
304
340
  this.showHardwareCursor = normalizedOptions.showHardwareCursor;
305
341
  }
306
342
  }
343
+ resetRenderState() { }
344
+ beforeTerminalStart() { }
345
+ afterTerminalStart() { }
346
+ beforeTerminalStop() { }
347
+ afterTerminalStop() { }
307
348
  get fullRedraws() {
308
349
  return this.fullRedrawCount;
309
350
  }
@@ -317,9 +358,6 @@ export class TUI extends Container {
317
358
  if (this.showHardwareCursor === enabled)
318
359
  return;
319
360
  this.showHardwareCursor = enabled;
320
- if (!enabled) {
321
- this.#setCursorVisibility(false);
322
- }
323
361
  this.requestRender();
324
362
  }
325
363
  getClearOnShrink() {
@@ -433,8 +471,11 @@ export class TUI extends Container {
433
471
  }
434
472
  }
435
473
  }
474
+ getMountedRoots() {
475
+ return this.children;
476
+ }
436
477
  isComponentMounted(component) {
437
- return this.children.some((child) => this.containsComponent(child, component));
478
+ return this.getMountedRoots().some((child) => this.containsComponent(child, component));
438
479
  }
439
480
  containsComponent(root, target) {
440
481
  if (root === target)
@@ -596,7 +637,9 @@ export class TUI extends Container {
596
637
  this.renderRequested = false;
597
638
  this.inputRenderPending = false;
598
639
  this.#lastCursorVisibility = undefined;
599
- this.terminal.start((data) => this.handleInput(data), () => this.requestRender());
640
+ this.beforeTerminalStart();
641
+ this.terminal.start((data) => this.handleTerminalInput(data), () => this.requestRender());
642
+ this.afterTerminalStart();
600
643
  if (process.env.TMUX)
601
644
  this.terminal.write(ENABLE_FOCUS_REPORTING);
602
645
  this.#setCursorVisibility(false);
@@ -650,10 +693,14 @@ export class TUI extends Container {
650
693
  if (this.terminalColorSchemeNotificationsEnabled) {
651
694
  this.terminal.write("\x1b[?2031l");
652
695
  }
696
+ this.beforeTerminalStop();
653
697
  // Move cursor to the end of the content to prevent overwriting/artifacts on exit
654
698
  if (this.previousLines.length > 0) {
655
- // Overwrite the inverted cursor with a normal space to clear the artifact
656
- this.terminal.write(" ");
699
+ // Only overwrite the cursor cell when the last published frame kept the hardware cursor hidden;
700
+ // a pending visibility change has not rendered yet and must not erase content.
701
+ if (this.#lastCursorVisibility === false) {
702
+ this.terminal.write(" ");
703
+ }
657
704
  const targetRow = this.previousLines.length; // Line after the last content
658
705
  const lineDiff = targetRow - this.hardwareCursorRow;
659
706
  if (lineDiff > 0) {
@@ -669,6 +716,8 @@ export class TUI extends Container {
669
716
  if (process.env.TMUX)
670
717
  this.terminal.write(DISABLE_FOCUS_REPORTING);
671
718
  this.terminal.stop();
719
+ this.afterTerminalStop();
720
+ this.resetRenderState();
672
721
  this.#lastCursorVisibility = undefined;
673
722
  this.previousLines = [];
674
723
  this.previousRawLines = [];
@@ -694,6 +743,7 @@ export class TUI extends Container {
694
743
  requestRender(force = false, source = "unknown") {
695
744
  if (force) {
696
745
  this.inputRenderPending = false;
746
+ this.resetRenderState();
697
747
  this.previousLines = [];
698
748
  this.previousRawLines = [];
699
749
  this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
@@ -773,7 +823,7 @@ export class TUI extends Container {
773
823
  this.lastRenderAt = performance.now();
774
824
  this.doRender();
775
825
  }
776
- handleInput(data) {
826
+ handleTerminalInput(data) {
777
827
  const focus = consumeTmuxFocusEvent(data);
778
828
  if (focus.event !== null) {
779
829
  resetCapabilitiesCache();
@@ -1107,7 +1157,10 @@ export class TUI extends Container {
1107
1157
  this.normalizeMemo.set(line, normalized);
1108
1158
  return { line: normalized, normalized: true };
1109
1159
  }
1110
- applyLineResets(lines, mode = "full") {
1160
+ applyLineResets(lines) {
1161
+ return this.applyLineResetResult(lines).lines;
1162
+ }
1163
+ applyLineResetResult(lines, mode = "full") {
1111
1164
  const previousMemo = this.normalizeMemo;
1112
1165
  const nextMemo = new Map();
1113
1166
  const normalizedLines = [];
@@ -1141,7 +1194,7 @@ export class TUI extends Container {
1141
1194
  this.previousLines.length === 0 ||
1142
1195
  this.previousLines.length !== rawLines.length ||
1143
1196
  this.previousRawLines.length !== rawLines.length) {
1144
- return this.applyLineResets(rawLines);
1197
+ return this.applyLineResetResult(rawLines);
1145
1198
  }
1146
1199
  const windowStart = Math.max(0, viewportTop - VIEWPORT_RENDER_OVERSCAN);
1147
1200
  const windowEnd = Math.min(rawLines.length, viewportTop + height + VIEWPORT_RENDER_OVERSCAN);
@@ -1154,7 +1207,7 @@ export class TUI extends Container {
1154
1207
  firstRawChanged = i;
1155
1208
  }
1156
1209
  if (i < windowStart || i >= windowEnd) {
1157
- return this.applyLineResets(rawLines, "escaped");
1210
+ return this.applyLineResetResult(rawLines, "escaped");
1158
1211
  }
1159
1212
  }
1160
1213
  const lines = this.previousLines.slice();
@@ -1323,13 +1376,12 @@ export class TUI extends Container {
1323
1376
  buffer += row.content;
1324
1377
  finalPaintedScreenRow = row.screenRow;
1325
1378
  }
1326
- buffer += TUI.FRAME_END;
1379
+ const finalCursorRow = plan.viewportTop + finalPaintedScreenRow;
1380
+ buffer = this.finishFrame(buffer, cursorPos, newLines.length, finalCursorRow);
1327
1381
  this.terminal.write(buffer);
1328
1382
  this.cursorRow = Math.max(0, newLines.length - 1);
1329
- this.hardwareCursorRow = plan.viewportTop + finalPaintedScreenRow;
1330
1383
  this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
1331
1384
  this.previousViewportTop = plan.viewportTop;
1332
- this.positionHardwareCursor(cursorPos, newLines.length);
1333
1385
  this.setPreviousLines(newLines, rawLines);
1334
1386
  this.previousKittyImageIds = this.collectKittyImageIds(newLines);
1335
1387
  this.previousWidth = width;
@@ -1352,13 +1404,11 @@ export class TUI extends Container {
1352
1404
  buffer += `\r\x1b[2K${TUI.SEGMENT_RESET}`;
1353
1405
  buffer += newLines[row] ?? "";
1354
1406
  }
1355
- buffer += TUI.FRAME_END;
1407
+ buffer = this.finishFrame(buffer, cursorPos, newLines.length, bufferLength - 1);
1356
1408
  this.terminal.write(buffer);
1357
1409
  this.cursorRow = Math.max(0, newLines.length - 1);
1358
- this.hardwareCursorRow = bufferLength - 1;
1359
1410
  this.maxLinesRendered = newLines.length;
1360
1411
  this.previousViewportTop = Math.max(0, bufferLength - height);
1361
- this.positionHardwareCursor(cursorPos, newLines.length);
1362
1412
  this.setPreviousLines(newLines, rawLines);
1363
1413
  this.previousKittyImageIds = this.collectKittyImageIds(newLines);
1364
1414
  this.previousWidth = width;
@@ -1381,14 +1431,13 @@ export class TUI extends Container {
1381
1431
  buffer += `\r\x1b[2K${TUI.SEGMENT_RESET}`;
1382
1432
  buffer += newLines[viewportTop + row] ?? "";
1383
1433
  }
1384
- buffer += TUI.FRAME_END;
1434
+ const finalCursorRow = viewportTop + Math.max(0, height - 1);
1435
+ buffer = this.finishFrame(buffer, cursorPos, newLines.length, finalCursorRow);
1385
1436
  this.terminal.write(buffer);
1386
1437
  this.muxViewportRepaintCount += 1;
1387
1438
  this.cursorRow = Math.max(0, newLines.length - 1);
1388
- this.hardwareCursorRow = viewportTop + Math.max(0, height - 1);
1389
1439
  this.maxLinesRendered = newLines.length;
1390
1440
  this.previousViewportTop = viewportTop;
1391
- this.positionHardwareCursor(cursorPos, newLines.length);
1392
1441
  this.setPreviousLines(newLines, rawLines);
1393
1442
  this.previousKittyImageIds = this.collectKittyImageIds(newLines);
1394
1443
  this.previousWidth = width;
@@ -1455,8 +1504,22 @@ export class TUI extends Container {
1455
1504
  // Calculate visual column (width of text before marker)
1456
1505
  const beforeMarker = line.slice(0, markerIndex);
1457
1506
  const col = visibleWidth(beforeMarker);
1458
- // Strip marker from the line
1459
- lines[row] = line.slice(0, markerIndex) + line.slice(markerIndex + CURSOR_MARKER.length);
1507
+ let afterMarker = line.slice(markerIndex + CURSOR_MARKER.length);
1508
+ if (this.showHardwareCursor && afterMarker.startsWith(FAKE_CURSOR_START)) {
1509
+ const fakeCursorEnd = afterMarker.indexOf(FAKE_CURSOR_END, FAKE_CURSOR_START.length);
1510
+ const fakeCursorReset = afterMarker.indexOf(FAKE_CURSOR_RESET, FAKE_CURSOR_START.length);
1511
+ if (fakeCursorReset !== -1 && (fakeCursorEnd === -1 || fakeCursorReset < fakeCursorEnd)) {
1512
+ // Keep a full reset because it may also terminate styles surrounding the fake cursor.
1513
+ afterMarker = afterMarker.slice(FAKE_CURSOR_START.length);
1514
+ }
1515
+ else if (fakeCursorEnd !== -1) {
1516
+ afterMarker =
1517
+ afterMarker.slice(FAKE_CURSOR_START.length, fakeCursorEnd) +
1518
+ afterMarker.slice(fakeCursorEnd + FAKE_CURSOR_END.length);
1519
+ }
1520
+ }
1521
+ // Strip marker and any colocated fake cursor styling when the hardware cursor owns the position.
1522
+ lines[row] = beforeMarker + afterMarker;
1460
1523
  return { row, col };
1461
1524
  }
1462
1525
  }
@@ -1522,10 +1585,10 @@ export class TUI extends Container {
1522
1585
  }
1523
1586
  buffer += line;
1524
1587
  }
1525
- buffer += TUI.FRAME_END;
1588
+ const finalCursorRow = Math.max(0, newLines.length - 1);
1589
+ buffer = this.finishFrame(buffer, cursorPos, newLines.length, finalCursorRow);
1526
1590
  this.terminal.write(buffer);
1527
1591
  this.cursorRow = Math.max(0, newLines.length - 1);
1528
- this.hardwareCursorRow = this.cursorRow;
1529
1592
  // Reset max lines when clearing, otherwise track growth
1530
1593
  if (clear) {
1531
1594
  this.maxLinesRendered = newLines.length;
@@ -1535,7 +1598,6 @@ export class TUI extends Container {
1535
1598
  }
1536
1599
  const bufferLength = Math.max(height, newLines.length);
1537
1600
  this.previousViewportTop = Math.max(0, bufferLength - height);
1538
- this.positionHardwareCursor(cursorPos, newLines.length);
1539
1601
  this.setPreviousLines(newLines, rawLines);
1540
1602
  this.previousKittyImageIds = this.collectKittyImageIds(newLines);
1541
1603
  this.previousWidth = width;
@@ -1545,8 +1607,9 @@ export class TUI extends Container {
1545
1607
  const logRedraw = (reason) => {
1546
1608
  if (!debugRedraw)
1547
1609
  return;
1548
- const logPath = path.join(os.homedir(), ".senpi", "agent", "senpi-debug.log");
1610
+ const logPath = path.join(this.logDirectory, "pi-debug.log");
1549
1611
  const msg = `[${new Date().toISOString()}] fullRender: ${reason} (prev=${this.previousLines.length}, new=${newLines.length}, height=${height})\n`;
1612
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
1550
1613
  fs.appendFileSync(logPath, msg, { encoding: "utf8", mode: DIAGNOSTIC_LOG_MODE });
1551
1614
  chmodDiagnosticLogBestEffort(logPath);
1552
1615
  };
@@ -1667,12 +1730,13 @@ export class TUI extends Container {
1667
1730
  if (moveBack > 0) {
1668
1731
  buffer += `\x1b[${moveBack}A`;
1669
1732
  }
1670
- buffer += TUI.FRAME_END;
1733
+ buffer = this.finishFrame(buffer, cursorPos, newLines.length, targetRow);
1671
1734
  this.terminal.write(buffer);
1672
1735
  this.cursorRow = targetRow;
1673
- this.hardwareCursorRow = targetRow;
1674
1736
  }
1675
- this.positionHardwareCursor(cursorPos, newLines.length);
1737
+ else {
1738
+ this.positionHardwareCursor(cursorPos, newLines.length);
1739
+ }
1676
1740
  this.setPreviousLines(newLines, rawLines);
1677
1741
  this.previousKittyImageIds = this.collectKittyImageIds(newLines);
1678
1742
  this.previousWidth = width;
@@ -1736,13 +1800,12 @@ export class TUI extends Container {
1736
1800
  buffer += `\r\x1b[2K${TUI.SEGMENT_RESET}`;
1737
1801
  buffer += newLines[viewportTop + row] ?? "";
1738
1802
  }
1739
- buffer += TUI.FRAME_END;
1803
+ const finalCursorRow = viewportTop + Math.max(0, height - 1);
1804
+ buffer = this.finishFrame(buffer, cursorPos, newLines.length, finalCursorRow);
1740
1805
  this.terminal.write(buffer);
1741
1806
  this.cursorRow = Math.max(0, newLines.length - 1);
1742
- this.hardwareCursorRow = viewportTop + Math.max(0, height - 1);
1743
1807
  this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
1744
1808
  this.previousViewportTop = viewportTop;
1745
- this.positionHardwareCursor(cursorPos, newLines.length);
1746
1809
  this.setPreviousLines(newLines, rawLines);
1747
1810
  this.previousKittyImageIds = this.collectKittyImageIds(newLines);
1748
1811
  this.previousWidth = width;
@@ -1857,7 +1920,7 @@ export class TUI extends Container {
1857
1920
  // Move cursor back to end of new content
1858
1921
  buffer += `\x1b[${extraLines}A`;
1859
1922
  }
1860
- buffer += TUI.FRAME_END;
1923
+ buffer = this.finishFrame(buffer, cursorPos, newLines.length, finalCursorRow);
1861
1924
  if (process.env.PI_TUI_DEBUG === "1") {
1862
1925
  const debugDir = "/tmp/tui";
1863
1926
  fs.mkdirSync(debugDir, { recursive: true });
@@ -1893,12 +1956,9 @@ export class TUI extends Container {
1893
1956
  // cursorRow tracks end of content (for viewport calculation)
1894
1957
  // hardwareCursorRow tracks actual terminal cursor position (for movement)
1895
1958
  this.cursorRow = Math.max(0, newLines.length - 1);
1896
- this.hardwareCursorRow = finalCursorRow;
1897
1959
  // Track terminal's working area (grows but doesn't shrink unless cleared)
1898
1960
  this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
1899
1961
  this.previousViewportTop = Math.max(prevViewportTop, finalCursorRow - height + 1);
1900
- // Position hardware cursor for IME
1901
- this.positionHardwareCursor(cursorPos, newLines.length);
1902
1962
  this.setPreviousLines(newLines, rawLines);
1903
1963
  if (needsKittyImageExpansion) {
1904
1964
  this.previousKittyImageIds = this.collectKittyImageIds(newLines);
@@ -1911,7 +1971,7 @@ export class TUI extends Container {
1911
1971
  * @param cursorPos The cursor position extracted from rendered output, or null
1912
1972
  * @param totalLines Total number of rendered lines
1913
1973
  */
1914
- positionHardwareCursor(cursorPos, totalLines) {
1974
+ buildHardwareCursorSequence(cursorPos, totalLines) {
1915
1975
  let buffer = "";
1916
1976
  if (!cursorPos || totalLines <= 0) {
1917
1977
  if (this.#lastCursorVisibility !== false) {
@@ -1939,9 +1999,16 @@ export class TUI extends Container {
1939
1999
  this.#lastCursorVisibility = this.showHardwareCursor;
1940
2000
  }
1941
2001
  }
1942
- if (buffer) {
1943
- this.terminal.write(buffer);
1944
- }
2002
+ return buffer;
2003
+ }
2004
+ finishFrame(buffer, cursorPos, totalLines, hardwareCursorRow) {
2005
+ this.hardwareCursorRow = hardwareCursorRow;
2006
+ return buffer + this.buildHardwareCursorSequence(cursorPos, totalLines) + TUI.FRAME_END;
2007
+ }
2008
+ positionHardwareCursor(cursorPos, totalLines) {
2009
+ const buffer = this.buildHardwareCursorSequence(cursorPos, totalLines);
2010
+ if (buffer)
2011
+ this.terminal.write(TUI.FRAME_BEGIN + buffer + TUI.FRAME_END);
1945
2012
  }
1946
2013
  /**
1947
2014
  * Query the terminal's default background color with OSC 11 (`ESC ] 11 ; ? BEL`).
@@ -1996,4 +2063,7 @@ export class TUI extends Container {
1996
2063
  });
1997
2064
  }
1998
2065
  }
2066
+ /** Legacy main-screen renderer export. */
2067
+ export class TUI extends TuiBase {
2068
+ }
1999
2069
  //# sourceMappingURL=tui.js.map