@code-yeongyu/senpi-tui 2026.7.31 → 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 (80) 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/mux.js +1 -1
  60. package/dist/mux.js.map +1 -1
  61. package/dist/slash-command-autocomplete.d.ts.map +1 -1
  62. package/dist/slash-command-autocomplete.js +36 -8
  63. package/dist/slash-command-autocomplete.js.map +1 -1
  64. package/dist/terminal-image.d.ts +10 -0
  65. package/dist/terminal-image.d.ts.map +1 -1
  66. package/dist/terminal-image.js +61 -3
  67. package/dist/terminal-image.js.map +1 -1
  68. package/dist/terminal-text.d.ts +1 -0
  69. package/dist/terminal-text.d.ts.map +1 -1
  70. package/dist/terminal-text.js +12 -2
  71. package/dist/terminal-text.js.map +1 -1
  72. package/dist/tui.d.ts +39 -16
  73. package/dist/tui.d.ts.map +1 -1
  74. package/dist/tui.js +138 -54
  75. package/dist/tui.js.map +1 -1
  76. package/dist/utils.d.ts +10 -0
  77. package/dist/utils.d.ts.map +1 -1
  78. package/dist/utils.js +95 -4
  79. package/dist/utils.js.map +1 -1
  80. 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,15 +258,51 @@ 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 = [];
305
+ this.normalizeMemo = new Map();
270
306
  this.previousKittyImageIds = new Set();
271
307
  this.previousWidth = 0;
272
308
  this.previousHeight = 0;
@@ -299,10 +335,16 @@ export class TUI extends Container {
299
335
  // Preserve existing positional boolean callers while allowing explicit render-policy overrides.
300
336
  const normalizedOptions = typeof options === "boolean" ? { showHardwareCursor: options } : (options ?? {});
301
337
  this.#muxDetector = normalizedOptions.muxDetector ?? isMultiplexerSession;
338
+ this.logDirectory = logDirectory ?? path.join(os.homedir(), ".senpi", "agent");
302
339
  if (normalizedOptions.showHardwareCursor !== undefined) {
303
340
  this.showHardwareCursor = normalizedOptions.showHardwareCursor;
304
341
  }
305
342
  }
343
+ resetRenderState() { }
344
+ beforeTerminalStart() { }
345
+ afterTerminalStart() { }
346
+ beforeTerminalStop() { }
347
+ afterTerminalStop() { }
306
348
  get fullRedraws() {
307
349
  return this.fullRedrawCount;
308
350
  }
@@ -316,9 +358,6 @@ export class TUI extends Container {
316
358
  if (this.showHardwareCursor === enabled)
317
359
  return;
318
360
  this.showHardwareCursor = enabled;
319
- if (!enabled) {
320
- this.#setCursorVisibility(false);
321
- }
322
361
  this.requestRender();
323
362
  }
324
363
  getClearOnShrink() {
@@ -432,8 +471,11 @@ export class TUI extends Container {
432
471
  }
433
472
  }
434
473
  }
474
+ getMountedRoots() {
475
+ return this.children;
476
+ }
435
477
  isComponentMounted(component) {
436
- return this.children.some((child) => this.containsComponent(child, component));
478
+ return this.getMountedRoots().some((child) => this.containsComponent(child, component));
437
479
  }
438
480
  containsComponent(root, target) {
439
481
  if (root === target)
@@ -595,7 +637,9 @@ export class TUI extends Container {
595
637
  this.renderRequested = false;
596
638
  this.inputRenderPending = false;
597
639
  this.#lastCursorVisibility = undefined;
598
- 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();
599
643
  if (process.env.TMUX)
600
644
  this.terminal.write(ENABLE_FOCUS_REPORTING);
601
645
  this.#setCursorVisibility(false);
@@ -649,10 +693,14 @@ export class TUI extends Container {
649
693
  if (this.terminalColorSchemeNotificationsEnabled) {
650
694
  this.terminal.write("\x1b[?2031l");
651
695
  }
696
+ this.beforeTerminalStop();
652
697
  // Move cursor to the end of the content to prevent overwriting/artifacts on exit
653
698
  if (this.previousLines.length > 0) {
654
- // Overwrite the inverted cursor with a normal space to clear the artifact
655
- 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
+ }
656
704
  const targetRow = this.previousLines.length; // Line after the last content
657
705
  const lineDiff = targetRow - this.hardwareCursorRow;
658
706
  if (lineDiff > 0) {
@@ -668,6 +716,8 @@ export class TUI extends Container {
668
716
  if (process.env.TMUX)
669
717
  this.terminal.write(DISABLE_FOCUS_REPORTING);
670
718
  this.terminal.stop();
719
+ this.afterTerminalStop();
720
+ this.resetRenderState();
671
721
  this.#lastCursorVisibility = undefined;
672
722
  this.previousLines = [];
673
723
  this.previousRawLines = [];
@@ -693,6 +743,7 @@ export class TUI extends Container {
693
743
  requestRender(force = false, source = "unknown") {
694
744
  if (force) {
695
745
  this.inputRenderPending = false;
746
+ this.resetRenderState();
696
747
  this.previousLines = [];
697
748
  this.previousRawLines = [];
698
749
  this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
@@ -772,7 +823,7 @@ export class TUI extends Container {
772
823
  this.lastRenderAt = performance.now();
773
824
  this.doRender();
774
825
  }
775
- handleInput(data) {
826
+ handleTerminalInput(data) {
776
827
  const focus = consumeTmuxFocusEvent(data);
777
828
  if (focus.event !== null) {
778
829
  resetCapabilitiesCache();
@@ -1098,21 +1149,37 @@ export class TUI extends Container {
1098
1149
  if (isImageLine(line)) {
1099
1150
  return { line, normalized: false };
1100
1151
  }
1101
- return { line: normalizeTerminalOutput(line) + TUI.SEGMENT_RESET, normalized: true };
1152
+ const cached = this.normalizeMemo.get(line);
1153
+ if (cached !== undefined) {
1154
+ return { line: cached, normalized: false };
1155
+ }
1156
+ const normalized = normalizeTerminalOutput(line) + TUI.SEGMENT_RESET;
1157
+ this.normalizeMemo.set(line, normalized);
1158
+ return { line: normalized, normalized: true };
1102
1159
  }
1103
- applyLineResets(lines, mode = "full") {
1160
+ applyLineResets(lines) {
1161
+ return this.applyLineResetResult(lines).lines;
1162
+ }
1163
+ applyLineResetResult(lines, mode = "full") {
1164
+ const previousMemo = this.normalizeMemo;
1165
+ const nextMemo = new Map();
1104
1166
  const normalizedLines = [];
1105
1167
  let normalizedCount = 0;
1106
1168
  for (let i = 0; i < lines.length; i++) {
1107
1169
  const line = lines[i];
1108
1170
  if (isImageLine(line)) {
1109
1171
  normalizedLines.push(line);
1172
+ continue;
1110
1173
  }
1111
- else {
1112
- normalizedLines.push(normalizeTerminalOutput(line) + TUI.SEGMENT_RESET);
1174
+ let normalized = nextMemo.get(line) ?? previousMemo.get(line);
1175
+ if (normalized === undefined) {
1176
+ normalized = normalizeTerminalOutput(line) + TUI.SEGMENT_RESET;
1113
1177
  normalizedCount += 1;
1114
1178
  }
1179
+ nextMemo.set(line, normalized);
1180
+ normalizedLines.push(normalized);
1115
1181
  }
1182
+ this.normalizeMemo = nextMemo;
1116
1183
  recordViewportRenderStats(normalizedCount, mode);
1117
1184
  return {
1118
1185
  lines: normalizedLines,
@@ -1127,7 +1194,7 @@ export class TUI extends Container {
1127
1194
  this.previousLines.length === 0 ||
1128
1195
  this.previousLines.length !== rawLines.length ||
1129
1196
  this.previousRawLines.length !== rawLines.length) {
1130
- return this.applyLineResets(rawLines);
1197
+ return this.applyLineResetResult(rawLines);
1131
1198
  }
1132
1199
  const windowStart = Math.max(0, viewportTop - VIEWPORT_RENDER_OVERSCAN);
1133
1200
  const windowEnd = Math.min(rawLines.length, viewportTop + height + VIEWPORT_RENDER_OVERSCAN);
@@ -1140,7 +1207,7 @@ export class TUI extends Container {
1140
1207
  firstRawChanged = i;
1141
1208
  }
1142
1209
  if (i < windowStart || i >= windowEnd) {
1143
- return this.applyLineResets(rawLines, "escaped");
1210
+ return this.applyLineResetResult(rawLines, "escaped");
1144
1211
  }
1145
1212
  }
1146
1213
  const lines = this.previousLines.slice();
@@ -1309,13 +1376,12 @@ export class TUI extends Container {
1309
1376
  buffer += row.content;
1310
1377
  finalPaintedScreenRow = row.screenRow;
1311
1378
  }
1312
- buffer += TUI.FRAME_END;
1379
+ const finalCursorRow = plan.viewportTop + finalPaintedScreenRow;
1380
+ buffer = this.finishFrame(buffer, cursorPos, newLines.length, finalCursorRow);
1313
1381
  this.terminal.write(buffer);
1314
1382
  this.cursorRow = Math.max(0, newLines.length - 1);
1315
- this.hardwareCursorRow = plan.viewportTop + finalPaintedScreenRow;
1316
1383
  this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
1317
1384
  this.previousViewportTop = plan.viewportTop;
1318
- this.positionHardwareCursor(cursorPos, newLines.length);
1319
1385
  this.setPreviousLines(newLines, rawLines);
1320
1386
  this.previousKittyImageIds = this.collectKittyImageIds(newLines);
1321
1387
  this.previousWidth = width;
@@ -1338,13 +1404,11 @@ export class TUI extends Container {
1338
1404
  buffer += `\r\x1b[2K${TUI.SEGMENT_RESET}`;
1339
1405
  buffer += newLines[row] ?? "";
1340
1406
  }
1341
- buffer += TUI.FRAME_END;
1407
+ buffer = this.finishFrame(buffer, cursorPos, newLines.length, bufferLength - 1);
1342
1408
  this.terminal.write(buffer);
1343
1409
  this.cursorRow = Math.max(0, newLines.length - 1);
1344
- this.hardwareCursorRow = bufferLength - 1;
1345
1410
  this.maxLinesRendered = newLines.length;
1346
1411
  this.previousViewportTop = Math.max(0, bufferLength - height);
1347
- this.positionHardwareCursor(cursorPos, newLines.length);
1348
1412
  this.setPreviousLines(newLines, rawLines);
1349
1413
  this.previousKittyImageIds = this.collectKittyImageIds(newLines);
1350
1414
  this.previousWidth = width;
@@ -1367,14 +1431,13 @@ export class TUI extends Container {
1367
1431
  buffer += `\r\x1b[2K${TUI.SEGMENT_RESET}`;
1368
1432
  buffer += newLines[viewportTop + row] ?? "";
1369
1433
  }
1370
- buffer += TUI.FRAME_END;
1434
+ const finalCursorRow = viewportTop + Math.max(0, height - 1);
1435
+ buffer = this.finishFrame(buffer, cursorPos, newLines.length, finalCursorRow);
1371
1436
  this.terminal.write(buffer);
1372
1437
  this.muxViewportRepaintCount += 1;
1373
1438
  this.cursorRow = Math.max(0, newLines.length - 1);
1374
- this.hardwareCursorRow = viewportTop + Math.max(0, height - 1);
1375
1439
  this.maxLinesRendered = newLines.length;
1376
1440
  this.previousViewportTop = viewportTop;
1377
- this.positionHardwareCursor(cursorPos, newLines.length);
1378
1441
  this.setPreviousLines(newLines, rawLines);
1379
1442
  this.previousKittyImageIds = this.collectKittyImageIds(newLines);
1380
1443
  this.previousWidth = width;
@@ -1441,8 +1504,22 @@ export class TUI extends Container {
1441
1504
  // Calculate visual column (width of text before marker)
1442
1505
  const beforeMarker = line.slice(0, markerIndex);
1443
1506
  const col = visibleWidth(beforeMarker);
1444
- // Strip marker from the line
1445
- 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;
1446
1523
  return { row, col };
1447
1524
  }
1448
1525
  }
@@ -1508,10 +1585,10 @@ export class TUI extends Container {
1508
1585
  }
1509
1586
  buffer += line;
1510
1587
  }
1511
- buffer += TUI.FRAME_END;
1588
+ const finalCursorRow = Math.max(0, newLines.length - 1);
1589
+ buffer = this.finishFrame(buffer, cursorPos, newLines.length, finalCursorRow);
1512
1590
  this.terminal.write(buffer);
1513
1591
  this.cursorRow = Math.max(0, newLines.length - 1);
1514
- this.hardwareCursorRow = this.cursorRow;
1515
1592
  // Reset max lines when clearing, otherwise track growth
1516
1593
  if (clear) {
1517
1594
  this.maxLinesRendered = newLines.length;
@@ -1521,7 +1598,6 @@ export class TUI extends Container {
1521
1598
  }
1522
1599
  const bufferLength = Math.max(height, newLines.length);
1523
1600
  this.previousViewportTop = Math.max(0, bufferLength - height);
1524
- this.positionHardwareCursor(cursorPos, newLines.length);
1525
1601
  this.setPreviousLines(newLines, rawLines);
1526
1602
  this.previousKittyImageIds = this.collectKittyImageIds(newLines);
1527
1603
  this.previousWidth = width;
@@ -1531,8 +1607,9 @@ export class TUI extends Container {
1531
1607
  const logRedraw = (reason) => {
1532
1608
  if (!debugRedraw)
1533
1609
  return;
1534
- const logPath = path.join(os.homedir(), ".senpi", "agent", "senpi-debug.log");
1610
+ const logPath = path.join(this.logDirectory, "pi-debug.log");
1535
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 });
1536
1613
  fs.appendFileSync(logPath, msg, { encoding: "utf8", mode: DIAGNOSTIC_LOG_MODE });
1537
1614
  chmodDiagnosticLogBestEffort(logPath);
1538
1615
  };
@@ -1653,12 +1730,13 @@ export class TUI extends Container {
1653
1730
  if (moveBack > 0) {
1654
1731
  buffer += `\x1b[${moveBack}A`;
1655
1732
  }
1656
- buffer += TUI.FRAME_END;
1733
+ buffer = this.finishFrame(buffer, cursorPos, newLines.length, targetRow);
1657
1734
  this.terminal.write(buffer);
1658
1735
  this.cursorRow = targetRow;
1659
- this.hardwareCursorRow = targetRow;
1660
1736
  }
1661
- this.positionHardwareCursor(cursorPos, newLines.length);
1737
+ else {
1738
+ this.positionHardwareCursor(cursorPos, newLines.length);
1739
+ }
1662
1740
  this.setPreviousLines(newLines, rawLines);
1663
1741
  this.previousKittyImageIds = this.collectKittyImageIds(newLines);
1664
1742
  this.previousWidth = width;
@@ -1722,13 +1800,12 @@ export class TUI extends Container {
1722
1800
  buffer += `\r\x1b[2K${TUI.SEGMENT_RESET}`;
1723
1801
  buffer += newLines[viewportTop + row] ?? "";
1724
1802
  }
1725
- buffer += TUI.FRAME_END;
1803
+ const finalCursorRow = viewportTop + Math.max(0, height - 1);
1804
+ buffer = this.finishFrame(buffer, cursorPos, newLines.length, finalCursorRow);
1726
1805
  this.terminal.write(buffer);
1727
1806
  this.cursorRow = Math.max(0, newLines.length - 1);
1728
- this.hardwareCursorRow = viewportTop + Math.max(0, height - 1);
1729
1807
  this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
1730
1808
  this.previousViewportTop = viewportTop;
1731
- this.positionHardwareCursor(cursorPos, newLines.length);
1732
1809
  this.setPreviousLines(newLines, rawLines);
1733
1810
  this.previousKittyImageIds = this.collectKittyImageIds(newLines);
1734
1811
  this.previousWidth = width;
@@ -1843,7 +1920,7 @@ export class TUI extends Container {
1843
1920
  // Move cursor back to end of new content
1844
1921
  buffer += `\x1b[${extraLines}A`;
1845
1922
  }
1846
- buffer += TUI.FRAME_END;
1923
+ buffer = this.finishFrame(buffer, cursorPos, newLines.length, finalCursorRow);
1847
1924
  if (process.env.PI_TUI_DEBUG === "1") {
1848
1925
  const debugDir = "/tmp/tui";
1849
1926
  fs.mkdirSync(debugDir, { recursive: true });
@@ -1879,12 +1956,9 @@ export class TUI extends Container {
1879
1956
  // cursorRow tracks end of content (for viewport calculation)
1880
1957
  // hardwareCursorRow tracks actual terminal cursor position (for movement)
1881
1958
  this.cursorRow = Math.max(0, newLines.length - 1);
1882
- this.hardwareCursorRow = finalCursorRow;
1883
1959
  // Track terminal's working area (grows but doesn't shrink unless cleared)
1884
1960
  this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
1885
1961
  this.previousViewportTop = Math.max(prevViewportTop, finalCursorRow - height + 1);
1886
- // Position hardware cursor for IME
1887
- this.positionHardwareCursor(cursorPos, newLines.length);
1888
1962
  this.setPreviousLines(newLines, rawLines);
1889
1963
  if (needsKittyImageExpansion) {
1890
1964
  this.previousKittyImageIds = this.collectKittyImageIds(newLines);
@@ -1897,7 +1971,7 @@ export class TUI extends Container {
1897
1971
  * @param cursorPos The cursor position extracted from rendered output, or null
1898
1972
  * @param totalLines Total number of rendered lines
1899
1973
  */
1900
- positionHardwareCursor(cursorPos, totalLines) {
1974
+ buildHardwareCursorSequence(cursorPos, totalLines) {
1901
1975
  let buffer = "";
1902
1976
  if (!cursorPos || totalLines <= 0) {
1903
1977
  if (this.#lastCursorVisibility !== false) {
@@ -1925,9 +1999,16 @@ export class TUI extends Container {
1925
1999
  this.#lastCursorVisibility = this.showHardwareCursor;
1926
2000
  }
1927
2001
  }
1928
- if (buffer) {
1929
- this.terminal.write(buffer);
1930
- }
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);
1931
2012
  }
1932
2013
  /**
1933
2014
  * Query the terminal's default background color with OSC 11 (`ESC ] 11 ; ? BEL`).
@@ -1982,4 +2063,7 @@ export class TUI extends Container {
1982
2063
  });
1983
2064
  }
1984
2065
  }
2066
+ /** Legacy main-screen renderer export. */
2067
+ export class TUI extends TuiBase {
2068
+ }
1985
2069
  //# sourceMappingURL=tui.js.map