@code-yeongyu/senpi-tui 2026.10.10 → 2026.10.12

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.
@@ -4,10 +4,17 @@ export interface EditorLineCursor {
4
4
  readonly marker: string;
5
5
  readonly drawFakeCursor: boolean;
6
6
  }
7
+ /** A keyword-run piece on one row; `runStart` may be negative when the run began on an earlier row. */
8
+ export interface EditorKeywordRange extends MentionRange {
9
+ readonly runStart: number;
10
+ readonly runLength: number;
11
+ }
7
12
  export interface EditorLineRenderInput {
8
13
  readonly text: string;
9
14
  readonly mentions: readonly MentionRange[];
10
15
  readonly mentionStyle: (text: string) => string;
16
+ readonly keywords?: readonly EditorKeywordRange[];
17
+ readonly keywordStyle?: (text: string, index: number, length: number) => string;
11
18
  readonly cursor: EditorLineCursor | undefined;
12
19
  readonly firstGrapheme: (text: string) => string;
13
20
  }
@@ -16,6 +23,8 @@ export interface EditorLineRenderResult {
16
23
  /** True when a fake cursor cell was appended past the text (adds one column). */
17
24
  readonly cursorAppended: boolean;
18
25
  }
26
+ /** Splits runs found in `lines.join("\n")` into per-logical-line pieces keyed by line index. */
27
+ export declare function keywordRangesByLine(lines: readonly string[], runs: readonly MentionRange[]): Map<number, EditorKeywordRange[]>;
19
28
  /**
20
29
  * Compose one visible editor row: mention ranges get `mentionStyle`, the
21
30
  * cursor grapheme gets reverse video, and every fragment is styled on its
@@ -4,6 +4,39 @@ function clampMentions(mentions, length) {
4
4
  .map((range) => ({ start: Math.max(0, range.start), end: Math.min(length, range.end) }))
5
5
  .filter((range) => range.start < range.end);
6
6
  }
7
+ /** Splits runs found in `lines.join("\n")` into per-logical-line pieces keyed by line index. */
8
+ export function keywordRangesByLine(lines, runs) {
9
+ const byLine = new Map();
10
+ let lineStart = 0;
11
+ for (let line = 0; line < lines.length; line++) {
12
+ const lineEnd = lineStart + (lines[line]?.length ?? 0);
13
+ for (const run of runs) {
14
+ const start = Math.max(run.start, lineStart);
15
+ const end = Math.min(run.end, lineEnd);
16
+ if (start >= end)
17
+ continue;
18
+ const pieces = byLine.get(line) ?? [];
19
+ pieces.push({
20
+ start: start - lineStart,
21
+ end: end - lineStart,
22
+ runStart: run.start - lineStart,
23
+ runLength: run.end - run.start,
24
+ });
25
+ byLine.set(line, pieces);
26
+ }
27
+ lineStart = lineEnd + 1;
28
+ }
29
+ return byLine;
30
+ }
31
+ function styleKeywordSegment(segment, from, range, style) {
32
+ let out = "";
33
+ let offset = from;
34
+ for (const char of segment) {
35
+ out += style(char, offset - range.runStart, range.runLength);
36
+ offset += char.length;
37
+ }
38
+ return out;
39
+ }
7
40
  function cutPoints(length, mentions, cursorGlyph) {
8
41
  const cuts = new Set([0, length]);
9
42
  for (const range of mentions) {
@@ -24,11 +57,16 @@ function cutPoints(length, mentions, cursorGlyph) {
24
57
  export function renderEditorLine(input) {
25
58
  const { text, cursor } = input;
26
59
  const mentions = clampMentions(input.mentions, text.length);
60
+ const keywordStyle = input.keywordStyle;
61
+ const keywords = keywordStyle
62
+ ? (input.keywords ?? []).filter((range) => Math.max(0, range.start) < Math.min(text.length, range.end) &&
63
+ !mentions.some((mention) => range.start < mention.end && mention.start < range.end))
64
+ : [];
27
65
  const cursorInText = cursor !== undefined && cursor.pos < text.length;
28
66
  const cursorGlyph = cursorInText && cursor.drawFakeCursor
29
67
  ? { start: cursor.pos, end: cursor.pos + input.firstGrapheme(text.slice(cursor.pos)).length }
30
68
  : undefined;
31
- const points = cutPoints(text.length, mentions, cursorGlyph);
69
+ const points = cutPoints(text.length, [...mentions, ...clampMentions(keywords, text.length)], cursorGlyph);
32
70
  let out = "";
33
71
  for (let index = 0; index < points.length - 1; index++) {
34
72
  const from = points[index] ?? 0;
@@ -42,6 +80,10 @@ export function renderEditorLine(input) {
42
80
  else if (mentions.some((range) => from >= range.start && to <= range.end)) {
43
81
  out += input.mentionStyle(segment);
44
82
  }
83
+ else if (keywordStyle) {
84
+ const keyword = keywords.find((range) => from >= range.start && to <= range.end);
85
+ out += keyword ? styleKeywordSegment(segment, from, keyword, keywordStyle) : segment;
86
+ }
45
87
  else {
46
88
  out += segment;
47
89
  }
@@ -1,4 +1,4 @@
1
- import type { AutocompleteProvider } from "../autocomplete.ts";
1
+ import type { AutocompleteProvider, MentionRange } from "../autocomplete.ts";
2
2
  import type { EditorSubmitDetails } from "../editor-component.ts";
3
3
  import { type EditorImageState } from "../image-markers.ts";
4
4
  import { type EditorPasteState } from "../paste-markers.ts";
@@ -27,10 +27,22 @@ export interface TextChunk {
27
27
  * @returns Array of chunks with text and position information
28
28
  */
29
29
  export declare function wordWrapLine(line: string, maxWidth: number, preSegmented?: Intl.SegmentData[]): TextChunk[];
30
+ /**
31
+ * Per-character decoration for keyword runs found in the whole editor text
32
+ * (lines joined with "\n"). `style` receives one character plus its offset
33
+ * within the run and the run length, so a gradient stays continuous across
34
+ * wrapped rows and logical lines. Decoration only: it never changes widths.
35
+ */
36
+ export interface EditorKeywordHighlight {
37
+ find: (text: string) => readonly MentionRange[];
38
+ style: (text: string, index: number, length: number) => string;
39
+ }
30
40
  export interface EditorTheme {
31
41
  borderColor: (str: string) => string;
32
42
  /** Style for resolved mention tokens (for example a known `$skill`); omit to render them plain. */
33
43
  mention?: (str: string) => string;
44
+ /** Optional keyword-run decoration; a mention or the cursor grapheme takes precedence over it. */
45
+ keywordHighlight?: EditorKeywordHighlight;
34
46
  /**
35
47
  * Theme for the autocomplete/slash SelectList built by
36
48
  * createAutocompleteList(). May carry the optional `renderRow` seam (see
@@ -61,6 +73,7 @@ export declare class Editor implements Component, Focusable {
61
73
  private renderedVisibleLineCount;
62
74
  private renderedAutocompleteHeight;
63
75
  private scrollOffset;
76
+ private keywordRunsCache?;
64
77
  borderColor: (str: string) => string;
65
78
  private autocompleteProvider?;
66
79
  private autocompleteTriggerCharacters;
@@ -153,6 +166,7 @@ export declare class Editor implements Component, Focusable {
153
166
  private getWrappedLine;
154
167
  protected renderTopBorder(width: number, hiddenLineCount: number): string;
155
168
  protected renderBottomBorder(width: number, hiddenLineCount: number): string;
169
+ private keywordRangesByLine;
156
170
  render(width: number): string[];
157
171
  handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined;
158
172
  handleInput(data: string): void;
@@ -9,7 +9,7 @@ import { CURSOR_MARKER, } from "../tui.js";
9
9
  import { UndoStack } from "../undo-stack.js";
10
10
  import { autocompleteBoundaryRegex, autocompleteSeparatorRegex, cjkBreakRegex, getGraphemeSegmenter, getWordSegmenter, isWhitespaceChar, sliceByColumn, visibleWidth, } from "../utils.js";
11
11
  import { findWordBackward, findWordForward } from "../word-navigation.js";
12
- import { renderEditorLine } from "./editor-line-render.js";
12
+ import { keywordRangesByLine, renderEditorLine } from "./editor-line-render.js";
13
13
  import { SelectList } from "./select-list.js";
14
14
  const graphemeSegmenter = getGraphemeSegmenter();
15
15
  const wordSegmenter = getWordSegmenter();
@@ -461,6 +461,13 @@ export class Editor {
461
461
  const border = hiddenLineCount > 0 ? createScrollBorder("↓", hiddenLineCount, width) : "─".repeat(width);
462
462
  return this.borderColor(border);
463
463
  }
464
+ keywordRangesByLine(highlight) {
465
+ const text = this.state.lines.join("\n");
466
+ if (this.keywordRunsCache?.text !== text) {
467
+ this.keywordRunsCache = { text, byLine: keywordRangesByLine(this.state.lines, highlight.find(text)) };
468
+ }
469
+ return this.keywordRunsCache.byLine;
470
+ }
464
471
  render(width) {
465
472
  const maxPadding = Math.max(0, Math.floor((width - 1) / 2));
466
473
  const paddingX = Math.min(this.paddingX, maxPadding);
@@ -518,6 +525,14 @@ export class Editor {
518
525
  end: range.end - layoutLine.startIndex,
519
526
  }));
520
527
  };
528
+ const keywordHighlight = this.theme.keywordHighlight;
529
+ const keywordRangesByLine = keywordHighlight ? this.keywordRangesByLine(keywordHighlight) : undefined;
530
+ const keywordRangesFor = (layoutLine) => (keywordRangesByLine?.get(layoutLine.logicalLine) ?? []).map((range) => ({
531
+ start: range.start - layoutLine.startIndex,
532
+ end: range.end - layoutLine.startIndex,
533
+ runStart: range.runStart - layoutLine.startIndex,
534
+ runLength: range.runLength,
535
+ }));
521
536
  for (const layoutLine of visibleLines) {
522
537
  let lineVisibleWidth = visibleWidth(layoutLine.text);
523
538
  let cursorInPadding = false;
@@ -529,6 +544,8 @@ export class Editor {
529
544
  text: layoutLine.text,
530
545
  mentions: mentionRangesFor(layoutLine),
531
546
  mentionStyle: mentionStyle ?? ((text) => text),
547
+ keywords: keywordRangesFor(layoutLine),
548
+ keywordStyle: keywordHighlight?.style,
532
549
  cursor,
533
550
  firstGrapheme: (text) => [...this.segment(text, "grapheme")][0]?.segment ?? "",
534
551
  });
package/dist/index.d.ts CHANGED
@@ -5,7 +5,7 @@ export { type AutocompleteItem, type AutocompleteProvider, type AutocompleteSugg
5
5
  export { backgroundAnsi, type Color, type ColorMixSpace, colorToHex, colorToOkhsl, colorToOklch, colorToRgb, foregroundAnsi, type IndexedColor, indexedColor, mixColors, type OkhslChannels, type OklchChannels, type OklchColorValue, okhslColor, oklchColor, parseColor, type RgbColorValue, rgbColor, styleText, styleTextWithAnsi, type TerminalColorMode, type TextAttributes, type TextStyle, } from "./colors.ts";
6
6
  export { Box } from "./components/box.ts";
7
7
  export { CancellableLoader } from "./components/cancellable-loader.ts";
8
- export { Editor, type EditorOptions, type EditorTheme, } from "./components/editor.ts";
8
+ export { Editor, type EditorKeywordHighlight, type EditorOptions, type EditorTheme, } from "./components/editor.ts";
9
9
  export { HStack } from "./components/h-stack.ts";
10
10
  export { Image, type ImageOptions, type ImageTheme } from "./components/image.ts";
11
11
  export { Input } from "./components/input.ts";
package/dist/tui.d.ts CHANGED
@@ -466,6 +466,16 @@ export declare abstract class TuiBase extends Container {
466
466
  setTerminalColorSchemeNotifications(enabled: boolean): void;
467
467
  private queryCellSize;
468
468
  stop(options?: TuiStopOptions): void;
469
+ /**
470
+ * While a reply streams, a frame that changes rows above the viewport would replay the whole scrollback
471
+ * (ESC[3J and a full rewrite), which throws a user who scrolled up back to the top (#2836). With the hold
472
+ * on, such a frame repaints only the viewport and leaves the off-screen rows stale. The terminal cannot
473
+ * report its scroll position in main-screen mode, so the catch-up replay waits for a moment the user is
474
+ * surely at the bottom: their next keypress, or `catchUpScrollback()` (the next turn start).
475
+ */
476
+ setScrollbackReplayHold(hold: boolean | "until-input"): void;
477
+ /** Replays the scrollback once if a held frame left rows above the viewport stale. */
478
+ catchUpScrollback(): void;
469
479
  renderNow(force?: boolean): void;
470
480
  requestRender(force?: boolean, source?: string): void;
471
481
  /**
@@ -539,6 +549,14 @@ export declare abstract class TuiBase extends Container {
539
549
  private createViewportInsertScrollPlan;
540
550
  private renderViewportInsertScroll;
541
551
  private renderScrollbackReplay;
552
+ /**
553
+ * The held frame of `setScrollbackReplayHold` (#2836): rows above the old viewport stay as the terminal
554
+ * shows them (possibly stale), and the frame is rewritten from the old viewport top down. Rows the
555
+ * document grew by scroll naturally into scrollback in their current form, so nothing is lost there and
556
+ * nothing is cleared, and a reader who scrolled up keeps their place. Returns false when an image row is
557
+ * involved, so the caller replays as before.
558
+ */
559
+ private renderHeldRepaint;
542
560
  private renderMuxViewportRepaint;
543
561
  /** Splice overlay content into a base line at a specific column. Single-pass optimized. */
544
562
  private compositeLineAt;
package/dist/tui.js CHANGED
@@ -624,9 +624,15 @@ export function isViewportTUI(tui) {
624
624
  return tui[VIEWPORT_TUI] === true;
625
625
  }
626
626
  export class TuiBase extends Container {
627
+ /** A multiplexer pane regained focus: the next frame repaints its viewport once (senpi#1704). */
628
+ #muxViewportRepaintPending;
627
629
  /** Minimum interval between scheduled renders. Default preserves the historic ~60fps cap. */
628
630
  #minRenderIntervalMs;
629
631
  #lastCursorVisibility;
632
+ #holdScrollbackReplay;
633
+ #releaseHoldOnInput;
634
+ #scrollbackStale;
635
+ #scrollbackCatchUpPending;
630
636
  get hasOverlayEntries() {
631
637
  return this.overlayStack.length > 0;
632
638
  }
@@ -637,6 +643,8 @@ export class TuiBase extends Container {
637
643
  this.previousRawLines = [];
638
644
  this.normalizeMemo = new Map();
639
645
  this.previousKittyImageIds = new Set();
646
+ /** A multiplexer pane regained focus: the next frame repaints its viewport once (senpi#1704). */
647
+ this.#muxViewportRepaintPending = false;
640
648
  this.previousWidth = 0;
641
649
  this.previousHeight = 0;
642
650
  this.focusedComponent = null;
@@ -663,6 +671,10 @@ export class TuiBase extends Container {
663
671
  this.pendingTerminalColorQueries = [];
664
672
  this.terminalColorSchemeListeners = new Set();
665
673
  this.terminalColorSchemeNotificationsEnabled = false;
674
+ this.#holdScrollbackReplay = false;
675
+ this.#releaseHoldOnInput = false;
676
+ this.#scrollbackStale = false;
677
+ this.#scrollbackCatchUpPending = false;
666
678
  // Overlay stack for modal components rendered on top of base content
667
679
  this.focusOrderCounter = 0;
668
680
  this.overlayStack = [];
@@ -1260,6 +1272,11 @@ export class TuiBase extends Container {
1260
1272
  }
1261
1273
  stop(options = {}) {
1262
1274
  this.stopped = true;
1275
+ // The hold itself belongs to the turn (interactive mode sets and releases it); a stop()/start() handover
1276
+ // for an external editor or a suspend in the middle of a turn must not drop it.
1277
+ this.#scrollbackStale = false;
1278
+ this.#scrollbackCatchUpPending = false;
1279
+ this.#muxViewportRepaintPending = false;
1263
1280
  this.renderRequested = false;
1264
1281
  this.inputRenderPending = false;
1265
1282
  this.cancelRenderTimer();
@@ -1303,6 +1320,25 @@ export class TuiBase extends Container {
1303
1320
  this.maxLinesRendered = 0;
1304
1321
  this.previousViewportTop = 0;
1305
1322
  }
1323
+ /**
1324
+ * While a reply streams, a frame that changes rows above the viewport would replay the whole scrollback
1325
+ * (ESC[3J and a full rewrite), which throws a user who scrolled up back to the top (#2836). With the hold
1326
+ * on, such a frame repaints only the viewport and leaves the off-screen rows stale. The terminal cannot
1327
+ * report its scroll position in main-screen mode, so the catch-up replay waits for a moment the user is
1328
+ * surely at the bottom: their next keypress, or `catchUpScrollback()` (the next turn start).
1329
+ */
1330
+ setScrollbackReplayHold(hold) {
1331
+ this.#holdScrollbackReplay = hold !== false;
1332
+ this.#releaseHoldOnInput = hold === "until-input";
1333
+ }
1334
+ /** Replays the scrollback once if a held frame left rows above the viewport stale. */
1335
+ catchUpScrollback() {
1336
+ if (!this.#scrollbackStale)
1337
+ return;
1338
+ this.#scrollbackStale = false;
1339
+ this.#scrollbackCatchUpPending = true;
1340
+ this.requestRender();
1341
+ }
1306
1342
  renderNow(force = false) {
1307
1343
  if (force)
1308
1344
  this.resetForcedRenderState();
@@ -1366,6 +1402,7 @@ export class TuiBase extends Container {
1366
1402
  }
1367
1403
  /** Drop every cached frame so the next render repaints from a clean slate. */
1368
1404
  resetForcedRenderState() {
1405
+ this.#scrollbackCatchUpPending = false;
1369
1406
  this.resetRenderState();
1370
1407
  this.dropPreviousLines();
1371
1408
  this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
@@ -1422,9 +1459,22 @@ export class TuiBase extends Container {
1422
1459
  if (this.mode !== "fullscreen") {
1423
1460
  const focus = consumeTmuxFocusEvent(data);
1424
1461
  if (focus.event !== null) {
1425
- resetCapabilitiesCache();
1426
- this.invalidate();
1427
- this.requestRender(true);
1462
+ if (this.shouldPreserveMuxScrollback()) {
1463
+ // senpi#1704: a forced render re-emitted the whole transcript into the pane's history on every
1464
+ // focus event. A pane losing focus is not visible, so it repaints nothing; a pane gaining focus
1465
+ // refreshes the terminal capabilities and repaints its viewport only.
1466
+ if (focus.event === "in") {
1467
+ resetCapabilitiesCache();
1468
+ this.invalidate();
1469
+ this.#muxViewportRepaintPending = true;
1470
+ this.requestRender();
1471
+ }
1472
+ }
1473
+ else {
1474
+ resetCapabilitiesCache();
1475
+ this.invalidate();
1476
+ this.requestRender(true);
1477
+ }
1428
1478
  if (focus.data.length === 0)
1429
1479
  return;
1430
1480
  data = focus.data;
@@ -1436,6 +1486,15 @@ export class TuiBase extends Container {
1436
1486
  if (this.consumeTerminalColorSchemeReport(data)) {
1437
1487
  return;
1438
1488
  }
1489
+ // A key press means the user is at the bottom again. Terminal reports (mouse/wheel, OSC/DCS replies,
1490
+ // DEC private reports, window and cell-size reports) are not the user and must not trigger it.
1491
+ if (!isTerminalReport(data)) {
1492
+ if (this.#releaseHoldOnInput) {
1493
+ this.#releaseHoldOnInput = false;
1494
+ this.#holdScrollbackReplay = false;
1495
+ }
1496
+ this.catchUpScrollback();
1497
+ }
1439
1498
  if (this.inputListeners.size > 0) {
1440
1499
  let current = data;
1441
1500
  for (const listener of this.inputListeners) {
@@ -2110,6 +2169,7 @@ export class TuiBase extends Container {
2110
2169
  this.placementEpoch++;
2111
2170
  }
2112
2171
  renderScrollbackReplay(newLines, rawLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow) {
2172
+ this.#scrollbackStale = false;
2113
2173
  let buffer = TUI.FRAME_BEGIN;
2114
2174
  buffer += this.deleteKittyImages(this.previousKittyImageIds);
2115
2175
  if (!this.shouldPreserveMuxScrollback()) {
@@ -2137,16 +2197,59 @@ export class TuiBase extends Container {
2137
2197
  this.previousHeight = height;
2138
2198
  this.placementEpoch++;
2139
2199
  }
2140
- renderMuxViewportRepaint(newLines, rawLines, cursorPos, width, height, viewportTop = Math.max(0, newLines.length - height)) {
2200
+ /**
2201
+ * The held frame of `setScrollbackReplayHold` (#2836): rows above the old viewport stay as the terminal
2202
+ * shows them (possibly stale), and the frame is rewritten from the old viewport top down. Rows the
2203
+ * document grew by scroll naturally into scrollback in their current form, so nothing is lost there and
2204
+ * nothing is cleared, and a reader who scrolled up keeps their place. Returns false when an image row is
2205
+ * involved, so the caller replays as before.
2206
+ */
2207
+ renderHeldRepaint(newLines, rawLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow) {
2208
+ const start = Math.min(prevViewportTop, Math.max(0, newLines.length - height));
2209
+ const rowCount = Math.max(height, newLines.length - start);
2210
+ const rows = Array.from({ length: rowCount }, (_, index) => newLines[start + index] ?? "");
2211
+ const previousVisible = this.getViewportRows(this.previousLines, prevViewportTop, height);
2212
+ if (rows.some(isImageLine) || previousVisible.some(isImageLine))
2213
+ return false;
2214
+ let buffer = TUI.FRAME_BEGIN;
2215
+ const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));
2216
+ if (currentScreenRow > 0)
2217
+ buffer += `\x1b[${currentScreenRow}A`;
2218
+ for (let row = 0; row < rows.length; row++) {
2219
+ if (row > 0)
2220
+ buffer += "\r\n";
2221
+ buffer += `\r\x1b[2K${TUI.SEGMENT_RESET}`;
2222
+ buffer += rows[row];
2223
+ }
2224
+ const lastRow = start + rows.length - 1;
2225
+ buffer = this.finishFrame(buffer, cursorPos, newLines.length, lastRow);
2226
+ writeBounded(this.terminal, buffer);
2227
+ this.#scrollbackStale = true;
2228
+ this.cursorRow = Math.max(0, newLines.length - 1);
2229
+ this.maxLinesRendered = newLines.length;
2230
+ this.previousViewportTop = Math.max(0, lastRow + 1 - height);
2231
+ this.setPreviousLines(newLines, rawLines);
2232
+ this.previousKittyImageIds = this.collectKittyImageIds(newLines);
2233
+ this.previousWidth = width;
2234
+ this.previousHeight = height;
2235
+ this.placementEpoch++;
2236
+ return true;
2237
+ }
2238
+ renderMuxViewportRepaint(newLines, rawLines, cursorPos, width, height, viewportTop = Math.max(0, newLines.length - height), home = "relative") {
2141
2239
  const previousVisible = this.getViewportRows(this.previousLines, this.previousViewportTop, height);
2142
2240
  const nextVisible = this.getViewportRows(newLines, viewportTop, height);
2143
2241
  if (previousVisible.some(isImageLine) || nextVisible.some(isImageLine)) {
2144
2242
  return false;
2145
2243
  }
2146
2244
  let buffer = TUI.FRAME_BEGIN;
2147
- const currentScreenRow = Math.max(0, Math.min(height - 1, this.hardwareCursorRow - this.previousViewportTop));
2148
- if (currentScreenRow > 0) {
2149
- buffer += `\x1b[${currentScreenRow}A`;
2245
+ if (home === "absolute") {
2246
+ buffer += "\x1b[H";
2247
+ }
2248
+ else {
2249
+ const currentScreenRow = Math.max(0, Math.min(height - 1, this.hardwareCursorRow - this.previousViewportTop));
2250
+ if (currentScreenRow > 0) {
2251
+ buffer += `\x1b[${currentScreenRow}A`;
2252
+ }
2150
2253
  }
2151
2254
  for (let row = 0; row < height; row++) {
2152
2255
  if (row > 0)
@@ -2293,11 +2396,24 @@ export class TuiBase extends Container {
2293
2396
  const normalizedLines = this.applyViewportLineResets(rawLines, prevViewportTop, height, !widthChanged && !heightChanged);
2294
2397
  newLines = normalizedLines.lines;
2295
2398
  const preserveMuxScrollback = this.shouldPreserveMuxScrollback();
2399
+ if (this.#scrollbackCatchUpPending) {
2400
+ this.#scrollbackCatchUpPending = false;
2401
+ if (!preserveMuxScrollback && !widthChanged && !heightChanged) {
2402
+ this.renderScrollbackReplay(newLines, rawLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow);
2403
+ return;
2404
+ }
2405
+ // A resize frame takes its own path below; if that path does not rewrite the scrollback, the
2406
+ // rows are still stale and the next key press catches up.
2407
+ if (!preserveMuxScrollback)
2408
+ this.#scrollbackStale = true;
2409
+ }
2296
2410
  // Helper to clear scrollback and viewport and render all new lines
2297
2411
  const fullRender = (clear, clearScrollback = clear) => {
2298
2412
  this.fullRedrawCount += 1;
2299
2413
  let buffer = TUI.FRAME_BEGIN;
2300
2414
  if (clear) {
2415
+ if (clearScrollback)
2416
+ this.#scrollbackStale = false;
2301
2417
  buffer += this.deleteKittyImages(this.previousKittyImageIds);
2302
2418
  buffer += "\x1b[2J\x1b[H";
2303
2419
  if (clearScrollback && !preserveMuxScrollback && process.platform !== "win32") {
@@ -2362,11 +2478,37 @@ export class TuiBase extends Container {
2362
2478
  }
2363
2479
  // Width changes always need a full re-render because wrapping changes.
2364
2480
  if (widthChanged) {
2481
+ // A width change (or a forced render) repaints everything below; a focus repaint queued in the
2482
+ // same tick is satisfied by it.
2483
+ this.#muxViewportRepaintPending = false;
2365
2484
  logRedraw(`terminal width changed (${this.previousWidth} -> ${width})`);
2366
- // In multiplexers, re-emit the viewport without 3J so pane history survives; an older copy may remain above.
2485
+ // In a multiplexer only the re-wrapped viewport is repainted: re-emitting every line of the buffer scrolled a
2486
+ // copy of the whole transcript into the pane's history on each width change (senpi#1704). Rows already in
2487
+ // that history keep their old wrapping. The pane re-wrapped the screen itself, so the repaint homes there.
2488
+ if (preserveMuxScrollback && this.previousWidth > 0) {
2489
+ if (this.renderMuxViewportRepaint(newLines, rawLines, cursorPos, width, height, undefined, "absolute"))
2490
+ return;
2491
+ }
2367
2492
  fullRender(true, !preserveMuxScrollback);
2368
2493
  return;
2369
2494
  }
2495
+ if (this.#muxViewportRepaintPending) {
2496
+ this.#muxViewportRepaintPending = false;
2497
+ if (preserveMuxScrollback && !heightChanged) {
2498
+ logRedraw("multiplexer pane focus regained");
2499
+ // A frame queued before the focus event may have grown the content: follow it exactly as an
2500
+ // ordinary frame does, so rows added below (the editor, the status line) stay on screen.
2501
+ // After a shrink the viewport keeps its top (as the deleted-lines path does), so the repaint does
2502
+ // not scroll rows already in the pane's history into view a second time.
2503
+ const focusViewportTop = newLines.length > prevViewportTop
2504
+ ? Math.max(prevViewportTop, newLines.length - height)
2505
+ : Math.max(0, newLines.length - height);
2506
+ if (!this.renderMuxViewportRepaint(newLines, rawLines, cursorPos, width, height, focusViewportTop)) {
2507
+ fullRender(true, false);
2508
+ }
2509
+ return;
2510
+ }
2511
+ }
2370
2512
  // Height changes normally need a full re-render to keep the visible viewport aligned,
2371
2513
  // but Termux changes height when the software keyboard shows or hides.
2372
2514
  // In that environment, a full redraw causes the entire history to replay on every toggle.
@@ -2514,6 +2656,10 @@ export class TuiBase extends Container {
2514
2656
  fullRender(true, false);
2515
2657
  }
2516
2658
  }
2659
+ else if (this.#holdScrollbackReplay &&
2660
+ this.renderHeldRepaint(newLines, rawLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow)) {
2661
+ // Held: rows above the old viewport stay stale until the catch-up replay.
2662
+ }
2517
2663
  else {
2518
2664
  this.renderScrollbackReplay(newLines, rawLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow);
2519
2665
  }
@@ -2539,6 +2685,10 @@ export class TuiBase extends Container {
2539
2685
  fullRender(true, false);
2540
2686
  }
2541
2687
  }
2688
+ else if (this.#holdScrollbackReplay &&
2689
+ this.renderHeldRepaint(newLines, rawLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow)) {
2690
+ // Held: rows above the old viewport stay stale until the catch-up replay.
2691
+ }
2542
2692
  else {
2543
2693
  this.renderScrollbackReplay(newLines, rawLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow);
2544
2694
  }
@@ -2679,7 +2829,7 @@ export class TuiBase extends Container {
2679
2829
  }
2680
2830
  buffer = this.finishFrame(buffer, cursorPos, newLines.length, finalCursorRow);
2681
2831
  if (process.env.PI_TUI_DEBUG === "1") {
2682
- const debugDir = "/tmp/tui";
2832
+ const debugDir = path.join(os.tmpdir(), "tui");
2683
2833
  fs.mkdirSync(debugDir, { recursive: true });
2684
2834
  const debugPath = path.join(debugDir, `render-${Date.now()}-${Math.random().toString(36).slice(2)}.log`);
2685
2835
  const debugData = [
@@ -2793,6 +2943,18 @@ export class TuiBase extends Container {
2793
2943
  });
2794
2944
  }
2795
2945
  }
2946
+ /**
2947
+ * Input that the terminal sends on its own rather than the user typing: mouse reports, OSC/DCS/APC
2948
+ * replies, DEC private reports (`ESC[?...`) and window/cell-size reports (`ESC[...t`). OSC/DCS/APC need
2949
+ * a body after the introducer, so a legacy Alt+] / Alt+Shift+P / Alt+_ key press is still a key.
2950
+ */
2951
+ function isTerminalReport(data) {
2952
+ return (data.startsWith("\x1b[<") ||
2953
+ data.startsWith("\x1b[M") ||
2954
+ (data.length > 2 && (data.startsWith("\x1b]") || data.startsWith("\x1bP") || data.startsWith("\x1b_"))) ||
2955
+ data.startsWith("\x1b[?") ||
2956
+ /^\x1b\[\d+(;\d+)*t$/.test(data));
2957
+ }
2796
2958
  /** Legacy main-screen renderer export. */
2797
2959
  export class TUI extends TuiBase {
2798
2960
  constructor() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-tui",
3
- "version": "2026.10.10",
3
+ "version": "2026.10.12",
4
4
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -10,7 +10,7 @@
10
10
  "build:native:darwin": "native/darwin/build.sh",
11
11
  "build:native:linux": "native/linux/build.sh",
12
12
  "build:native:win32": "node native/win32/build.mjs",
13
- "test": "node --test --import tsx --import ./test/setup-multiplexer-env.mjs --test-reporter=dot --test-reporter-destination=stdout test/*.test.ts",
13
+ "test": "node ../../scripts/run-tests.mjs node --test --import tsx --import ./test/setup-multiplexer-env.mjs --test-reporter=dot --test-reporter-destination=stdout test/*.test.ts",
14
14
  "prepublishOnly": "npm run clean && npm run build",
15
15
  "bench:frame-cost": "tsx bench/frame-cost.ts"
16
16
  },