@code-yeongyu/senpi 2026.7.2 → 2026.7.4

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 (165) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dist/cli/config-selector.d.ts.map +1 -1
  3. package/dist/cli/config-selector.js +2 -1
  4. package/dist/cli/config-selector.js.map +1 -1
  5. package/dist/cli/startup-ui.d.ts.map +1 -1
  6. package/dist/cli/startup-ui.js +2 -1
  7. package/dist/cli/startup-ui.js.map +1 -1
  8. package/dist/core/dynamic-prompt/build.d.ts +13 -0
  9. package/dist/core/dynamic-prompt/build.d.ts.map +1 -1
  10. package/dist/core/dynamic-prompt/build.js +24 -21
  11. package/dist/core/dynamic-prompt/build.js.map +1 -1
  12. package/dist/core/dynamic-prompt/exploration.d.ts.map +1 -1
  13. package/dist/core/dynamic-prompt/exploration.js +1 -3
  14. package/dist/core/dynamic-prompt/exploration.js.map +1 -1
  15. package/dist/core/dynamic-prompt/index.d.ts +1 -1
  16. package/dist/core/dynamic-prompt/index.d.ts.map +1 -1
  17. package/dist/core/dynamic-prompt/index.js.map +1 -1
  18. package/dist/core/dynamic-prompt/intent-gate.d.ts.map +1 -1
  19. package/dist/core/dynamic-prompt/intent-gate.js +20 -35
  20. package/dist/core/dynamic-prompt/intent-gate.js.map +1 -1
  21. package/dist/core/dynamic-prompt/parallel-tools.d.ts.map +1 -1
  22. package/dist/core/dynamic-prompt/parallel-tools.js +2 -4
  23. package/dist/core/dynamic-prompt/parallel-tools.js.map +1 -1
  24. package/dist/core/dynamic-prompt/style.d.ts.map +1 -1
  25. package/dist/core/dynamic-prompt/style.js +7 -26
  26. package/dist/core/dynamic-prompt/style.js.map +1 -1
  27. package/dist/core/extensions/builtin/hooks/dispatcher.d.ts +2 -0
  28. package/dist/core/extensions/builtin/hooks/dispatcher.d.ts.map +1 -1
  29. package/dist/core/extensions/builtin/hooks/dispatcher.js +50 -17
  30. package/dist/core/extensions/builtin/hooks/dispatcher.js.map +1 -1
  31. package/dist/core/extensions/builtin/hooks/index.d.ts.map +1 -1
  32. package/dist/core/extensions/builtin/hooks/index.js +11 -1
  33. package/dist/core/extensions/builtin/hooks/index.js.map +1 -1
  34. package/dist/core/extensions/builtin/hooks/output-bounds.d.ts.map +1 -1
  35. package/dist/core/extensions/builtin/hooks/output-bounds.js +3 -7
  36. package/dist/core/extensions/builtin/hooks/output-bounds.js.map +1 -1
  37. package/dist/core/extensions/builtin/prompt-preset/claude-fable-5.d.ts +3 -0
  38. package/dist/core/extensions/builtin/prompt-preset/claude-fable-5.d.ts.map +1 -0
  39. package/dist/core/extensions/builtin/prompt-preset/claude-fable-5.js +16 -0
  40. package/dist/core/extensions/builtin/prompt-preset/claude-fable-5.js.map +1 -0
  41. package/dist/core/extensions/builtin/prompt-preset/claude-opus-4-5.d.ts.map +1 -1
  42. package/dist/core/extensions/builtin/prompt-preset/claude-opus-4-5.js +3 -1
  43. package/dist/core/extensions/builtin/prompt-preset/claude-opus-4-5.js.map +1 -1
  44. package/dist/core/extensions/builtin/prompt-preset/claude-opus-4-6.d.ts.map +1 -1
  45. package/dist/core/extensions/builtin/prompt-preset/claude-opus-4-6.js +3 -1
  46. package/dist/core/extensions/builtin/prompt-preset/claude-opus-4-6.js.map +1 -1
  47. package/dist/core/extensions/builtin/prompt-preset/claude-opus-4-7.d.ts.map +1 -1
  48. package/dist/core/extensions/builtin/prompt-preset/claude-opus-4-7.js +6 -2
  49. package/dist/core/extensions/builtin/prompt-preset/claude-opus-4-7.js.map +1 -1
  50. package/dist/core/extensions/builtin/prompt-preset/claude-opus-4-8.d.ts.map +1 -1
  51. package/dist/core/extensions/builtin/prompt-preset/claude-opus-4-8.js +5 -3
  52. package/dist/core/extensions/builtin/prompt-preset/claude-opus-4-8.js.map +1 -1
  53. package/dist/core/extensions/builtin/prompt-preset/gpt-5.5.d.ts.map +1 -1
  54. package/dist/core/extensions/builtin/prompt-preset/gpt-5.5.js +68 -8
  55. package/dist/core/extensions/builtin/prompt-preset/gpt-5.5.js.map +1 -1
  56. package/dist/core/extensions/builtin/prompt-preset/presets.d.ts.map +1 -1
  57. package/dist/core/extensions/builtin/prompt-preset/presets.js +9 -0
  58. package/dist/core/extensions/builtin/prompt-preset/presets.js.map +1 -1
  59. package/dist/core/extensions/builtin/prompt-preset/settings.d.ts +1 -1
  60. package/dist/core/extensions/builtin/prompt-preset/settings.d.ts.map +1 -1
  61. package/dist/core/extensions/builtin/prompt-preset/settings.js +1 -0
  62. package/dist/core/extensions/builtin/prompt-preset/settings.js.map +1 -1
  63. package/dist/core/extensions/runner.d.ts +3 -0
  64. package/dist/core/extensions/runner.d.ts.map +1 -1
  65. package/dist/core/extensions/runner.js +89 -58
  66. package/dist/core/extensions/runner.js.map +1 -1
  67. package/dist/core/extensions/types.d.ts +7 -0
  68. package/dist/core/extensions/types.d.ts.map +1 -1
  69. package/dist/core/extensions/types.js.map +1 -1
  70. package/dist/core/hidden-stdout-log.d.ts +2 -0
  71. package/dist/core/hidden-stdout-log.d.ts.map +1 -0
  72. package/dist/core/hidden-stdout-log.js +17 -0
  73. package/dist/core/hidden-stdout-log.js.map +1 -0
  74. package/dist/core/model-resolver.js +5 -5
  75. package/dist/core/model-resolver.js.map +1 -1
  76. package/dist/core/output-guard.d.ts +2 -0
  77. package/dist/core/output-guard.d.ts.map +1 -1
  78. package/dist/core/output-guard.js +63 -0
  79. package/dist/core/output-guard.js.map +1 -1
  80. package/dist/core/provider-attribution.d.ts.map +1 -1
  81. package/dist/core/provider-attribution.js +0 -10
  82. package/dist/core/provider-attribution.js.map +1 -1
  83. package/dist/core/sensitive-output.d.ts +3 -0
  84. package/dist/core/sensitive-output.d.ts.map +1 -0
  85. package/dist/core/sensitive-output.js +12 -0
  86. package/dist/core/sensitive-output.js.map +1 -0
  87. package/dist/core/tools/edit.js +2 -2
  88. package/dist/core/tools/edit.js.map +1 -1
  89. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  90. package/dist/modes/interactive/interactive-mode.js +41 -4
  91. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  92. package/dist/modes/interactive/interactive-stderr-guard.d.ts +3 -0
  93. package/dist/modes/interactive/interactive-stderr-guard.d.ts.map +1 -0
  94. package/dist/modes/interactive/interactive-stderr-guard.js +24 -0
  95. package/dist/modes/interactive/interactive-stderr-guard.js.map +1 -0
  96. package/dist/package-manager-cli.d.ts.map +1 -1
  97. package/dist/package-manager-cli.js +7 -0
  98. package/dist/package-manager-cli.js.map +1 -1
  99. package/docs/extensions.md +14 -1
  100. package/docs/settings.md +1 -1
  101. package/examples/extensions/question.ts +1 -0
  102. package/node_modules/@earendil-works/pi-agent-core/package.json +2 -2
  103. package/node_modules/@earendil-works/pi-ai/dist/api/anthropic-messages.d.ts.map +1 -1
  104. package/node_modules/@earendil-works/pi-ai/dist/api/anthropic-messages.js +6 -0
  105. package/node_modules/@earendil-works/pi-ai/dist/api/anthropic-messages.js.map +1 -1
  106. package/node_modules/@earendil-works/pi-ai/dist/api/bedrock-converse-stream.js +4 -1
  107. package/node_modules/@earendil-works/pi-ai/dist/api/bedrock-converse-stream.js.map +1 -1
  108. package/node_modules/@earendil-works/pi-ai/dist/api/openai-codex-responses.d.ts.map +1 -1
  109. package/node_modules/@earendil-works/pi-ai/dist/api/openai-codex-responses.js +10 -2
  110. package/node_modules/@earendil-works/pi-ai/dist/api/openai-codex-responses.js.map +1 -1
  111. package/node_modules/@earendil-works/pi-ai/dist/models.generated.d.ts +126 -0
  112. package/node_modules/@earendil-works/pi-ai/dist/models.generated.d.ts.map +1 -1
  113. package/node_modules/@earendil-works/pi-ai/dist/providers/github-copilot.models.d.ts +56 -0
  114. package/node_modules/@earendil-works/pi-ai/dist/providers/github-copilot.models.d.ts.map +1 -1
  115. package/node_modules/@earendil-works/pi-ai/dist/providers/github-copilot.models.js +38 -0
  116. package/node_modules/@earendil-works/pi-ai/dist/providers/github-copilot.models.js.map +1 -1
  117. package/node_modules/@earendil-works/pi-ai/dist/providers/nvidia.models.d.ts +28 -0
  118. package/node_modules/@earendil-works/pi-ai/dist/providers/nvidia.models.d.ts.map +1 -1
  119. package/node_modules/@earendil-works/pi-ai/dist/providers/nvidia.models.js +19 -0
  120. package/node_modules/@earendil-works/pi-ai/dist/providers/nvidia.models.js.map +1 -1
  121. package/node_modules/@earendil-works/pi-ai/dist/providers/openrouter.models.d.ts +42 -0
  122. package/node_modules/@earendil-works/pi-ai/dist/providers/openrouter.models.d.ts.map +1 -1
  123. package/node_modules/@earendil-works/pi-ai/dist/providers/openrouter.models.js +56 -20
  124. package/node_modules/@earendil-works/pi-ai/dist/providers/openrouter.models.js.map +1 -1
  125. package/node_modules/@earendil-works/pi-ai/dist/providers/together.models.js +2 -2
  126. package/node_modules/@earendil-works/pi-ai/dist/providers/together.models.js.map +1 -1
  127. package/node_modules/@earendil-works/pi-ai/dist/providers/vercel-ai-gateway.models.js +4 -4
  128. package/node_modules/@earendil-works/pi-ai/dist/providers/vercel-ai-gateway.models.js.map +1 -1
  129. package/node_modules/@earendil-works/pi-ai/dist/utils/oauth/device-code.d.ts +1 -0
  130. package/node_modules/@earendil-works/pi-ai/dist/utils/oauth/device-code.d.ts.map +1 -1
  131. package/node_modules/@earendil-works/pi-ai/dist/utils/oauth/device-code.js +9 -2
  132. package/node_modules/@earendil-works/pi-ai/dist/utils/oauth/device-code.js.map +1 -1
  133. package/node_modules/@earendil-works/pi-ai/dist/utils/oauth/github-copilot.d.ts.map +1 -1
  134. package/node_modules/@earendil-works/pi-ai/dist/utils/oauth/github-copilot.js +2 -2
  135. package/node_modules/@earendil-works/pi-ai/dist/utils/oauth/github-copilot.js.map +1 -1
  136. package/node_modules/@earendil-works/pi-ai/dist/utils/overflow.d.ts +1 -0
  137. package/node_modules/@earendil-works/pi-ai/dist/utils/overflow.d.ts.map +1 -1
  138. package/node_modules/@earendil-works/pi-ai/dist/utils/overflow.js +3 -0
  139. package/node_modules/@earendil-works/pi-ai/dist/utils/overflow.js.map +1 -1
  140. package/node_modules/@earendil-works/pi-ai/dist/utils/retry.d.ts.map +1 -1
  141. package/node_modules/@earendil-works/pi-ai/dist/utils/retry.js +1 -0
  142. package/node_modules/@earendil-works/pi-ai/dist/utils/retry.js.map +1 -1
  143. package/node_modules/@earendil-works/pi-ai/package.json +1 -1
  144. package/node_modules/@earendil-works/pi-tui/dist/index.d.ts +1 -1
  145. package/node_modules/@earendil-works/pi-tui/dist/index.d.ts.map +1 -1
  146. package/node_modules/@earendil-works/pi-tui/dist/index.js.map +1 -1
  147. package/node_modules/@earendil-works/pi-tui/dist/mux.d.ts +4 -0
  148. package/node_modules/@earendil-works/pi-tui/dist/mux.d.ts.map +1 -0
  149. package/node_modules/@earendil-works/pi-tui/dist/mux.js +10 -0
  150. package/node_modules/@earendil-works/pi-tui/dist/mux.js.map +1 -0
  151. package/node_modules/@earendil-works/pi-tui/dist/terminal.d.ts +17 -0
  152. package/node_modules/@earendil-works/pi-tui/dist/terminal.d.ts.map +1 -1
  153. package/node_modules/@earendil-works/pi-tui/dist/terminal.js +75 -22
  154. package/node_modules/@earendil-works/pi-tui/dist/terminal.js.map +1 -1
  155. package/node_modules/@earendil-works/pi-tui/dist/tui.d.ts +29 -1
  156. package/node_modules/@earendil-works/pi-tui/dist/tui.d.ts.map +1 -1
  157. package/node_modules/@earendil-works/pi-tui/dist/tui.js +401 -89
  158. package/node_modules/@earendil-works/pi-tui/dist/tui.js.map +1 -1
  159. package/node_modules/@earendil-works/pi-tui/dist/utils.d.ts +10 -0
  160. package/node_modules/@earendil-works/pi-tui/dist/utils.d.ts.map +1 -1
  161. package/node_modules/@earendil-works/pi-tui/dist/utils.js +53 -13
  162. package/node_modules/@earendil-works/pi-tui/dist/utils.js.map +1 -1
  163. package/node_modules/@earendil-works/pi-tui/package.json +2 -1
  164. package/npm-shrinkwrap.json +9 -9
  165. 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,11 @@ export class TUI extends Container {
451
569
  }
452
570
  start() {
453
571
  this.stopped = false;
572
+ this.renderRequested = false;
573
+ this.inputRenderPending = false;
574
+ this.#lastCursorVisibility = undefined;
454
575
  this.terminal.start((data) => this.handleInput(data), () => this.requestRender());
455
- this.terminal.hideCursor();
576
+ this.#setCursorVisibility(false);
456
577
  if (this.terminalColorSchemeNotificationsEnabled) {
457
578
  this.terminal.write("\x1b[?2031h");
458
579
  }
@@ -494,6 +615,8 @@ export class TUI extends Container {
494
615
  }
495
616
  stop() {
496
617
  this.stopped = true;
618
+ this.renderRequested = false;
619
+ this.inputRenderPending = false;
497
620
  if (this.renderTimer) {
498
621
  clearTimeout(this.renderTimer);
499
622
  this.renderTimer = undefined;
@@ -513,9 +636,12 @@ export class TUI extends Container {
513
636
  }
514
637
  this.terminal.write("\r\n");
515
638
  }
516
- this.terminal.showCursor();
639
+ this.#lastCursorVisibility = undefined;
640
+ this.#setCursorVisibility(true);
517
641
  this.terminal.stop();
642
+ this.#lastCursorVisibility = undefined;
518
643
  this.previousLines = [];
644
+ this.previousRawLines = [];
519
645
  this.previousKittyImageIds.clear();
520
646
  this.previousWidth = 0;
521
647
  this.previousHeight = 0;
@@ -524,10 +650,22 @@ export class TUI extends Container {
524
650
  this.maxLinesRendered = 0;
525
651
  this.previousViewportTop = 0;
526
652
  }
653
+ #setCursorVisibility(visible) {
654
+ if (this.#lastCursorVisibility === visible)
655
+ return;
656
+ if (visible) {
657
+ this.terminal.showCursor();
658
+ }
659
+ else {
660
+ this.terminal.hideCursor();
661
+ }
662
+ this.#lastCursorVisibility = visible;
663
+ }
527
664
  requestRender(force = false, source = "unknown") {
528
665
  if (force) {
529
666
  this.inputRenderPending = false;
530
667
  this.previousLines = [];
668
+ this.previousRawLines = [];
531
669
  this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
532
670
  this.previousHeight = -1; // -1 triggers heightChanged, forcing a full clear
533
671
  this.cursorRow = 0;
@@ -905,17 +1043,83 @@ export class TUI extends Container {
905
1043
  */
906
1044
  static { this.FRAME_BEGIN = "\x1b[?2026h\x1b[?7l"; }
907
1045
  static { this.FRAME_END = "\x1b[?7h\x1b[?2026l"; }
908
- applyLineResets(lines) {
909
- const reset = TUI.SEGMENT_RESET;
1046
+ setPreviousLines(lines, rawLines) {
1047
+ this.previousLines = lines;
1048
+ this.previousRawLines = rawLines;
1049
+ }
1050
+ normalizeLine(line) {
1051
+ if (isImageLine(line)) {
1052
+ return { line, normalized: false };
1053
+ }
1054
+ return { line: normalizeTerminalOutput(line) + TUI.SEGMENT_RESET, normalized: true };
1055
+ }
1056
+ applyLineResets(lines, mode = "full") {
1057
+ const normalizedLines = [];
1058
+ let normalizedCount = 0;
910
1059
  for (let i = 0; i < lines.length; i++) {
911
1060
  const line = lines[i];
912
- if (!isImageLine(line)) {
913
- lines[i] = normalizeTerminalOutput(line) + reset;
1061
+ if (isImageLine(line)) {
1062
+ normalizedLines.push(line);
1063
+ }
1064
+ else {
1065
+ normalizedLines.push(normalizeTerminalOutput(line) + TUI.SEGMENT_RESET);
1066
+ normalizedCount += 1;
914
1067
  }
915
1068
  }
916
- return lines;
1069
+ recordViewportRenderStats(normalizedCount, mode);
1070
+ return {
1071
+ lines: normalizedLines,
1072
+ firstRawChanged: 0,
1073
+ compareEndExclusive: normalizedLines.length,
1074
+ bounded: false,
1075
+ };
1076
+ }
1077
+ applyViewportLineResets(rawLines, viewportTop, height, stableDimensions) {
1078
+ if (!viewportRenderEnabled() ||
1079
+ !stableDimensions ||
1080
+ this.previousLines.length === 0 ||
1081
+ this.previousLines.length !== rawLines.length ||
1082
+ this.previousRawLines.length !== rawLines.length) {
1083
+ return this.applyLineResets(rawLines);
1084
+ }
1085
+ const windowStart = Math.max(0, viewportTop - VIEWPORT_RENDER_OVERSCAN);
1086
+ const windowEnd = Math.min(rawLines.length, viewportTop + height + VIEWPORT_RENDER_OVERSCAN);
1087
+ let firstRawChanged = -1;
1088
+ for (let i = 0; i < rawLines.length; i++) {
1089
+ if (rawLines[i] === this.previousRawLines[i]) {
1090
+ continue;
1091
+ }
1092
+ if (firstRawChanged === -1) {
1093
+ firstRawChanged = i;
1094
+ }
1095
+ if (i < windowStart || i >= windowEnd) {
1096
+ return this.applyLineResets(rawLines, "escaped");
1097
+ }
1098
+ }
1099
+ const lines = this.previousLines.slice();
1100
+ let normalizedCount = 0;
1101
+ if (firstRawChanged !== -1) {
1102
+ for (let i = windowStart; i < windowEnd; i++) {
1103
+ if (rawLines[i] === this.previousRawLines[i]) {
1104
+ continue;
1105
+ }
1106
+ const normalized = this.normalizeLine(rawLines[i] ?? "");
1107
+ lines[i] = normalized.line;
1108
+ if (normalized.normalized) {
1109
+ normalizedCount += 1;
1110
+ }
1111
+ }
1112
+ }
1113
+ recordViewportRenderStats(normalizedCount, "bounded");
1114
+ return {
1115
+ lines,
1116
+ firstRawChanged,
1117
+ compareEndExclusive: windowEnd,
1118
+ bounded: true,
1119
+ };
917
1120
  }
918
1121
  collectKittyImageIds(lines) {
1122
+ recordKittyImageScanStats(lines.length);
919
1123
  const ids = new Set();
920
1124
  for (const line of lines) {
921
1125
  for (const id of extractKittyImageIds(line)) {
@@ -963,6 +1167,24 @@ export class TUI extends Container {
963
1167
  expandForLines(newLines);
964
1168
  return { firstChanged: expandedFirstChanged, lastChanged: expandedLastChanged };
965
1169
  }
1170
+ changedRangeNeedsKittyImageExpansion(lines, firstChanged, lastChanged) {
1171
+ if (this.previousKittyImageIds.size > 0) {
1172
+ return true;
1173
+ }
1174
+ const start = Math.max(0, firstChanged);
1175
+ const end = Math.min(lines.length - 1, lastChanged);
1176
+ let scannedLines = 0;
1177
+ for (let i = start; i <= end; i++) {
1178
+ scannedLines += 1;
1179
+ const line = lines[i] ?? "";
1180
+ if (isImageLine(line) || extractKittyImageIds(line).length > 0) {
1181
+ recordKittyImageScanStats(scannedLines);
1182
+ return true;
1183
+ }
1184
+ }
1185
+ recordKittyImageScanStats(scannedLines);
1186
+ return false;
1187
+ }
966
1188
  deleteChangedKittyImages(firstChanged, lastChanged) {
967
1189
  if (firstChanged < 0 || lastChanged < firstChanged)
968
1190
  return "";
@@ -978,6 +1200,9 @@ export class TUI extends Container {
978
1200
  getViewportRows(lines, viewportTop, height) {
979
1201
  return Array.from({ length: height }, (_, row) => lines[viewportTop + row] ?? "");
980
1202
  }
1203
+ shouldPreserveMuxScrollback() {
1204
+ return this.#muxDetector() && !useLegacyMuxRender();
1205
+ }
981
1206
  createViewportInsertScrollPlan(newLines, prevViewportTop, height, lineCountDelta) {
982
1207
  if (lineCountDelta <= 0 || lineCountDelta >= height || this.overlayStack.length > 0) {
983
1208
  return undefined;
@@ -1001,18 +1226,23 @@ export class TUI extends Container {
1001
1226
  if (regionHeight <= 0 || regionHeight < lineCountDelta) {
1002
1227
  return undefined;
1003
1228
  }
1229
+ const mutatedRows = [];
1004
1230
  for (let row = 0; row < regionHeight - lineCountDelta; row++) {
1005
1231
  if (previousVisible[row + lineCountDelta] !== nextVisible[row]) {
1006
- return undefined;
1232
+ mutatedRows.push({ screenRow: row, content: nextVisible[row] });
1233
+ if (mutatedRows.length > MAX_SCROLL_DIFF_ROWS) {
1234
+ return undefined;
1235
+ }
1007
1236
  }
1008
1237
  }
1009
1238
  return {
1010
1239
  viewportTop,
1011
1240
  regionBottom: regionHeight - 1,
1012
1241
  insertedRows: nextVisible.slice(regionHeight - lineCountDelta, regionHeight),
1242
+ mutatedRows,
1013
1243
  };
1014
1244
  }
1015
- renderViewportInsertScroll(plan, newLines, cursorPos, width, height) {
1245
+ renderViewportInsertScroll(plan, newLines, rawLines, cursorPos, width, height) {
1016
1246
  let buffer = TUI.FRAME_BEGIN;
1017
1247
  const regionTop = 0;
1018
1248
  const regionBottom = plan.regionBottom;
@@ -1021,27 +1251,35 @@ export class TUI extends Container {
1021
1251
  buffer += "\n".repeat(plan.insertedRows.length);
1022
1252
  buffer += "\x1b[r";
1023
1253
  const firstInsertedScreenRow = regionBottom - plan.insertedRows.length + 1;
1254
+ let finalPaintedScreenRow = firstInsertedScreenRow + plan.insertedRows.length - 1;
1024
1255
  for (let index = 0; index < plan.insertedRows.length; index++) {
1025
1256
  const screenRow = firstInsertedScreenRow + index;
1026
- buffer += `\x1b[${screenRow + 1};1H\x1b[2K`;
1257
+ buffer += `\x1b[${screenRow + 1};1H\x1b[2K${TUI.SEGMENT_RESET}`;
1027
1258
  buffer += plan.insertedRows[index] ?? "";
1028
1259
  }
1260
+ for (const row of plan.mutatedRows) {
1261
+ buffer += `\x1b[${row.screenRow + 1};1H\x1b[2K${TUI.SEGMENT_RESET}`;
1262
+ buffer += row.content;
1263
+ finalPaintedScreenRow = row.screenRow;
1264
+ }
1029
1265
  buffer += TUI.FRAME_END;
1030
1266
  this.terminal.write(buffer);
1031
1267
  this.cursorRow = Math.max(0, newLines.length - 1);
1032
- this.hardwareCursorRow = plan.viewportTop + firstInsertedScreenRow + plan.insertedRows.length - 1;
1268
+ this.hardwareCursorRow = plan.viewportTop + finalPaintedScreenRow;
1033
1269
  this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
1034
1270
  this.previousViewportTop = plan.viewportTop;
1035
1271
  this.positionHardwareCursor(cursorPos, newLines.length);
1036
- this.previousLines = newLines;
1272
+ this.setPreviousLines(newLines, rawLines);
1037
1273
  this.previousKittyImageIds = this.collectKittyImageIds(newLines);
1038
1274
  this.previousWidth = width;
1039
1275
  this.previousHeight = height;
1040
1276
  }
1041
- renderScrollbackReplay(newLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow) {
1277
+ renderScrollbackReplay(newLines, rawLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow) {
1042
1278
  let buffer = TUI.FRAME_BEGIN;
1043
1279
  buffer += this.deleteKittyImages(this.previousKittyImageIds);
1044
- buffer += "\x1b[3J";
1280
+ if (!this.shouldPreserveMuxScrollback()) {
1281
+ buffer += "\x1b[3J";
1282
+ }
1045
1283
  const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));
1046
1284
  if (currentScreenRow > 0) {
1047
1285
  buffer += `\x1b[${currentScreenRow}A`;
@@ -1050,7 +1288,7 @@ export class TUI extends Container {
1050
1288
  for (let row = 0; row < bufferLength; row++) {
1051
1289
  if (row > 0)
1052
1290
  buffer += "\r\n";
1053
- buffer += "\r\x1b[2K";
1291
+ buffer += `\r\x1b[2K${TUI.SEGMENT_RESET}`;
1054
1292
  buffer += newLines[row] ?? "";
1055
1293
  }
1056
1294
  buffer += TUI.FRAME_END;
@@ -1060,10 +1298,41 @@ export class TUI extends Container {
1060
1298
  this.maxLinesRendered = newLines.length;
1061
1299
  this.previousViewportTop = Math.max(0, bufferLength - height);
1062
1300
  this.positionHardwareCursor(cursorPos, newLines.length);
1063
- this.previousLines = newLines;
1301
+ this.setPreviousLines(newLines, rawLines);
1302
+ this.previousKittyImageIds = this.collectKittyImageIds(newLines);
1303
+ this.previousWidth = width;
1304
+ this.previousHeight = height;
1305
+ }
1306
+ renderMuxViewportRepaint(newLines, rawLines, cursorPos, width, height, viewportTop = Math.max(0, newLines.length - height)) {
1307
+ const previousVisible = this.getViewportRows(this.previousLines, this.previousViewportTop, height);
1308
+ const nextVisible = this.getViewportRows(newLines, viewportTop, height);
1309
+ if (previousVisible.some(isImageLine) || nextVisible.some(isImageLine)) {
1310
+ return false;
1311
+ }
1312
+ let buffer = TUI.FRAME_BEGIN;
1313
+ const currentScreenRow = Math.max(0, Math.min(height - 1, this.hardwareCursorRow - this.previousViewportTop));
1314
+ if (currentScreenRow > 0) {
1315
+ buffer += `\x1b[${currentScreenRow}A`;
1316
+ }
1317
+ for (let row = 0; row < height; row++) {
1318
+ if (row > 0)
1319
+ buffer += "\r\n";
1320
+ buffer += `\r\x1b[2K${TUI.SEGMENT_RESET}`;
1321
+ buffer += newLines[viewportTop + row] ?? "";
1322
+ }
1323
+ buffer += TUI.FRAME_END;
1324
+ this.terminal.write(buffer);
1325
+ this.muxViewportRepaintCount += 1;
1326
+ this.cursorRow = Math.max(0, newLines.length - 1);
1327
+ this.hardwareCursorRow = viewportTop + Math.max(0, height - 1);
1328
+ this.maxLinesRendered = newLines.length;
1329
+ this.previousViewportTop = viewportTop;
1330
+ this.positionHardwareCursor(cursorPos, newLines.length);
1331
+ this.setPreviousLines(newLines, rawLines);
1064
1332
  this.previousKittyImageIds = this.collectKittyImageIds(newLines);
1065
1333
  this.previousWidth = width;
1066
1334
  this.previousHeight = height;
1335
+ return true;
1067
1336
  }
1068
1337
  /** Splice overlay content into a base line at a specific column. Single-pass optimized. */
1069
1338
  compositeLineAt(baseLine, overlayLine, startCol, overlayWidth, totalWidth) {
@@ -1153,17 +1422,23 @@ export class TUI extends Container {
1153
1422
  }
1154
1423
  // Extract cursor position before applying line resets (marker must be found first)
1155
1424
  const cursorPos = this.extractCursorPosition(newLines, height);
1156
- newLines = this.applyLineResets(newLines);
1425
+ const rawLines = newLines;
1426
+ const normalizedLines = this.applyViewportLineResets(rawLines, prevViewportTop, height, !widthChanged && !heightChanged);
1427
+ newLines = normalizedLines.lines;
1428
+ const preserveMuxScrollback = this.shouldPreserveMuxScrollback();
1157
1429
  // Helper to clear scrollback and viewport and render all new lines
1158
- const fullRender = (clear) => {
1430
+ const fullRender = (clear, clearScrollback = clear) => {
1159
1431
  this.fullRedrawCount += 1;
1160
1432
  let buffer = TUI.FRAME_BEGIN;
1161
1433
  if (clear) {
1162
1434
  buffer += this.deleteKittyImages(this.previousKittyImageIds);
1163
- buffer += "\x1b[2J\x1b[H\x1b[3J"; // Clear screen, home, then clear scrollback
1435
+ buffer += "\x1b[2J\x1b[H";
1436
+ if (clearScrollback && !preserveMuxScrollback) {
1437
+ buffer += "\x1b[3J";
1438
+ }
1164
1439
  }
1165
1440
  else {
1166
- buffer += "\r\x1b[2K";
1441
+ buffer += `\r\x1b[2K${TUI.SEGMENT_RESET}`;
1167
1442
  }
1168
1443
  for (let i = 0; i < newLines.length; i++) {
1169
1444
  if (i > 0)
@@ -1197,7 +1472,7 @@ export class TUI extends Container {
1197
1472
  const bufferLength = Math.max(height, newLines.length);
1198
1473
  this.previousViewportTop = Math.max(0, bufferLength - height);
1199
1474
  this.positionHardwareCursor(cursorPos, newLines.length);
1200
- this.previousLines = newLines;
1475
+ this.setPreviousLines(newLines, rawLines);
1201
1476
  this.previousKittyImageIds = this.collectKittyImageIds(newLines);
1202
1477
  this.previousWidth = width;
1203
1478
  this.previousHeight = height;
@@ -1208,7 +1483,8 @@ export class TUI extends Container {
1208
1483
  return;
1209
1484
  const logPath = path.join(os.homedir(), ".senpi", "agent", "senpi-debug.log");
1210
1485
  const msg = `[${new Date().toISOString()}] fullRender: ${reason} (prev=${this.previousLines.length}, new=${newLines.length}, height=${height})\n`;
1211
- fs.appendFileSync(logPath, msg);
1486
+ fs.appendFileSync(logPath, msg, { encoding: "utf8", mode: DIAGNOSTIC_LOG_MODE });
1487
+ chmodDiagnosticLogBestEffort(logPath);
1212
1488
  };
1213
1489
  // First render - just output everything without clearing (assumes clean screen)
1214
1490
  if (this.previousLines.length === 0 && !widthChanged && !heightChanged) {
@@ -1219,7 +1495,8 @@ export class TUI extends Container {
1219
1495
  // Width changes always need a full re-render because wrapping changes.
1220
1496
  if (widthChanged) {
1221
1497
  logRedraw(`terminal width changed (${this.previousWidth} -> ${width})`);
1222
- fullRender(true);
1498
+ // In multiplexers, re-emit the viewport without 3J so pane history survives; an older copy may remain above.
1499
+ fullRender(true, !preserveMuxScrollback);
1223
1500
  return;
1224
1501
  }
1225
1502
  // Height changes normally need a full re-render to keep the visible viewport aligned,
@@ -1227,7 +1504,14 @@ export class TUI extends Container {
1227
1504
  // In that environment, a full redraw causes the entire history to replay on every toggle.
1228
1505
  if (heightChanged && !isTermuxSession()) {
1229
1506
  logRedraw(`terminal height changed (${this.previousHeight} -> ${height})`);
1230
- fullRender(true);
1507
+ if (preserveMuxScrollback) {
1508
+ if (!this.renderMuxViewportRepaint(newLines, rawLines, cursorPos, width, height)) {
1509
+ fullRender(true, false);
1510
+ }
1511
+ }
1512
+ else {
1513
+ fullRender(true);
1514
+ }
1231
1515
  return;
1232
1516
  }
1233
1517
  // Content shrunk below the working area and no overlays - re-render to clear empty rows
@@ -1235,14 +1519,16 @@ export class TUI extends Container {
1235
1519
  // Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var
1236
1520
  if (this.clearOnShrink && newLines.length < this.maxLinesRendered && this.overlayStack.length === 0) {
1237
1521
  logRedraw(`clearOnShrink (maxLinesRendered=${this.maxLinesRendered})`);
1238
- fullRender(true);
1522
+ fullRender(true, !preserveMuxScrollback);
1239
1523
  return;
1240
1524
  }
1241
1525
  // Find first and last changed lines
1242
1526
  let firstChanged = -1;
1243
1527
  let lastChanged = -1;
1244
1528
  const maxLines = Math.max(newLines.length, this.previousLines.length);
1245
- for (let i = 0; i < maxLines; i++) {
1529
+ const diffScanStart = normalizedLines.bounded && normalizedLines.firstRawChanged !== -1 ? normalizedLines.firstRawChanged : 0;
1530
+ const diffScanEndExclusive = normalizedLines.bounded ? normalizedLines.compareEndExclusive : maxLines;
1531
+ for (let i = diffScanStart; i < diffScanEndExclusive; i++) {
1246
1532
  const oldLine = i < this.previousLines.length ? this.previousLines[i] : "";
1247
1533
  const newLine = i < newLines.length ? newLines[i] : "";
1248
1534
  if (oldLine !== newLine) {
@@ -1260,7 +1546,8 @@ export class TUI extends Container {
1260
1546
  }
1261
1547
  lastChanged = newLines.length - 1;
1262
1548
  }
1263
- if (firstChanged !== -1) {
1549
+ const needsKittyImageExpansion = firstChanged !== -1 && this.changedRangeNeedsKittyImageExpansion(newLines, firstChanged, lastChanged);
1550
+ if (needsKittyImageExpansion) {
1264
1551
  const expandedRange = this.expandChangedRangeForKittyImages(firstChanged, lastChanged, newLines);
1265
1552
  firstChanged = expandedRange.firstChanged;
1266
1553
  lastChanged = expandedRange.lastChanged;
@@ -1275,7 +1562,7 @@ export class TUI extends Container {
1275
1562
  return;
1276
1563
  }
1277
1564
  if (insertScrollPlan) {
1278
- this.renderViewportInsertScroll(insertScrollPlan, newLines, cursorPos, width, height);
1565
+ this.renderViewportInsertScroll(insertScrollPlan, newLines, rawLines, cursorPos, width, height);
1279
1566
  return;
1280
1567
  }
1281
1568
  // All changes are in deleted lines (nothing to render, just clear)
@@ -1287,7 +1574,7 @@ export class TUI extends Container {
1287
1574
  const targetRow = Math.max(0, newLines.length - 1);
1288
1575
  if (targetRow < prevViewportTop) {
1289
1576
  logRedraw(`deleted lines moved viewport up (${targetRow} < ${prevViewportTop})`);
1290
- fullRender(true);
1577
+ fullRender(true, !preserveMuxScrollback);
1291
1578
  return;
1292
1579
  }
1293
1580
  const lineDiff = computeLineDiff(targetRow);
@@ -1300,7 +1587,7 @@ export class TUI extends Container {
1300
1587
  const extraLines = this.previousLines.length - newLines.length;
1301
1588
  if (extraLines > height) {
1302
1589
  logRedraw(`extraLines > height (${extraLines} > ${height})`);
1303
- fullRender(true);
1590
+ fullRender(true, !preserveMuxScrollback);
1304
1591
  return;
1305
1592
  }
1306
1593
  const clearStartOffset = newLines.length === 0 ? 0 : 1;
@@ -1308,7 +1595,7 @@ export class TUI extends Container {
1308
1595
  buffer += `\x1b[${clearStartOffset}B`;
1309
1596
  }
1310
1597
  for (let i = 0; i < extraLines; i++) {
1311
- buffer += "\r\x1b[2K";
1598
+ buffer += `\r\x1b[2K${TUI.SEGMENT_RESET}`;
1312
1599
  if (i < extraLines - 1)
1313
1600
  buffer += "\x1b[1B";
1314
1601
  }
@@ -1322,7 +1609,7 @@ export class TUI extends Container {
1322
1609
  this.hardwareCursorRow = targetRow;
1323
1610
  }
1324
1611
  this.positionHardwareCursor(cursorPos, newLines.length);
1325
- this.previousLines = newLines;
1612
+ this.setPreviousLines(newLines, rawLines);
1326
1613
  this.previousKittyImageIds = this.collectKittyImageIds(newLines);
1327
1614
  this.previousWidth = width;
1328
1615
  this.previousHeight = height;
@@ -1352,12 +1639,20 @@ export class TUI extends Container {
1352
1639
  }
1353
1640
  if (firstVisibleChanged === -1) {
1354
1641
  if (lineCountDelta !== 0) {
1355
- this.renderScrollbackReplay(newLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow);
1642
+ if (preserveMuxScrollback) {
1643
+ // Above-viewport scrollback may stay stale in mux panes; only the visible viewport is repainted.
1644
+ if (!this.renderMuxViewportRepaint(newLines, rawLines, cursorPos, width, height, viewportTop)) {
1645
+ fullRender(true, false);
1646
+ }
1647
+ }
1648
+ else {
1649
+ this.renderScrollbackReplay(newLines, rawLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow);
1650
+ }
1356
1651
  return;
1357
1652
  }
1358
1653
  this.cursorRow = Math.max(0, newLines.length - 1);
1359
1654
  this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
1360
- this.previousLines = newLines;
1655
+ this.setPreviousLines(newLines, rawLines);
1361
1656
  this.previousWidth = width;
1362
1657
  this.previousHeight = height;
1363
1658
  this.previousViewportTop = viewportTop;
@@ -1374,7 +1669,7 @@ export class TUI extends Container {
1374
1669
  for (let row = 0; row < height; row++) {
1375
1670
  if (row > 0)
1376
1671
  buffer += "\r\n";
1377
- buffer += "\r\x1b[2K";
1672
+ buffer += `\r\x1b[2K${TUI.SEGMENT_RESET}`;
1378
1673
  buffer += newLines[viewportTop + row] ?? "";
1379
1674
  }
1380
1675
  buffer += TUI.FRAME_END;
@@ -1384,7 +1679,7 @@ export class TUI extends Container {
1384
1679
  this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
1385
1680
  this.previousViewportTop = viewportTop;
1386
1681
  this.positionHardwareCursor(cursorPos, newLines.length);
1387
- this.previousLines = newLines;
1682
+ this.setPreviousLines(newLines, rawLines);
1388
1683
  this.previousKittyImageIds = this.collectKittyImageIds(newLines);
1389
1684
  this.previousWidth = width;
1390
1685
  this.previousHeight = height;
@@ -1433,12 +1728,12 @@ export class TUI extends Container {
1433
1728
  const imageStartScreenRow = i - viewportTop;
1434
1729
  if (imageStartScreenRow < 0 || imageStartScreenRow + imageReservedRows > height) {
1435
1730
  logRedraw(`kitty image pre-clear would scroll (${imageStartScreenRow} + ${imageReservedRows} > ${height})`);
1436
- fullRender(true);
1731
+ fullRender(true, !preserveMuxScrollback);
1437
1732
  return;
1438
1733
  }
1439
- buffer += "\x1b[2K";
1734
+ buffer += `\x1b[2K${TUI.SEGMENT_RESET}`;
1440
1735
  for (let row = 1; row < imageReservedRows; row++) {
1441
- buffer += "\r\n\x1b[2K";
1736
+ buffer += `\r\n\x1b[2K${TUI.SEGMENT_RESET}`;
1442
1737
  }
1443
1738
  buffer += `\x1b[${imageReservedRows - 1}A`;
1444
1739
  buffer += line;
@@ -1446,32 +1741,44 @@ export class TUI extends Container {
1446
1741
  i += imageReservedRows - 1;
1447
1742
  continue;
1448
1743
  }
1449
- buffer += "\x1b[2K"; // Clear current line
1450
- if (!isImage && visibleWidth(line) > width) {
1744
+ buffer += `\x1b[2K${TUI.SEGMENT_RESET}`; // Clear current line
1745
+ const lineWidth = visibleWidth(line);
1746
+ if (!isImage && lineWidth > width) {
1451
1747
  // Log all lines to crash file for debugging
1452
1748
  const crashLogPath = path.join(os.homedir(), ".senpi", "agent", "senpi-crash.log");
1453
1749
  const crashData = [
1454
1750
  `Crash at ${new Date().toISOString()}`,
1455
1751
  `Terminal width: ${width}`,
1456
- `Line ${i} visible width: ${visibleWidth(line)}`,
1752
+ `Line ${i} visible width: ${lineWidth}`,
1457
1753
  "",
1458
1754
  "=== All rendered lines ===",
1459
1755
  ...newLines.map((l, idx) => `[${idx}] (w=${visibleWidth(l)}) ${l}`),
1460
1756
  "",
1461
1757
  ].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);
1758
+ if (process.env.PI_TUI_STRICT_RENDER === "1") {
1759
+ const crashDumpWritten = writeRenderDiagnosticBestEffort(crashLogPath, crashData);
1760
+ // Clean up terminal state before throwing
1761
+ this.stop();
1762
+ const errorMsg = [
1763
+ `Rendered line ${i} exceeds terminal width (${lineWidth} > ${width}).`,
1764
+ "",
1765
+ "This is likely caused by a custom TUI component not truncating its output.",
1766
+ "Use visibleWidth() to measure and truncateToWidth() to truncate lines.",
1767
+ "",
1768
+ crashDumpWritten
1769
+ ? `Debug log written to: ${crashLogPath}`
1770
+ : `Debug log could not be written to: ${crashLogPath}`,
1771
+ ].join("\n");
1772
+ throw new Error(errorMsg);
1773
+ }
1774
+ if (!this.overWideCrashDumpWritten) {
1775
+ writeRenderDiagnosticBestEffort(crashLogPath, crashData);
1776
+ this.overWideCrashDumpWritten = true;
1777
+ }
1778
+ const truncatedLine = sliceByColumn(line, 0, width, true) + TUI.SEGMENT_RESET;
1779
+ newLines[i] = truncatedLine;
1780
+ buffer += truncatedLine;
1781
+ continue;
1475
1782
  }
1476
1783
  buffer += line;
1477
1784
  }
@@ -1487,7 +1794,7 @@ export class TUI extends Container {
1487
1794
  }
1488
1795
  const extraLines = this.previousLines.length - newLines.length;
1489
1796
  for (let i = newLines.length; i < this.previousLines.length; i++) {
1490
- buffer += "\r\n\x1b[2K";
1797
+ buffer += `\r\n\x1b[2K${TUI.SEGMENT_RESET}`;
1491
1798
  }
1492
1799
  // Move cursor back to end of new content
1493
1800
  buffer += `\x1b[${extraLines}A`;
@@ -1519,7 +1826,8 @@ export class TUI extends Container {
1519
1826
  "=== buffer ===",
1520
1827
  JSON.stringify(buffer),
1521
1828
  ].join("\n");
1522
- fs.writeFileSync(debugPath, debugData);
1829
+ fs.writeFileSync(debugPath, debugData, { encoding: "utf8", mode: DIAGNOSTIC_LOG_MODE });
1830
+ chmodDiagnosticLogBestEffort(debugPath);
1523
1831
  }
1524
1832
  // Write entire buffer at once
1525
1833
  this.terminal.write(buffer);
@@ -1533,8 +1841,10 @@ export class TUI extends Container {
1533
1841
  this.previousViewportTop = Math.max(prevViewportTop, finalCursorRow - height + 1);
1534
1842
  // Position hardware cursor for IME
1535
1843
  this.positionHardwareCursor(cursorPos, newLines.length);
1536
- this.previousLines = newLines;
1537
- this.previousKittyImageIds = this.collectKittyImageIds(newLines);
1844
+ this.setPreviousLines(newLines, rawLines);
1845
+ if (needsKittyImageExpansion) {
1846
+ this.previousKittyImageIds = this.collectKittyImageIds(newLines);
1847
+ }
1538
1848
  this.previousWidth = width;
1539
1849
  this.previousHeight = height;
1540
1850
  }
@@ -1544,34 +1854,36 @@ export class TUI extends Container {
1544
1854
  * @param totalLines Total number of rendered lines
1545
1855
  */
1546
1856
  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
1857
  let buffer = "";
1557
- if (rowDelta > 0) {
1558
- buffer += `\x1b[${rowDelta}B`; // Move down
1858
+ if (!cursorPos || totalLines <= 0) {
1859
+ if (this.#lastCursorVisibility !== false) {
1860
+ buffer += "\x1b[?25l";
1861
+ this.#lastCursorVisibility = false;
1862
+ }
1559
1863
  }
1560
- else if (rowDelta < 0) {
1561
- buffer += `\x1b[${-rowDelta}A`; // Move up
1864
+ else {
1865
+ // Clamp cursor position to valid range
1866
+ const targetRow = Math.max(0, Math.min(cursorPos.row, totalLines - 1));
1867
+ const targetCol = Math.max(0, cursorPos.col);
1868
+ // Move cursor from current position to target
1869
+ const rowDelta = targetRow - this.hardwareCursorRow;
1870
+ if (rowDelta > 0) {
1871
+ buffer += `\x1b[${rowDelta}B`; // Move down
1872
+ }
1873
+ else if (rowDelta < 0) {
1874
+ buffer += `\x1b[${-rowDelta}A`; // Move up
1875
+ }
1876
+ // Move to absolute column (1-indexed)
1877
+ buffer += `\x1b[${targetCol + 1}G`;
1878
+ this.hardwareCursorRow = targetRow;
1879
+ if (this.#lastCursorVisibility !== this.showHardwareCursor) {
1880
+ buffer += this.showHardwareCursor ? "\x1b[?25h" : "\x1b[?25l";
1881
+ this.#lastCursorVisibility = this.showHardwareCursor;
1882
+ }
1562
1883
  }
1563
- // Move to absolute column (1-indexed)
1564
- buffer += `\x1b[${targetCol + 1}G`;
1565
1884
  if (buffer) {
1566
1885
  this.terminal.write(buffer);
1567
1886
  }
1568
- this.hardwareCursorRow = targetRow;
1569
- if (this.showHardwareCursor) {
1570
- this.terminal.showCursor();
1571
- }
1572
- else {
1573
- this.terminal.hideCursor();
1574
- }
1575
1887
  }
1576
1888
  /**
1577
1889
  * Query the terminal's default background color with OSC 11 (`ESC ] 11 ; ? BEL`).