@code-yeongyu/senpi 2026.7.2 → 2026.7.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 (96) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/core/dynamic-prompt/build.d.ts +13 -0
  3. package/dist/core/dynamic-prompt/build.d.ts.map +1 -1
  4. package/dist/core/dynamic-prompt/build.js +24 -21
  5. package/dist/core/dynamic-prompt/build.js.map +1 -1
  6. package/dist/core/dynamic-prompt/exploration.d.ts.map +1 -1
  7. package/dist/core/dynamic-prompt/exploration.js +1 -3
  8. package/dist/core/dynamic-prompt/exploration.js.map +1 -1
  9. package/dist/core/dynamic-prompt/index.d.ts +1 -1
  10. package/dist/core/dynamic-prompt/index.d.ts.map +1 -1
  11. package/dist/core/dynamic-prompt/index.js.map +1 -1
  12. package/dist/core/dynamic-prompt/intent-gate.d.ts.map +1 -1
  13. package/dist/core/dynamic-prompt/intent-gate.js +20 -35
  14. package/dist/core/dynamic-prompt/intent-gate.js.map +1 -1
  15. package/dist/core/dynamic-prompt/parallel-tools.d.ts.map +1 -1
  16. package/dist/core/dynamic-prompt/parallel-tools.js +2 -4
  17. package/dist/core/dynamic-prompt/parallel-tools.js.map +1 -1
  18. package/dist/core/dynamic-prompt/style.d.ts.map +1 -1
  19. package/dist/core/dynamic-prompt/style.js +7 -26
  20. package/dist/core/dynamic-prompt/style.js.map +1 -1
  21. package/dist/core/extensions/builtin/prompt-preset/claude-fable-5.d.ts +3 -0
  22. package/dist/core/extensions/builtin/prompt-preset/claude-fable-5.d.ts.map +1 -0
  23. package/dist/core/extensions/builtin/prompt-preset/claude-fable-5.js +16 -0
  24. package/dist/core/extensions/builtin/prompt-preset/claude-fable-5.js.map +1 -0
  25. package/dist/core/extensions/builtin/prompt-preset/claude-opus-4-5.d.ts.map +1 -1
  26. package/dist/core/extensions/builtin/prompt-preset/claude-opus-4-5.js +3 -1
  27. package/dist/core/extensions/builtin/prompt-preset/claude-opus-4-5.js.map +1 -1
  28. package/dist/core/extensions/builtin/prompt-preset/claude-opus-4-6.d.ts.map +1 -1
  29. package/dist/core/extensions/builtin/prompt-preset/claude-opus-4-6.js +3 -1
  30. package/dist/core/extensions/builtin/prompt-preset/claude-opus-4-6.js.map +1 -1
  31. package/dist/core/extensions/builtin/prompt-preset/claude-opus-4-7.d.ts.map +1 -1
  32. package/dist/core/extensions/builtin/prompt-preset/claude-opus-4-7.js +6 -2
  33. package/dist/core/extensions/builtin/prompt-preset/claude-opus-4-7.js.map +1 -1
  34. package/dist/core/extensions/builtin/prompt-preset/claude-opus-4-8.d.ts.map +1 -1
  35. package/dist/core/extensions/builtin/prompt-preset/claude-opus-4-8.js +5 -3
  36. package/dist/core/extensions/builtin/prompt-preset/claude-opus-4-8.js.map +1 -1
  37. package/dist/core/extensions/builtin/prompt-preset/gpt-5.5.d.ts.map +1 -1
  38. package/dist/core/extensions/builtin/prompt-preset/gpt-5.5.js +68 -8
  39. package/dist/core/extensions/builtin/prompt-preset/gpt-5.5.js.map +1 -1
  40. package/dist/core/extensions/builtin/prompt-preset/presets.d.ts.map +1 -1
  41. package/dist/core/extensions/builtin/prompt-preset/presets.js +9 -0
  42. package/dist/core/extensions/builtin/prompt-preset/presets.js.map +1 -1
  43. package/dist/core/extensions/builtin/prompt-preset/settings.d.ts +1 -1
  44. package/dist/core/extensions/builtin/prompt-preset/settings.d.ts.map +1 -1
  45. package/dist/core/extensions/builtin/prompt-preset/settings.js +1 -0
  46. package/dist/core/extensions/builtin/prompt-preset/settings.js.map +1 -1
  47. package/dist/core/extensions/runner.d.ts.map +1 -1
  48. package/dist/core/extensions/runner.js +24 -1
  49. package/dist/core/extensions/runner.js.map +1 -1
  50. package/dist/core/model-resolver.js +5 -5
  51. package/dist/core/model-resolver.js.map +1 -1
  52. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  53. package/dist/modes/interactive/interactive-mode.js +1 -0
  54. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  55. package/docs/settings.md +1 -1
  56. package/examples/extensions/question.ts +1 -0
  57. package/node_modules/@earendil-works/pi-agent-core/package.json +2 -2
  58. package/node_modules/@earendil-works/pi-ai/dist/api/anthropic-messages.d.ts.map +1 -1
  59. package/node_modules/@earendil-works/pi-ai/dist/api/anthropic-messages.js +6 -0
  60. package/node_modules/@earendil-works/pi-ai/dist/api/anthropic-messages.js.map +1 -1
  61. package/node_modules/@earendil-works/pi-ai/dist/api/bedrock-converse-stream.js +4 -1
  62. package/node_modules/@earendil-works/pi-ai/dist/api/bedrock-converse-stream.js.map +1 -1
  63. package/node_modules/@earendil-works/pi-ai/dist/models.generated.d.ts +98 -0
  64. package/node_modules/@earendil-works/pi-ai/dist/models.generated.d.ts.map +1 -1
  65. package/node_modules/@earendil-works/pi-ai/dist/providers/github-copilot.models.d.ts +56 -0
  66. package/node_modules/@earendil-works/pi-ai/dist/providers/github-copilot.models.d.ts.map +1 -1
  67. package/node_modules/@earendil-works/pi-ai/dist/providers/github-copilot.models.js +38 -0
  68. package/node_modules/@earendil-works/pi-ai/dist/providers/github-copilot.models.js.map +1 -1
  69. package/node_modules/@earendil-works/pi-ai/dist/providers/openrouter.models.d.ts +42 -0
  70. package/node_modules/@earendil-works/pi-ai/dist/providers/openrouter.models.d.ts.map +1 -1
  71. package/node_modules/@earendil-works/pi-ai/dist/providers/openrouter.models.js +46 -10
  72. package/node_modules/@earendil-works/pi-ai/dist/providers/openrouter.models.js.map +1 -1
  73. package/node_modules/@earendil-works/pi-ai/dist/providers/together.models.js +2 -2
  74. package/node_modules/@earendil-works/pi-ai/dist/providers/together.models.js.map +1 -1
  75. package/node_modules/@earendil-works/pi-ai/dist/providers/vercel-ai-gateway.models.js +4 -4
  76. package/node_modules/@earendil-works/pi-ai/dist/providers/vercel-ai-gateway.models.js.map +1 -1
  77. package/node_modules/@earendil-works/pi-ai/dist/utils/overflow.d.ts +1 -0
  78. package/node_modules/@earendil-works/pi-ai/dist/utils/overflow.d.ts.map +1 -1
  79. package/node_modules/@earendil-works/pi-ai/dist/utils/overflow.js +3 -0
  80. package/node_modules/@earendil-works/pi-ai/dist/utils/overflow.js.map +1 -1
  81. package/node_modules/@earendil-works/pi-ai/package.json +1 -1
  82. package/node_modules/@earendil-works/pi-tui/dist/mux.d.ts +4 -0
  83. package/node_modules/@earendil-works/pi-tui/dist/mux.d.ts.map +1 -0
  84. package/node_modules/@earendil-works/pi-tui/dist/mux.js +10 -0
  85. package/node_modules/@earendil-works/pi-tui/dist/mux.js.map +1 -0
  86. package/node_modules/@earendil-works/pi-tui/dist/tui.d.ts +29 -1
  87. package/node_modules/@earendil-works/pi-tui/dist/tui.d.ts.map +1 -1
  88. package/node_modules/@earendil-works/pi-tui/dist/tui.js +397 -89
  89. package/node_modules/@earendil-works/pi-tui/dist/tui.js.map +1 -1
  90. package/node_modules/@earendil-works/pi-tui/dist/utils.d.ts +10 -0
  91. package/node_modules/@earendil-works/pi-tui/dist/utils.d.ts.map +1 -1
  92. package/node_modules/@earendil-works/pi-tui/dist/utils.js +53 -13
  93. package/node_modules/@earendil-works/pi-tui/dist/utils.js.map +1 -1
  94. package/node_modules/@earendil-works/pi-tui/package.json +2 -1
  95. package/npm-shrinkwrap.json +9 -9
  96. package/package.json +4 -4
@@ -6,6 +6,7 @@ import * as os from "node:os";
6
6
  import * as path from "node:path";
7
7
  import { performance } from "node:perf_hooks";
8
8
  import { isKeyRelease, matchesKey } from "./keys.js";
9
+ import { isMultiplexerSession, useLegacyMuxRender, viewportRenderEnabled } from "./mux.js";
9
10
  import { isOsc11BackgroundColorResponse, parseOsc11BackgroundColor, parseTerminalColorSchemeReport, } from "./terminal-colors.js";
10
11
  import { deleteKittyImage, getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.js";
11
12
  import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.js";
@@ -55,6 +56,103 @@ export function isFocusable(component) {
55
56
  */
56
57
  export const CURSOR_MARKER = "\x1b_pi:c\x07";
57
58
  export { visibleWidth };
59
+ const renderErrorLoggedClasses = new Set();
60
+ let renderErrorLogWrites = 0;
61
+ const DIAGNOSTIC_LOG_MODE = 0o600;
62
+ const VIEWPORT_RENDER_OVERSCAN = 16;
63
+ // Keep scroll-region wins cheap when a few visible rows mutate during append streaming.
64
+ const MAX_SCROLL_DIFF_ROWS = 4;
65
+ const viewportRenderStats = {
66
+ lastNormalizedLines: 0,
67
+ lastKittyImageScannedLines: 0,
68
+ totalNormalizedLines: 0,
69
+ totalKittyImageScannedLines: 0,
70
+ boundedFrames: 0,
71
+ escapedFrames: 0,
72
+ fullFrames: 0,
73
+ };
74
+ export function __renderErrorLogStats() {
75
+ if (process.env.PI_TUI_TEST_SEAMS !== "1") {
76
+ return undefined;
77
+ }
78
+ return { writes: renderErrorLogWrites };
79
+ }
80
+ export function __viewportRenderStats() {
81
+ if (process.env.PI_TUI_TEST_SEAMS !== "1") {
82
+ return undefined;
83
+ }
84
+ return { ...viewportRenderStats };
85
+ }
86
+ function recordViewportRenderStats(normalizedLines, mode) {
87
+ viewportRenderStats.lastNormalizedLines = normalizedLines;
88
+ viewportRenderStats.totalNormalizedLines += normalizedLines;
89
+ if (mode === "bounded") {
90
+ viewportRenderStats.boundedFrames += 1;
91
+ }
92
+ else if (mode === "escaped") {
93
+ viewportRenderStats.escapedFrames += 1;
94
+ }
95
+ else {
96
+ viewportRenderStats.fullFrames += 1;
97
+ }
98
+ }
99
+ function recordKittyImageScanStats(scannedLines) {
100
+ viewportRenderStats.lastKittyImageScannedLines = scannedLines;
101
+ viewportRenderStats.totalKittyImageScannedLines += scannedLines;
102
+ }
103
+ function componentRenderErrorName(component) {
104
+ return component.constructor.name || "AnonymousComponent";
105
+ }
106
+ function logRenderErrorOnce(component, error) {
107
+ const componentName = componentRenderErrorName(component);
108
+ if (renderErrorLoggedClasses.has(componentName)) {
109
+ return;
110
+ }
111
+ renderErrorLoggedClasses.add(componentName);
112
+ renderErrorLogWrites += 1;
113
+ const errorText = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
114
+ const logPath = path.join(os.homedir(), ".senpi", "agent", "senpi-debug.log");
115
+ const msg = `[${new Date().toISOString()}] render error: ${componentName}: ${errorText}\n`;
116
+ appendRenderErrorLogBestEffort(logPath, msg);
117
+ }
118
+ function appendRenderErrorLogBestEffort(logPath, msg) {
119
+ try {
120
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
121
+ fs.appendFileSync(logPath, msg, { encoding: "utf8", mode: DIAGNOSTIC_LOG_MODE });
122
+ chmodDiagnosticLogBestEffort(logPath);
123
+ }
124
+ catch (error) {
125
+ if (error instanceof Error) {
126
+ return;
127
+ }
128
+ throw error;
129
+ }
130
+ }
131
+ function writeRenderDiagnosticBestEffort(logPath, data) {
132
+ try {
133
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
134
+ fs.writeFileSync(logPath, data, { encoding: "utf8", mode: DIAGNOSTIC_LOG_MODE });
135
+ chmodDiagnosticLogBestEffort(logPath);
136
+ return true;
137
+ }
138
+ catch (error) {
139
+ if (error instanceof Error) {
140
+ return false;
141
+ }
142
+ throw error;
143
+ }
144
+ }
145
+ function chmodDiagnosticLogBestEffort(logPath) {
146
+ try {
147
+ fs.chmodSync(logPath, DIAGNOSTIC_LOG_MODE);
148
+ }
149
+ catch (error) {
150
+ if (error instanceof Error) {
151
+ return;
152
+ }
153
+ throw error;
154
+ }
155
+ }
58
156
  /** Parse a SizeValue into absolute value given a reference size */
59
157
  function parseSizeValue(value, referenceSize) {
60
158
  if (value === undefined)
@@ -120,7 +218,16 @@ export class Container {
120
218
  render(width) {
121
219
  const lines = [];
122
220
  for (const child of this.children) {
123
- const childLines = child.render(width);
221
+ let childLines;
222
+ try {
223
+ childLines = child.render(width);
224
+ }
225
+ catch (error) {
226
+ logRenderErrorOnce(child, error);
227
+ const componentName = componentRenderErrorName(child);
228
+ // Focus ownership stays unchanged; render containment must not steal or clear focus implicitly.
229
+ childLines = [`[render error: ${componentName}]`];
230
+ }
124
231
  for (const line of childLines) {
125
232
  lines.push(line);
126
233
  }
@@ -133,9 +240,12 @@ export class Container {
133
240
  */
134
241
  export class TUI extends Container {
135
242
  static { this.MIN_RENDER_INTERVAL_MS = 16; }
136
- constructor(terminal, showHardwareCursor) {
243
+ #lastCursorVisibility;
244
+ #muxDetector;
245
+ constructor(terminal, options) {
137
246
  super();
138
247
  this.previousLines = [];
248
+ this.previousRawLines = [];
139
249
  this.previousKittyImageIds = new Set();
140
250
  this.previousWidth = 0;
141
251
  this.previousHeight = 0;
@@ -151,6 +261,8 @@ export class TUI extends Container {
151
261
  this.maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered)
152
262
  this.previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves
153
263
  this.fullRedrawCount = 0;
264
+ this.muxViewportRepaintCount = 0;
265
+ this.overWideCrashDumpWritten = false;
154
266
  this.stopped = false;
155
267
  this.pendingOsc11BackgroundReplies = 0;
156
268
  this.pendingOsc11BackgroundQueries = [];
@@ -161,13 +273,19 @@ export class TUI extends Container {
161
273
  this.overlayStack = [];
162
274
  this.overlayFocusRestore = { status: "inactive" };
163
275
  this.terminal = terminal;
164
- if (showHardwareCursor !== undefined) {
165
- this.showHardwareCursor = showHardwareCursor;
276
+ // Preserve existing positional boolean callers while allowing explicit render-policy overrides.
277
+ const normalizedOptions = typeof options === "boolean" ? { showHardwareCursor: options } : (options ?? {});
278
+ this.#muxDetector = normalizedOptions.muxDetector ?? isMultiplexerSession;
279
+ if (normalizedOptions.showHardwareCursor !== undefined) {
280
+ this.showHardwareCursor = normalizedOptions.showHardwareCursor;
166
281
  }
167
282
  }
168
283
  get fullRedraws() {
169
284
  return this.fullRedrawCount;
170
285
  }
286
+ get muxViewportRepaints() {
287
+ return this.muxViewportRepaintCount;
288
+ }
171
289
  getShowHardwareCursor() {
172
290
  return this.showHardwareCursor;
173
291
  }
@@ -176,7 +294,7 @@ export class TUI extends Container {
176
294
  return;
177
295
  this.showHardwareCursor = enabled;
178
296
  if (!enabled) {
179
- this.terminal.hideCursor();
297
+ this.#setCursorVisibility(false);
180
298
  }
181
299
  this.requestRender();
182
300
  }
@@ -318,7 +436,7 @@ export class TUI extends Container {
318
436
  if (!options?.nonCapturing && this.isOverlayVisible(entry)) {
319
437
  this.setFocus(component);
320
438
  }
321
- this.terminal.hideCursor();
439
+ this.#setCursorVisibility(false);
322
440
  this.requestRender();
323
441
  // Return handle for controlling this overlay
324
442
  return {
@@ -334,7 +452,7 @@ export class TUI extends Container {
334
452
  this.setFocus(topVisible?.component ?? entry.preFocus);
335
453
  }
336
454
  if (this.overlayStack.length === 0)
337
- this.terminal.hideCursor();
455
+ this.#setCursorVisibility(false);
338
456
  this.requestRender();
339
457
  }
340
458
  },
@@ -416,7 +534,7 @@ export class TUI extends Container {
416
534
  this.setFocus(topVisible?.component ?? overlay.preFocus);
417
535
  }
418
536
  if (this.overlayStack.length === 0)
419
- this.terminal.hideCursor();
537
+ this.#setCursorVisibility(false);
420
538
  this.requestRender();
421
539
  }
422
540
  /** Check if there are any visible overlays */
@@ -451,8 +569,9 @@ export class TUI extends Container {
451
569
  }
452
570
  start() {
453
571
  this.stopped = false;
572
+ this.#lastCursorVisibility = undefined;
454
573
  this.terminal.start((data) => this.handleInput(data), () => this.requestRender());
455
- this.terminal.hideCursor();
574
+ this.#setCursorVisibility(false);
456
575
  if (this.terminalColorSchemeNotificationsEnabled) {
457
576
  this.terminal.write("\x1b[?2031h");
458
577
  }
@@ -513,9 +632,12 @@ export class TUI extends Container {
513
632
  }
514
633
  this.terminal.write("\r\n");
515
634
  }
516
- this.terminal.showCursor();
635
+ this.#lastCursorVisibility = undefined;
636
+ this.#setCursorVisibility(true);
517
637
  this.terminal.stop();
638
+ this.#lastCursorVisibility = undefined;
518
639
  this.previousLines = [];
640
+ this.previousRawLines = [];
519
641
  this.previousKittyImageIds.clear();
520
642
  this.previousWidth = 0;
521
643
  this.previousHeight = 0;
@@ -524,10 +646,22 @@ export class TUI extends Container {
524
646
  this.maxLinesRendered = 0;
525
647
  this.previousViewportTop = 0;
526
648
  }
649
+ #setCursorVisibility(visible) {
650
+ if (this.#lastCursorVisibility === visible)
651
+ return;
652
+ if (visible) {
653
+ this.terminal.showCursor();
654
+ }
655
+ else {
656
+ this.terminal.hideCursor();
657
+ }
658
+ this.#lastCursorVisibility = visible;
659
+ }
527
660
  requestRender(force = false, source = "unknown") {
528
661
  if (force) {
529
662
  this.inputRenderPending = false;
530
663
  this.previousLines = [];
664
+ this.previousRawLines = [];
531
665
  this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
532
666
  this.previousHeight = -1; // -1 triggers heightChanged, forcing a full clear
533
667
  this.cursorRow = 0;
@@ -905,17 +1039,83 @@ export class TUI extends Container {
905
1039
  */
906
1040
  static { this.FRAME_BEGIN = "\x1b[?2026h\x1b[?7l"; }
907
1041
  static { this.FRAME_END = "\x1b[?7h\x1b[?2026l"; }
908
- applyLineResets(lines) {
909
- const reset = TUI.SEGMENT_RESET;
1042
+ setPreviousLines(lines, rawLines) {
1043
+ this.previousLines = lines;
1044
+ this.previousRawLines = rawLines;
1045
+ }
1046
+ normalizeLine(line) {
1047
+ if (isImageLine(line)) {
1048
+ return { line, normalized: false };
1049
+ }
1050
+ return { line: normalizeTerminalOutput(line) + TUI.SEGMENT_RESET, normalized: true };
1051
+ }
1052
+ applyLineResets(lines, mode = "full") {
1053
+ const normalizedLines = [];
1054
+ let normalizedCount = 0;
910
1055
  for (let i = 0; i < lines.length; i++) {
911
1056
  const line = lines[i];
912
- if (!isImageLine(line)) {
913
- lines[i] = normalizeTerminalOutput(line) + reset;
1057
+ if (isImageLine(line)) {
1058
+ normalizedLines.push(line);
1059
+ }
1060
+ else {
1061
+ normalizedLines.push(normalizeTerminalOutput(line) + TUI.SEGMENT_RESET);
1062
+ normalizedCount += 1;
914
1063
  }
915
1064
  }
916
- return lines;
1065
+ recordViewportRenderStats(normalizedCount, mode);
1066
+ return {
1067
+ lines: normalizedLines,
1068
+ firstRawChanged: 0,
1069
+ compareEndExclusive: normalizedLines.length,
1070
+ bounded: false,
1071
+ };
1072
+ }
1073
+ applyViewportLineResets(rawLines, viewportTop, height, stableDimensions) {
1074
+ if (!viewportRenderEnabled() ||
1075
+ !stableDimensions ||
1076
+ this.previousLines.length === 0 ||
1077
+ this.previousLines.length !== rawLines.length ||
1078
+ this.previousRawLines.length !== rawLines.length) {
1079
+ return this.applyLineResets(rawLines);
1080
+ }
1081
+ const windowStart = Math.max(0, viewportTop - VIEWPORT_RENDER_OVERSCAN);
1082
+ const windowEnd = Math.min(rawLines.length, viewportTop + height + VIEWPORT_RENDER_OVERSCAN);
1083
+ let firstRawChanged = -1;
1084
+ for (let i = 0; i < rawLines.length; i++) {
1085
+ if (rawLines[i] === this.previousRawLines[i]) {
1086
+ continue;
1087
+ }
1088
+ if (firstRawChanged === -1) {
1089
+ firstRawChanged = i;
1090
+ }
1091
+ if (i < windowStart || i >= windowEnd) {
1092
+ return this.applyLineResets(rawLines, "escaped");
1093
+ }
1094
+ }
1095
+ const lines = this.previousLines.slice();
1096
+ let normalizedCount = 0;
1097
+ if (firstRawChanged !== -1) {
1098
+ for (let i = windowStart; i < windowEnd; i++) {
1099
+ if (rawLines[i] === this.previousRawLines[i]) {
1100
+ continue;
1101
+ }
1102
+ const normalized = this.normalizeLine(rawLines[i] ?? "");
1103
+ lines[i] = normalized.line;
1104
+ if (normalized.normalized) {
1105
+ normalizedCount += 1;
1106
+ }
1107
+ }
1108
+ }
1109
+ recordViewportRenderStats(normalizedCount, "bounded");
1110
+ return {
1111
+ lines,
1112
+ firstRawChanged,
1113
+ compareEndExclusive: windowEnd,
1114
+ bounded: true,
1115
+ };
917
1116
  }
918
1117
  collectKittyImageIds(lines) {
1118
+ recordKittyImageScanStats(lines.length);
919
1119
  const ids = new Set();
920
1120
  for (const line of lines) {
921
1121
  for (const id of extractKittyImageIds(line)) {
@@ -963,6 +1163,24 @@ export class TUI extends Container {
963
1163
  expandForLines(newLines);
964
1164
  return { firstChanged: expandedFirstChanged, lastChanged: expandedLastChanged };
965
1165
  }
1166
+ changedRangeNeedsKittyImageExpansion(lines, firstChanged, lastChanged) {
1167
+ if (this.previousKittyImageIds.size > 0) {
1168
+ return true;
1169
+ }
1170
+ const start = Math.max(0, firstChanged);
1171
+ const end = Math.min(lines.length - 1, lastChanged);
1172
+ let scannedLines = 0;
1173
+ for (let i = start; i <= end; i++) {
1174
+ scannedLines += 1;
1175
+ const line = lines[i] ?? "";
1176
+ if (isImageLine(line) || extractKittyImageIds(line).length > 0) {
1177
+ recordKittyImageScanStats(scannedLines);
1178
+ return true;
1179
+ }
1180
+ }
1181
+ recordKittyImageScanStats(scannedLines);
1182
+ return false;
1183
+ }
966
1184
  deleteChangedKittyImages(firstChanged, lastChanged) {
967
1185
  if (firstChanged < 0 || lastChanged < firstChanged)
968
1186
  return "";
@@ -978,6 +1196,9 @@ export class TUI extends Container {
978
1196
  getViewportRows(lines, viewportTop, height) {
979
1197
  return Array.from({ length: height }, (_, row) => lines[viewportTop + row] ?? "");
980
1198
  }
1199
+ shouldPreserveMuxScrollback() {
1200
+ return this.#muxDetector() && !useLegacyMuxRender();
1201
+ }
981
1202
  createViewportInsertScrollPlan(newLines, prevViewportTop, height, lineCountDelta) {
982
1203
  if (lineCountDelta <= 0 || lineCountDelta >= height || this.overlayStack.length > 0) {
983
1204
  return undefined;
@@ -1001,18 +1222,23 @@ export class TUI extends Container {
1001
1222
  if (regionHeight <= 0 || regionHeight < lineCountDelta) {
1002
1223
  return undefined;
1003
1224
  }
1225
+ const mutatedRows = [];
1004
1226
  for (let row = 0; row < regionHeight - lineCountDelta; row++) {
1005
1227
  if (previousVisible[row + lineCountDelta] !== nextVisible[row]) {
1006
- return undefined;
1228
+ mutatedRows.push({ screenRow: row, content: nextVisible[row] });
1229
+ if (mutatedRows.length > MAX_SCROLL_DIFF_ROWS) {
1230
+ return undefined;
1231
+ }
1007
1232
  }
1008
1233
  }
1009
1234
  return {
1010
1235
  viewportTop,
1011
1236
  regionBottom: regionHeight - 1,
1012
1237
  insertedRows: nextVisible.slice(regionHeight - lineCountDelta, regionHeight),
1238
+ mutatedRows,
1013
1239
  };
1014
1240
  }
1015
- renderViewportInsertScroll(plan, newLines, cursorPos, width, height) {
1241
+ renderViewportInsertScroll(plan, newLines, rawLines, cursorPos, width, height) {
1016
1242
  let buffer = TUI.FRAME_BEGIN;
1017
1243
  const regionTop = 0;
1018
1244
  const regionBottom = plan.regionBottom;
@@ -1021,27 +1247,35 @@ export class TUI extends Container {
1021
1247
  buffer += "\n".repeat(plan.insertedRows.length);
1022
1248
  buffer += "\x1b[r";
1023
1249
  const firstInsertedScreenRow = regionBottom - plan.insertedRows.length + 1;
1250
+ let finalPaintedScreenRow = firstInsertedScreenRow + plan.insertedRows.length - 1;
1024
1251
  for (let index = 0; index < plan.insertedRows.length; index++) {
1025
1252
  const screenRow = firstInsertedScreenRow + index;
1026
- buffer += `\x1b[${screenRow + 1};1H\x1b[2K`;
1253
+ buffer += `\x1b[${screenRow + 1};1H\x1b[2K${TUI.SEGMENT_RESET}`;
1027
1254
  buffer += plan.insertedRows[index] ?? "";
1028
1255
  }
1256
+ for (const row of plan.mutatedRows) {
1257
+ buffer += `\x1b[${row.screenRow + 1};1H\x1b[2K${TUI.SEGMENT_RESET}`;
1258
+ buffer += row.content;
1259
+ finalPaintedScreenRow = row.screenRow;
1260
+ }
1029
1261
  buffer += TUI.FRAME_END;
1030
1262
  this.terminal.write(buffer);
1031
1263
  this.cursorRow = Math.max(0, newLines.length - 1);
1032
- this.hardwareCursorRow = plan.viewportTop + firstInsertedScreenRow + plan.insertedRows.length - 1;
1264
+ this.hardwareCursorRow = plan.viewportTop + finalPaintedScreenRow;
1033
1265
  this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
1034
1266
  this.previousViewportTop = plan.viewportTop;
1035
1267
  this.positionHardwareCursor(cursorPos, newLines.length);
1036
- this.previousLines = newLines;
1268
+ this.setPreviousLines(newLines, rawLines);
1037
1269
  this.previousKittyImageIds = this.collectKittyImageIds(newLines);
1038
1270
  this.previousWidth = width;
1039
1271
  this.previousHeight = height;
1040
1272
  }
1041
- renderScrollbackReplay(newLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow) {
1273
+ renderScrollbackReplay(newLines, rawLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow) {
1042
1274
  let buffer = TUI.FRAME_BEGIN;
1043
1275
  buffer += this.deleteKittyImages(this.previousKittyImageIds);
1044
- buffer += "\x1b[3J";
1276
+ if (!this.shouldPreserveMuxScrollback()) {
1277
+ buffer += "\x1b[3J";
1278
+ }
1045
1279
  const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));
1046
1280
  if (currentScreenRow > 0) {
1047
1281
  buffer += `\x1b[${currentScreenRow}A`;
@@ -1050,7 +1284,7 @@ export class TUI extends Container {
1050
1284
  for (let row = 0; row < bufferLength; row++) {
1051
1285
  if (row > 0)
1052
1286
  buffer += "\r\n";
1053
- buffer += "\r\x1b[2K";
1287
+ buffer += `\r\x1b[2K${TUI.SEGMENT_RESET}`;
1054
1288
  buffer += newLines[row] ?? "";
1055
1289
  }
1056
1290
  buffer += TUI.FRAME_END;
@@ -1060,11 +1294,42 @@ export class TUI extends Container {
1060
1294
  this.maxLinesRendered = newLines.length;
1061
1295
  this.previousViewportTop = Math.max(0, bufferLength - height);
1062
1296
  this.positionHardwareCursor(cursorPos, newLines.length);
1063
- this.previousLines = newLines;
1297
+ this.setPreviousLines(newLines, rawLines);
1064
1298
  this.previousKittyImageIds = this.collectKittyImageIds(newLines);
1065
1299
  this.previousWidth = width;
1066
1300
  this.previousHeight = height;
1067
1301
  }
1302
+ renderMuxViewportRepaint(newLines, rawLines, cursorPos, width, height, viewportTop = Math.max(0, newLines.length - height)) {
1303
+ const previousVisible = this.getViewportRows(this.previousLines, this.previousViewportTop, height);
1304
+ const nextVisible = this.getViewportRows(newLines, viewportTop, height);
1305
+ if (previousVisible.some(isImageLine) || nextVisible.some(isImageLine)) {
1306
+ return false;
1307
+ }
1308
+ let buffer = TUI.FRAME_BEGIN;
1309
+ const currentScreenRow = Math.max(0, Math.min(height - 1, this.hardwareCursorRow - this.previousViewportTop));
1310
+ if (currentScreenRow > 0) {
1311
+ buffer += `\x1b[${currentScreenRow}A`;
1312
+ }
1313
+ for (let row = 0; row < height; row++) {
1314
+ if (row > 0)
1315
+ buffer += "\r\n";
1316
+ buffer += `\r\x1b[2K${TUI.SEGMENT_RESET}`;
1317
+ buffer += newLines[viewportTop + row] ?? "";
1318
+ }
1319
+ buffer += TUI.FRAME_END;
1320
+ this.terminal.write(buffer);
1321
+ this.muxViewportRepaintCount += 1;
1322
+ this.cursorRow = Math.max(0, newLines.length - 1);
1323
+ this.hardwareCursorRow = viewportTop + Math.max(0, height - 1);
1324
+ this.maxLinesRendered = newLines.length;
1325
+ this.previousViewportTop = viewportTop;
1326
+ this.positionHardwareCursor(cursorPos, newLines.length);
1327
+ this.setPreviousLines(newLines, rawLines);
1328
+ this.previousKittyImageIds = this.collectKittyImageIds(newLines);
1329
+ this.previousWidth = width;
1330
+ this.previousHeight = height;
1331
+ return true;
1332
+ }
1068
1333
  /** Splice overlay content into a base line at a specific column. Single-pass optimized. */
1069
1334
  compositeLineAt(baseLine, overlayLine, startCol, overlayWidth, totalWidth) {
1070
1335
  if (isImageLine(baseLine))
@@ -1153,17 +1418,23 @@ export class TUI extends Container {
1153
1418
  }
1154
1419
  // Extract cursor position before applying line resets (marker must be found first)
1155
1420
  const cursorPos = this.extractCursorPosition(newLines, height);
1156
- newLines = this.applyLineResets(newLines);
1421
+ const rawLines = newLines;
1422
+ const normalizedLines = this.applyViewportLineResets(rawLines, prevViewportTop, height, !widthChanged && !heightChanged);
1423
+ newLines = normalizedLines.lines;
1424
+ const preserveMuxScrollback = this.shouldPreserveMuxScrollback();
1157
1425
  // Helper to clear scrollback and viewport and render all new lines
1158
- const fullRender = (clear) => {
1426
+ const fullRender = (clear, clearScrollback = clear) => {
1159
1427
  this.fullRedrawCount += 1;
1160
1428
  let buffer = TUI.FRAME_BEGIN;
1161
1429
  if (clear) {
1162
1430
  buffer += this.deleteKittyImages(this.previousKittyImageIds);
1163
- buffer += "\x1b[2J\x1b[H\x1b[3J"; // Clear screen, home, then clear scrollback
1431
+ buffer += "\x1b[2J\x1b[H";
1432
+ if (clearScrollback && !preserveMuxScrollback) {
1433
+ buffer += "\x1b[3J";
1434
+ }
1164
1435
  }
1165
1436
  else {
1166
- buffer += "\r\x1b[2K";
1437
+ buffer += `\r\x1b[2K${TUI.SEGMENT_RESET}`;
1167
1438
  }
1168
1439
  for (let i = 0; i < newLines.length; i++) {
1169
1440
  if (i > 0)
@@ -1197,7 +1468,7 @@ export class TUI extends Container {
1197
1468
  const bufferLength = Math.max(height, newLines.length);
1198
1469
  this.previousViewportTop = Math.max(0, bufferLength - height);
1199
1470
  this.positionHardwareCursor(cursorPos, newLines.length);
1200
- this.previousLines = newLines;
1471
+ this.setPreviousLines(newLines, rawLines);
1201
1472
  this.previousKittyImageIds = this.collectKittyImageIds(newLines);
1202
1473
  this.previousWidth = width;
1203
1474
  this.previousHeight = height;
@@ -1208,7 +1479,8 @@ export class TUI extends Container {
1208
1479
  return;
1209
1480
  const logPath = path.join(os.homedir(), ".senpi", "agent", "senpi-debug.log");
1210
1481
  const msg = `[${new Date().toISOString()}] fullRender: ${reason} (prev=${this.previousLines.length}, new=${newLines.length}, height=${height})\n`;
1211
- fs.appendFileSync(logPath, msg);
1482
+ fs.appendFileSync(logPath, msg, { encoding: "utf8", mode: DIAGNOSTIC_LOG_MODE });
1483
+ chmodDiagnosticLogBestEffort(logPath);
1212
1484
  };
1213
1485
  // First render - just output everything without clearing (assumes clean screen)
1214
1486
  if (this.previousLines.length === 0 && !widthChanged && !heightChanged) {
@@ -1219,7 +1491,8 @@ export class TUI extends Container {
1219
1491
  // Width changes always need a full re-render because wrapping changes.
1220
1492
  if (widthChanged) {
1221
1493
  logRedraw(`terminal width changed (${this.previousWidth} -> ${width})`);
1222
- fullRender(true);
1494
+ // In multiplexers, re-emit the viewport without 3J so pane history survives; an older copy may remain above.
1495
+ fullRender(true, !preserveMuxScrollback);
1223
1496
  return;
1224
1497
  }
1225
1498
  // Height changes normally need a full re-render to keep the visible viewport aligned,
@@ -1227,7 +1500,14 @@ export class TUI extends Container {
1227
1500
  // In that environment, a full redraw causes the entire history to replay on every toggle.
1228
1501
  if (heightChanged && !isTermuxSession()) {
1229
1502
  logRedraw(`terminal height changed (${this.previousHeight} -> ${height})`);
1230
- fullRender(true);
1503
+ if (preserveMuxScrollback) {
1504
+ if (!this.renderMuxViewportRepaint(newLines, rawLines, cursorPos, width, height)) {
1505
+ fullRender(true, false);
1506
+ }
1507
+ }
1508
+ else {
1509
+ fullRender(true);
1510
+ }
1231
1511
  return;
1232
1512
  }
1233
1513
  // Content shrunk below the working area and no overlays - re-render to clear empty rows
@@ -1235,14 +1515,16 @@ export class TUI extends Container {
1235
1515
  // Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var
1236
1516
  if (this.clearOnShrink && newLines.length < this.maxLinesRendered && this.overlayStack.length === 0) {
1237
1517
  logRedraw(`clearOnShrink (maxLinesRendered=${this.maxLinesRendered})`);
1238
- fullRender(true);
1518
+ fullRender(true, !preserveMuxScrollback);
1239
1519
  return;
1240
1520
  }
1241
1521
  // Find first and last changed lines
1242
1522
  let firstChanged = -1;
1243
1523
  let lastChanged = -1;
1244
1524
  const maxLines = Math.max(newLines.length, this.previousLines.length);
1245
- for (let i = 0; i < maxLines; i++) {
1525
+ const diffScanStart = normalizedLines.bounded && normalizedLines.firstRawChanged !== -1 ? normalizedLines.firstRawChanged : 0;
1526
+ const diffScanEndExclusive = normalizedLines.bounded ? normalizedLines.compareEndExclusive : maxLines;
1527
+ for (let i = diffScanStart; i < diffScanEndExclusive; i++) {
1246
1528
  const oldLine = i < this.previousLines.length ? this.previousLines[i] : "";
1247
1529
  const newLine = i < newLines.length ? newLines[i] : "";
1248
1530
  if (oldLine !== newLine) {
@@ -1260,7 +1542,8 @@ export class TUI extends Container {
1260
1542
  }
1261
1543
  lastChanged = newLines.length - 1;
1262
1544
  }
1263
- if (firstChanged !== -1) {
1545
+ const needsKittyImageExpansion = firstChanged !== -1 && this.changedRangeNeedsKittyImageExpansion(newLines, firstChanged, lastChanged);
1546
+ if (needsKittyImageExpansion) {
1264
1547
  const expandedRange = this.expandChangedRangeForKittyImages(firstChanged, lastChanged, newLines);
1265
1548
  firstChanged = expandedRange.firstChanged;
1266
1549
  lastChanged = expandedRange.lastChanged;
@@ -1275,7 +1558,7 @@ export class TUI extends Container {
1275
1558
  return;
1276
1559
  }
1277
1560
  if (insertScrollPlan) {
1278
- this.renderViewportInsertScroll(insertScrollPlan, newLines, cursorPos, width, height);
1561
+ this.renderViewportInsertScroll(insertScrollPlan, newLines, rawLines, cursorPos, width, height);
1279
1562
  return;
1280
1563
  }
1281
1564
  // All changes are in deleted lines (nothing to render, just clear)
@@ -1287,7 +1570,7 @@ export class TUI extends Container {
1287
1570
  const targetRow = Math.max(0, newLines.length - 1);
1288
1571
  if (targetRow < prevViewportTop) {
1289
1572
  logRedraw(`deleted lines moved viewport up (${targetRow} < ${prevViewportTop})`);
1290
- fullRender(true);
1573
+ fullRender(true, !preserveMuxScrollback);
1291
1574
  return;
1292
1575
  }
1293
1576
  const lineDiff = computeLineDiff(targetRow);
@@ -1300,7 +1583,7 @@ export class TUI extends Container {
1300
1583
  const extraLines = this.previousLines.length - newLines.length;
1301
1584
  if (extraLines > height) {
1302
1585
  logRedraw(`extraLines > height (${extraLines} > ${height})`);
1303
- fullRender(true);
1586
+ fullRender(true, !preserveMuxScrollback);
1304
1587
  return;
1305
1588
  }
1306
1589
  const clearStartOffset = newLines.length === 0 ? 0 : 1;
@@ -1308,7 +1591,7 @@ export class TUI extends Container {
1308
1591
  buffer += `\x1b[${clearStartOffset}B`;
1309
1592
  }
1310
1593
  for (let i = 0; i < extraLines; i++) {
1311
- buffer += "\r\x1b[2K";
1594
+ buffer += `\r\x1b[2K${TUI.SEGMENT_RESET}`;
1312
1595
  if (i < extraLines - 1)
1313
1596
  buffer += "\x1b[1B";
1314
1597
  }
@@ -1322,7 +1605,7 @@ export class TUI extends Container {
1322
1605
  this.hardwareCursorRow = targetRow;
1323
1606
  }
1324
1607
  this.positionHardwareCursor(cursorPos, newLines.length);
1325
- this.previousLines = newLines;
1608
+ this.setPreviousLines(newLines, rawLines);
1326
1609
  this.previousKittyImageIds = this.collectKittyImageIds(newLines);
1327
1610
  this.previousWidth = width;
1328
1611
  this.previousHeight = height;
@@ -1352,12 +1635,20 @@ export class TUI extends Container {
1352
1635
  }
1353
1636
  if (firstVisibleChanged === -1) {
1354
1637
  if (lineCountDelta !== 0) {
1355
- this.renderScrollbackReplay(newLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow);
1638
+ if (preserveMuxScrollback) {
1639
+ // Above-viewport scrollback may stay stale in mux panes; only the visible viewport is repainted.
1640
+ if (!this.renderMuxViewportRepaint(newLines, rawLines, cursorPos, width, height, viewportTop)) {
1641
+ fullRender(true, false);
1642
+ }
1643
+ }
1644
+ else {
1645
+ this.renderScrollbackReplay(newLines, rawLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow);
1646
+ }
1356
1647
  return;
1357
1648
  }
1358
1649
  this.cursorRow = Math.max(0, newLines.length - 1);
1359
1650
  this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
1360
- this.previousLines = newLines;
1651
+ this.setPreviousLines(newLines, rawLines);
1361
1652
  this.previousWidth = width;
1362
1653
  this.previousHeight = height;
1363
1654
  this.previousViewportTop = viewportTop;
@@ -1374,7 +1665,7 @@ export class TUI extends Container {
1374
1665
  for (let row = 0; row < height; row++) {
1375
1666
  if (row > 0)
1376
1667
  buffer += "\r\n";
1377
- buffer += "\r\x1b[2K";
1668
+ buffer += `\r\x1b[2K${TUI.SEGMENT_RESET}`;
1378
1669
  buffer += newLines[viewportTop + row] ?? "";
1379
1670
  }
1380
1671
  buffer += TUI.FRAME_END;
@@ -1384,7 +1675,7 @@ export class TUI extends Container {
1384
1675
  this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
1385
1676
  this.previousViewportTop = viewportTop;
1386
1677
  this.positionHardwareCursor(cursorPos, newLines.length);
1387
- this.previousLines = newLines;
1678
+ this.setPreviousLines(newLines, rawLines);
1388
1679
  this.previousKittyImageIds = this.collectKittyImageIds(newLines);
1389
1680
  this.previousWidth = width;
1390
1681
  this.previousHeight = height;
@@ -1433,12 +1724,12 @@ export class TUI extends Container {
1433
1724
  const imageStartScreenRow = i - viewportTop;
1434
1725
  if (imageStartScreenRow < 0 || imageStartScreenRow + imageReservedRows > height) {
1435
1726
  logRedraw(`kitty image pre-clear would scroll (${imageStartScreenRow} + ${imageReservedRows} > ${height})`);
1436
- fullRender(true);
1727
+ fullRender(true, !preserveMuxScrollback);
1437
1728
  return;
1438
1729
  }
1439
- buffer += "\x1b[2K";
1730
+ buffer += `\x1b[2K${TUI.SEGMENT_RESET}`;
1440
1731
  for (let row = 1; row < imageReservedRows; row++) {
1441
- buffer += "\r\n\x1b[2K";
1732
+ buffer += `\r\n\x1b[2K${TUI.SEGMENT_RESET}`;
1442
1733
  }
1443
1734
  buffer += `\x1b[${imageReservedRows - 1}A`;
1444
1735
  buffer += line;
@@ -1446,32 +1737,44 @@ export class TUI extends Container {
1446
1737
  i += imageReservedRows - 1;
1447
1738
  continue;
1448
1739
  }
1449
- buffer += "\x1b[2K"; // Clear current line
1450
- if (!isImage && visibleWidth(line) > width) {
1740
+ buffer += `\x1b[2K${TUI.SEGMENT_RESET}`; // Clear current line
1741
+ const lineWidth = visibleWidth(line);
1742
+ if (!isImage && lineWidth > width) {
1451
1743
  // Log all lines to crash file for debugging
1452
1744
  const crashLogPath = path.join(os.homedir(), ".senpi", "agent", "senpi-crash.log");
1453
1745
  const crashData = [
1454
1746
  `Crash at ${new Date().toISOString()}`,
1455
1747
  `Terminal width: ${width}`,
1456
- `Line ${i} visible width: ${visibleWidth(line)}`,
1748
+ `Line ${i} visible width: ${lineWidth}`,
1457
1749
  "",
1458
1750
  "=== All rendered lines ===",
1459
1751
  ...newLines.map((l, idx) => `[${idx}] (w=${visibleWidth(l)}) ${l}`),
1460
1752
  "",
1461
1753
  ].join("\n");
1462
- fs.mkdirSync(path.dirname(crashLogPath), { recursive: true });
1463
- fs.writeFileSync(crashLogPath, crashData);
1464
- // Clean up terminal state before throwing
1465
- this.stop();
1466
- const errorMsg = [
1467
- `Rendered line ${i} exceeds terminal width (${visibleWidth(line)} > ${width}).`,
1468
- "",
1469
- "This is likely caused by a custom TUI component not truncating its output.",
1470
- "Use visibleWidth() to measure and truncateToWidth() to truncate lines.",
1471
- "",
1472
- `Debug log written to: ${crashLogPath}`,
1473
- ].join("\n");
1474
- throw new Error(errorMsg);
1754
+ if (process.env.PI_TUI_STRICT_RENDER === "1") {
1755
+ const crashDumpWritten = writeRenderDiagnosticBestEffort(crashLogPath, crashData);
1756
+ // Clean up terminal state before throwing
1757
+ this.stop();
1758
+ const errorMsg = [
1759
+ `Rendered line ${i} exceeds terminal width (${lineWidth} > ${width}).`,
1760
+ "",
1761
+ "This is likely caused by a custom TUI component not truncating its output.",
1762
+ "Use visibleWidth() to measure and truncateToWidth() to truncate lines.",
1763
+ "",
1764
+ crashDumpWritten
1765
+ ? `Debug log written to: ${crashLogPath}`
1766
+ : `Debug log could not be written to: ${crashLogPath}`,
1767
+ ].join("\n");
1768
+ throw new Error(errorMsg);
1769
+ }
1770
+ if (!this.overWideCrashDumpWritten) {
1771
+ writeRenderDiagnosticBestEffort(crashLogPath, crashData);
1772
+ this.overWideCrashDumpWritten = true;
1773
+ }
1774
+ const truncatedLine = sliceByColumn(line, 0, width, true) + TUI.SEGMENT_RESET;
1775
+ newLines[i] = truncatedLine;
1776
+ buffer += truncatedLine;
1777
+ continue;
1475
1778
  }
1476
1779
  buffer += line;
1477
1780
  }
@@ -1487,7 +1790,7 @@ export class TUI extends Container {
1487
1790
  }
1488
1791
  const extraLines = this.previousLines.length - newLines.length;
1489
1792
  for (let i = newLines.length; i < this.previousLines.length; i++) {
1490
- buffer += "\r\n\x1b[2K";
1793
+ buffer += `\r\n\x1b[2K${TUI.SEGMENT_RESET}`;
1491
1794
  }
1492
1795
  // Move cursor back to end of new content
1493
1796
  buffer += `\x1b[${extraLines}A`;
@@ -1519,7 +1822,8 @@ export class TUI extends Container {
1519
1822
  "=== buffer ===",
1520
1823
  JSON.stringify(buffer),
1521
1824
  ].join("\n");
1522
- fs.writeFileSync(debugPath, debugData);
1825
+ fs.writeFileSync(debugPath, debugData, { encoding: "utf8", mode: DIAGNOSTIC_LOG_MODE });
1826
+ chmodDiagnosticLogBestEffort(debugPath);
1523
1827
  }
1524
1828
  // Write entire buffer at once
1525
1829
  this.terminal.write(buffer);
@@ -1533,8 +1837,10 @@ export class TUI extends Container {
1533
1837
  this.previousViewportTop = Math.max(prevViewportTop, finalCursorRow - height + 1);
1534
1838
  // Position hardware cursor for IME
1535
1839
  this.positionHardwareCursor(cursorPos, newLines.length);
1536
- this.previousLines = newLines;
1537
- this.previousKittyImageIds = this.collectKittyImageIds(newLines);
1840
+ this.setPreviousLines(newLines, rawLines);
1841
+ if (needsKittyImageExpansion) {
1842
+ this.previousKittyImageIds = this.collectKittyImageIds(newLines);
1843
+ }
1538
1844
  this.previousWidth = width;
1539
1845
  this.previousHeight = height;
1540
1846
  }
@@ -1544,34 +1850,36 @@ export class TUI extends Container {
1544
1850
  * @param totalLines Total number of rendered lines
1545
1851
  */
1546
1852
  positionHardwareCursor(cursorPos, totalLines) {
1547
- if (!cursorPos || totalLines <= 0) {
1548
- this.terminal.hideCursor();
1549
- return;
1550
- }
1551
- // Clamp cursor position to valid range
1552
- const targetRow = Math.max(0, Math.min(cursorPos.row, totalLines - 1));
1553
- const targetCol = Math.max(0, cursorPos.col);
1554
- // Move cursor from current position to target
1555
- const rowDelta = targetRow - this.hardwareCursorRow;
1556
1853
  let buffer = "";
1557
- if (rowDelta > 0) {
1558
- buffer += `\x1b[${rowDelta}B`; // Move down
1854
+ if (!cursorPos || totalLines <= 0) {
1855
+ if (this.#lastCursorVisibility !== false) {
1856
+ buffer += "\x1b[?25l";
1857
+ this.#lastCursorVisibility = false;
1858
+ }
1559
1859
  }
1560
- else if (rowDelta < 0) {
1561
- buffer += `\x1b[${-rowDelta}A`; // Move up
1860
+ else {
1861
+ // Clamp cursor position to valid range
1862
+ const targetRow = Math.max(0, Math.min(cursorPos.row, totalLines - 1));
1863
+ const targetCol = Math.max(0, cursorPos.col);
1864
+ // Move cursor from current position to target
1865
+ const rowDelta = targetRow - this.hardwareCursorRow;
1866
+ if (rowDelta > 0) {
1867
+ buffer += `\x1b[${rowDelta}B`; // Move down
1868
+ }
1869
+ else if (rowDelta < 0) {
1870
+ buffer += `\x1b[${-rowDelta}A`; // Move up
1871
+ }
1872
+ // Move to absolute column (1-indexed)
1873
+ buffer += `\x1b[${targetCol + 1}G`;
1874
+ this.hardwareCursorRow = targetRow;
1875
+ if (this.#lastCursorVisibility !== this.showHardwareCursor) {
1876
+ buffer += this.showHardwareCursor ? "\x1b[?25h" : "\x1b[?25l";
1877
+ this.#lastCursorVisibility = this.showHardwareCursor;
1878
+ }
1562
1879
  }
1563
- // Move to absolute column (1-indexed)
1564
- buffer += `\x1b[${targetCol + 1}G`;
1565
1880
  if (buffer) {
1566
1881
  this.terminal.write(buffer);
1567
1882
  }
1568
- this.hardwareCursorRow = targetRow;
1569
- if (this.showHardwareCursor) {
1570
- this.terminal.showCursor();
1571
- }
1572
- else {
1573
- this.terminal.hideCursor();
1574
- }
1575
1883
  }
1576
1884
  /**
1577
1885
  * Query the terminal's default background color with OSC 11 (`ESC ] 11 ; ? BEL`).