@oh-my-pi/pi-tui 17.0.4 → 17.0.5

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.0.5] - 2026-07-18
6
+
7
+ ### Changed
8
+
9
+ - Improved rendering performance across text, box, editor, and frame layouts by caching validated line widths and avoiding redundant Unicode width measurements.
10
+
11
+ ### Fixed
12
+
13
+ - Fixed a performance issue where typing in the editor triggered a full UI re-render, significantly improving keystroke responsiveness.
14
+ - Restored text wrapping for long descriptions in the slash-command autocomplete picker to ensure readability at standard terminal widths.
15
+ - Prevented temporary dashboard frame updates from cluttering the terminal's native scrollback history.
16
+ - Added support for cleaning up tracked Kitty graphics, allowing inline images to be properly deleted before falling back to text.
17
+ - Fixed an issue where resizing or growing a multiplexer pane would incorrectly overwrite newly exposed rows with blank padding.
18
+
5
19
  ## [17.0.3] - 2026-07-17
6
20
 
7
21
  ### Fixed
@@ -82,17 +82,22 @@ export interface OverlayFocusOwner {
82
82
  * FINAL — byte-stable at the current width for the component's lifetime — and
83
83
  * commit to native scrollback as exact, audited content. Rows at/after the
84
84
  * boundary repaint in place inside the visible window; when they scroll above
85
- * the window top they still commit the tape records what was on screen —
86
- * but as frozen visual snapshots that are permanently audit-exempt: later
87
- * re-layout of their source never re-anchors or recommits them. A root that
88
- * reports no seam commits everything that scrolls as final (shell semantics).
85
+ * the window top they normally commit as frozen visual snapshots.
86
+ *
87
+ * A viewport-pinned region opts out of those mutable snapshot commits. Its
88
+ * offscreen mutable rows are virtually clipped until the boundary advances;
89
+ * use this for fixed-height dashboards whose frames replace each other rather
90
+ * than append. A root that reports no seam commits everything that scrolls as
91
+ * final (shell semantics).
89
92
  *
90
93
  * When several root children report a seam in the same frame, the topmost one
91
- * defines the boundary: exactness is prefix-only, so everything below the
92
- * first seam is already excluded.
94
+ * defines the boundary and pinning policy: commits are prefix-only, so
95
+ * everything below the first seam is already excluded.
93
96
  */
94
97
  export interface NativeScrollbackLiveRegion {
95
98
  getNativeScrollbackLiveRegionStart(): number | undefined;
99
+ /** Keeps the mutable suffix viewport-local instead of recording frozen snapshots. */
100
+ isNativeScrollbackLiveRegionPinned?(): boolean;
96
101
  }
97
102
  export interface NativeScrollbackCommittedRows {
98
103
  setNativeScrollbackCommittedRows(rows: number): void;
@@ -359,6 +364,8 @@ export declare class TUI extends Container {
359
364
  * plus a full redraw on the frame after a new image exceeds the cap.
360
365
  */
361
366
  setMaxInlineImages(cap: number): void;
367
+ /** Delete every tracked Kitty image from the terminal graphics store. */
368
+ clearInlineImages(): void;
362
369
  /**
363
370
  * Get whether scrollback divergence rebuild is enabled.
364
371
  */
@@ -413,6 +420,14 @@ export declare class TUI extends Container {
413
420
  */
414
421
  resetDisplay(): void;
415
422
  requestRender(force?: boolean, options?: RenderRequestOptions): void;
423
+ /**
424
+ * Opt `component` into subtree-only renders when input leaves focus stable.
425
+ *
426
+ * The host must explicitly request renders for every sibling mutated by the
427
+ * component's input callbacks. Components without this opt-in retain the
428
+ * legacy full-root render after input.
429
+ */
430
+ enableScopedInputRender(component: Component): void;
416
431
  /**
417
432
  * Schedule a render on behalf of `component` after a self-contained change
418
433
  * (spinner frame, blink) that cannot have affected any other component.
@@ -3,6 +3,11 @@ export { Ellipsis } from "@oh-my-pi/pi-natives";
3
3
  export { DEFAULT_TAB_WIDTH } from "@oh-my-pi/pi-utils";
4
4
  export type HangulCompatibilityJamoWidth = "platform" | "unicode" | 1 | 2;
5
5
  export declare function getHangulCompatibilityJamoWidth(): HangulCompatibilityJamoWidth;
6
+ export declare function getWidthConfigEpoch(): number;
7
+ /** Publish exact per-line visible widths for a rendered lines array. */
8
+ export declare function publishLineWidths(lines: readonly string[], widths: readonly number[]): void;
9
+ /** Exact per-line visible widths for an unchanged `lines` array under the current width config. */
10
+ export declare function getPublishedLineWidths(lines: readonly string[]): readonly number[] | undefined;
6
11
  export declare function setHangulCompatibilityJamoWidth(width: HangulCompatibilityJamoWidth): boolean;
7
12
  export declare function resetHangulCompatibilityJamoWidthForTests(): void;
8
13
  export type TextSizingScale = 1 | 2 | 3;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "17.0.4",
4
+ "version": "17.0.5",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -37,8 +37,8 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/pi-natives": "17.0.4",
41
- "@oh-my-pi/pi-utils": "17.0.4",
40
+ "@oh-my-pi/pi-natives": "17.0.5",
41
+ "@oh-my-pi/pi-utils": "17.0.5",
42
42
  "lru-cache": "11.5.2",
43
43
  "marked": "^18.0.6"
44
44
  },
@@ -1,11 +1,21 @@
1
1
  import type { Component } from "../tui";
2
- import { applyBackgroundToLine, getPaddingX, padding, visibleWidth } from "../utils";
2
+ import {
3
+ getPaddingX,
4
+ getPublishedLineWidths,
5
+ getWidthConfigEpoch,
6
+ padding,
7
+ publishLineWidths,
8
+ visibleWidth,
9
+ } from "../utils";
3
10
 
4
11
  type Cache = {
5
12
  width: number;
13
+ widthEpoch: number;
6
14
  bgSample: string | undefined;
7
15
  borderSample: string | undefined;
8
16
  childLines: (readonly string[])[];
17
+ childWidths: (readonly number[] | undefined)[];
18
+ childSnapshots: (readonly string[] | undefined)[];
9
19
  result: string[];
10
20
  };
11
21
 
@@ -122,43 +132,74 @@ export class Box implements Component {
122
132
  : undefined;
123
133
 
124
134
  // Render every child every frame (renders may carry side effects); the
125
- // memo only skips re-deriving the padded/background rows. Per the
126
- // Component render contract, identical child array references prove the
127
- // content is unchanged.
135
+ // memo only skips re-deriving the padded/background rows.
136
+ const widthEpoch = getWidthConfigEpoch();
137
+ let contentRows = 0;
138
+ const childLines = children.map(child => {
139
+ const lines = child.render(contentWidth);
140
+ contentRows += lines.length;
141
+ return lines;
142
+ });
143
+ const childWidths = childLines.map(lines => getPublishedLineWidths(lines));
128
144
  const cached = this.#cached;
129
- let unchanged =
145
+ if (
130
146
  cached !== undefined &&
131
147
  cached.width === width &&
148
+ cached.widthEpoch === widthEpoch &&
149
+ cached.widthEpoch === getWidthConfigEpoch() &&
132
150
  cached.bgSample === bgSample &&
133
151
  cached.borderSample === borderSample &&
134
- cached.childLines.length === count;
135
- const childLines: (readonly string[])[] = new Array(count);
136
- let contentRows = 0;
137
- for (let i = 0; i < count; i++) {
138
- const lines = children[i]!.render(contentWidth);
139
- childLines[i] = lines;
140
- contentRows += lines.length;
141
- if (unchanged && cached!.childLines[i] !== lines) unchanged = false;
152
+ cached.childLines.length === count &&
153
+ childLines.every((lines, i) => {
154
+ if (cached.childLines[i] !== lines) return false;
155
+ const published = childWidths[i];
156
+ const cachedPublished = cached.childWidths[i];
157
+ if (published !== undefined || cachedPublished !== undefined) {
158
+ return published === cachedPublished;
159
+ }
160
+ const snapshot = cached.childSnapshots[i];
161
+ return (
162
+ snapshot !== undefined &&
163
+ snapshot.length === lines.length &&
164
+ lines.every((line, j) => snapshot[j] === line)
165
+ );
166
+ })
167
+ ) {
168
+ return cached.result;
142
169
  }
143
- if (unchanged) return cached!.result;
144
170
 
145
171
  const result: string[] = [];
172
+ // Exact visible widths of `result` rows, published only when the row
173
+ // bytes are `content + spaces` (no bg/border transform of unknown width).
174
+ const resultWidths: number[] | undefined = !border && !this.#bgFn ? [] : undefined;
146
175
  if (contentRows > 0) {
147
176
  const leftPad = padding(paddingX);
148
177
  const interior: string[] = [];
178
+ const pushRow = (row: string, visLen: number): void => {
179
+ const padNeeded = Math.max(0, innerWidth - visLen);
180
+ const padded = padNeeded > 0 ? row + padding(padNeeded) : row;
181
+ interior.push(this.#bgFn ? this.#bgFn(padded) : padded);
182
+ resultWidths?.push(visLen + padNeeded);
183
+ };
149
184
  // Top padding
150
185
  for (let i = 0; i < this.#paddingY; i++) {
151
- interior.push(this.#applyBg("", innerWidth));
186
+ pushRow("", 0);
152
187
  }
153
188
  // Content
189
+ let childIndex = 0;
154
190
  for (const lines of childLines) {
155
- for (const line of lines) {
156
- interior.push(this.#applyBg(leftPad + line, innerWidth));
191
+ const widths = childWidths[childIndex++];
192
+ for (let j = 0; j < lines.length; j++) {
193
+ const line = lines[j] ?? "";
194
+ const row = paddingX > 0 ? leftPad + line : line;
195
+ const carried = widths?.[j];
196
+ const visLen = carried !== undefined && paddingX === 0 ? carried : visibleWidth(row);
197
+ pushRow(row, visLen);
157
198
  }
158
199
  }
159
200
  // Bottom padding
160
201
  for (let i = 0; i < this.#paddingY; i++) {
161
- interior.push(this.#applyBg("", innerWidth));
202
+ pushRow("", 0);
162
203
  }
163
204
 
164
205
  if (border) {
@@ -177,18 +218,19 @@ export class Box implements Component {
177
218
  }
178
219
  }
179
220
 
180
- this.#cached = { width, bgSample, borderSample, childLines, result };
221
+ const finalWidthEpoch = getWidthConfigEpoch();
222
+ if (resultWidths !== undefined) publishLineWidths(result, resultWidths);
223
+ const childSnapshots = childLines.map((lines, i) => (childWidths[i] === undefined ? [...lines] : undefined));
224
+ this.#cached = {
225
+ width,
226
+ widthEpoch: finalWidthEpoch,
227
+ bgSample,
228
+ borderSample,
229
+ childLines,
230
+ childWidths,
231
+ childSnapshots,
232
+ result,
233
+ };
181
234
  return result;
182
235
  }
183
-
184
- #applyBg(line: string, width: number): string {
185
- const visLen = visibleWidth(line);
186
- const padNeeded = Math.max(0, width - visLen);
187
- const padded = line + padding(padNeeded);
188
-
189
- if (this.#bgFn) {
190
- return applyBackgroundToLine(padded, width, this.#bgFn);
191
- }
192
- return padded;
193
- }
194
236
  }
@@ -13,6 +13,7 @@ import type { SymbolTheme } from "../symbols";
13
13
  import { type Component, CURSOR_MARKER, type Focusable } from "../tui";
14
14
  import {
15
15
  getSegmenter,
16
+ getWidthConfigEpoch,
16
17
  getWordNavKind,
17
18
  moveWordLeft,
18
19
  moveWordRight,
@@ -31,6 +32,7 @@ const AUTOCOMPLETE_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
31
32
  const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
32
33
  minPrimaryColumnWidth: 12,
33
34
  maxPrimaryColumnWidth: 32,
35
+ wrapDescription: true,
34
36
  overflowSearch: false,
35
37
  };
36
38
 
@@ -43,12 +45,15 @@ const segmenter = getSegmenter();
43
45
 
44
46
  /**
45
47
  * Represents a chunk of text for word-wrap layout.
46
- * Tracks both the text content and its position in the original line.
48
+ * Tracks the text content, its position in the original line, and its exact
49
+ * visible width (`width === visibleWidth(text)`, measured at build time) so
50
+ * layout/render never re-measure cached chunks.
47
51
  */
48
52
  interface TextChunk {
49
53
  text: string;
50
54
  startIndex: number;
51
55
  endIndex: number;
56
+ width: number;
52
57
  }
53
58
 
54
59
  /**
@@ -56,92 +61,121 @@ interface TextChunk {
56
61
  * Wraps at word boundaries when possible, falling back to character-level
57
62
  * wrapping for words longer than the available width.
58
63
  *
64
+ * Widths are carried, never recomputed: the line is segmented exactly once,
65
+ * per-grapheme widths are measured lazily at most once each, and every chunk
66
+ * is a contiguous slice of `line` (no incremental string concatenation).
67
+ *
59
68
  * @param line - The text line to wrap
60
69
  * @param maxWidth - Maximum visible width per chunk
61
- * @returns Array of chunks with text and position information
70
+ * @param knownLineWidth - Caller-carried exact `visibleWidth(line)`, if already measured
71
+ * @returns Array of chunks with text, position, and exact visible width
62
72
  */
63
- function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
73
+ function wordWrapLine(line: string, maxWidth: number, knownLineWidth?: number): TextChunk[] {
64
74
  if (!line || maxWidth <= 0) {
65
- return [{ text: "", startIndex: 0, endIndex: 0 }];
75
+ return [{ text: "", startIndex: 0, endIndex: 0, width: 0 }];
66
76
  }
67
77
 
68
- const lineWidth = visibleWidth(line);
78
+ const lineWidth = knownLineWidth ?? visibleWidth(line);
69
79
  if (lineWidth <= maxWidth) {
70
- return [{ text: line, startIndex: 0, endIndex: line.length }];
80
+ return [{ text: line, startIndex: 0, endIndex: line.length, width: lineWidth }];
71
81
  }
72
82
 
73
- const chunks: TextChunk[] = [];
74
-
75
- // Split into tokens (words and whitespace runs)
76
- const tokens: { text: string; startIndex: number; endIndex: number; isWhitespace: boolean }[] = [];
77
- let currentToken = "";
78
- let tokenStart = 0;
83
+ // Single segmentation pass: grapheme start offsets (with end sentinel),
84
+ // lazily-filled grapheme widths, and word/whitespace token boundaries.
85
+ const gStart: number[] = [];
86
+ const gWidth: number[] = [];
87
+ interface Token {
88
+ startG: number;
89
+ endG: number;
90
+ startIndex: number;
91
+ endIndex: number;
92
+ isWhitespace: boolean;
93
+ }
94
+ const tokens: Token[] = [];
79
95
  let inWhitespace = false;
80
- let charIndex = 0;
81
-
96
+ let tokenStartG = 0;
97
+ let tokenStartIndex = 0;
98
+ let gCount = 0;
82
99
  for (const seg of segmenter.segment(line)) {
83
- const grapheme = seg.segment;
84
- const graphemeIsWhitespace = getWordNavKind(grapheme) === "whitespace";
85
-
86
- if (currentToken === "") {
100
+ const graphemeIsWhitespace = getWordNavKind(seg.segment) === "whitespace";
101
+ if (gCount === 0) {
87
102
  inWhitespace = graphemeIsWhitespace;
88
- tokenStart = charIndex;
89
103
  } else if (graphemeIsWhitespace !== inWhitespace) {
90
- // Token type changed - save current token
104
+ // Token type changed - close the current token
91
105
  tokens.push({
92
- text: currentToken,
93
- startIndex: tokenStart,
94
- endIndex: charIndex,
106
+ startG: tokenStartG,
107
+ endG: gCount,
108
+ startIndex: tokenStartIndex,
109
+ endIndex: seg.index,
95
110
  isWhitespace: inWhitespace,
96
111
  });
97
- currentToken = "";
98
- tokenStart = charIndex;
112
+ tokenStartG = gCount;
113
+ tokenStartIndex = seg.index;
99
114
  inWhitespace = graphemeIsWhitespace;
100
115
  }
101
-
102
- currentToken += grapheme;
103
- charIndex += grapheme.length;
116
+ gStart.push(seg.index);
117
+ gWidth.push(-1);
118
+ gCount++;
104
119
  }
105
-
106
- // Push final token
107
- if (currentToken) {
120
+ gStart.push(line.length);
121
+ if (gCount > tokenStartG) {
108
122
  tokens.push({
109
- text: currentToken,
110
- startIndex: tokenStart,
111
- endIndex: charIndex,
123
+ startG: tokenStartG,
124
+ endG: gCount,
125
+ startIndex: tokenStartIndex,
126
+ endIndex: line.length,
112
127
  isWhitespace: inWhitespace,
113
128
  });
114
129
  }
115
130
 
116
- // Build chunks using word wrapping
117
- let currentChunk = "";
118
- let currentWidth = 0;
119
- let chunkStartIndex = 0;
120
- let atLineStart = true; // Track if we're at the start of a line (for skipping whitespace)
131
+ /** Exact `visibleWidth` of grapheme `g`, measured at most once. */
132
+ const graphemeWidth = (g: number): number => {
133
+ let w = gWidth[g] ?? -1;
134
+ if (w < 0) {
135
+ w = visibleWidth(line.slice(gStart[g] ?? 0, gStart[g + 1] ?? line.length));
136
+ gWidth[g] = w;
137
+ }
138
+ return w;
139
+ };
140
+
141
+ const chunks: TextChunk[] = [];
142
+ const pushChunk = (text: string, startIndex: number, endIndex: number): void => {
143
+ chunks.push({ text, startIndex, endIndex, width: visibleWidth(text) });
144
+ };
121
145
 
122
- function consumePrefixToWidth(text: string, availableWidth: number): { text: string; len: number } {
123
- let prefix = "";
146
+ /** Widest grapheme prefix of [startG, endG) that fits `availableWidth`. */
147
+ const consumePrefixToWidth = (
148
+ startG: number,
149
+ endG: number,
150
+ availableWidth: number,
151
+ ): { endG: number; len: number } => {
124
152
  let prefixWidth = 0;
125
- let len = 0;
126
- for (const seg of segmenter.segment(text)) {
127
- const grapheme = seg.segment;
128
- const graphemeWidth = visibleWidth(grapheme);
129
- if (prefixWidth + graphemeWidth > availableWidth) break;
130
- prefix += grapheme;
131
- prefixWidth += graphemeWidth;
132
- len += grapheme.length;
153
+ let g = startG;
154
+ while (g < endG) {
155
+ const w = graphemeWidth(g);
156
+ if (prefixWidth + w > availableWidth) break;
157
+ prefixWidth += w;
158
+ g++;
133
159
  if (prefixWidth === availableWidth) break;
134
160
  }
135
- return { text: prefix, len };
136
- }
137
- function hasWideGrapheme(text: string): boolean {
138
- for (const seg of segmenter.segment(text)) {
139
- if (visibleWidth(seg.segment) > 1) return true;
161
+ return { endG: g, len: (gStart[g] ?? 0) - (gStart[startG] ?? 0) };
162
+ };
163
+ const hasWideGrapheme = (startG: number, endG: number): boolean => {
164
+ for (let g = startG; g < endG; g++) {
165
+ if (graphemeWidth(g) > 1) return true;
140
166
  }
141
167
  return false;
142
- }
168
+ };
169
+
170
+ // Build chunks using word wrapping. The pending chunk is always the
171
+ // contiguous slice line[chunkStart, chunkEnd) with visible width currentWidth.
172
+ let chunkStart = 0;
173
+ let chunkEnd = 0;
174
+ let currentWidth = 0;
175
+ let atLineStart = true; // Track if we're at the start of a line (for skipping whitespace)
176
+
143
177
  for (const token of tokens) {
144
- const tokenWidth = visibleWidth(token.text);
178
+ const tokenWidth = visibleWidth(line.slice(token.startIndex, token.endIndex));
145
179
 
146
180
  // Skip leading whitespace at line start. Keep the skipped run mapped onto the
147
181
  // preceding chunk (when one exists) so every cursor position resolves to a
@@ -149,7 +183,8 @@ function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
149
183
  if (atLineStart && token.isWhitespace) {
150
184
  const prev = chunks[chunks.length - 1];
151
185
  if (prev) prev.endIndex = token.endIndex;
152
- chunkStartIndex = token.endIndex;
186
+ chunkStart = token.endIndex;
187
+ chunkEnd = token.endIndex;
153
188
  continue;
154
189
  }
155
190
  atLineStart = false;
@@ -157,65 +192,49 @@ function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
157
192
  // If this single token is wider than maxWidth, we need to break it
158
193
  if (tokenWidth > maxWidth) {
159
194
  // If we're mid-line, try to use the remaining width by consuming a prefix of this long token.
160
- let consumedPrefix = "";
161
- let consumedPrefixLen = 0; // JS string index (code units) consumed from token.text
162
- if (currentChunk && currentWidth < maxWidth) {
195
+ let consumedPrefixLen = 0; // JS string index (code units) consumed from the token
196
+ let consumedPrefixEndG = token.startG;
197
+ if (chunkEnd > chunkStart && currentWidth < maxWidth) {
163
198
  const remainingWidth = maxWidth - currentWidth;
164
- const consumed = consumePrefixToWidth(token.text, remainingWidth);
165
- consumedPrefix = consumed.text;
199
+ const consumed = consumePrefixToWidth(token.startG, token.endG, remainingWidth);
200
+ consumedPrefixEndG = consumed.endG;
166
201
  consumedPrefixLen = consumed.len;
167
202
  }
168
203
  // First, push any accumulated chunk (optionally filled with the prefix).
169
- if (currentChunk) {
170
- if (consumedPrefix) {
171
- chunks.push({
172
- text: currentChunk + consumedPrefix,
173
- startIndex: chunkStartIndex,
174
- endIndex: token.startIndex + consumedPrefixLen,
175
- });
176
- currentChunk = "";
177
- currentWidth = 0;
178
- chunkStartIndex = token.startIndex + consumedPrefixLen;
204
+ if (chunkEnd > chunkStart) {
205
+ if (consumedPrefixLen > 0) {
206
+ const endIndex = token.startIndex + consumedPrefixLen;
207
+ pushChunk(line.slice(chunkStart, endIndex), chunkStart, endIndex);
208
+ chunkStart = endIndex;
209
+ chunkEnd = endIndex;
179
210
  } else {
180
- chunks.push({
181
- text: currentChunk,
182
- startIndex: chunkStartIndex,
183
- endIndex: token.startIndex,
184
- });
185
- currentChunk = "";
186
- currentWidth = 0;
187
- chunkStartIndex = token.startIndex;
211
+ pushChunk(line.slice(chunkStart, chunkEnd), chunkStart, token.startIndex);
212
+ chunkStart = token.startIndex;
213
+ chunkEnd = token.startIndex;
188
214
  }
215
+ currentWidth = 0;
189
216
  }
190
217
  // Break the remaining long token by grapheme
191
- const remainingText = consumedPrefixLen > 0 ? token.text.slice(consumedPrefixLen) : token.text;
192
- let tokenChunk = "";
193
- let tokenChunkWidth = 0;
194
- let tokenChunkStart = token.startIndex + consumedPrefixLen;
195
- let tokenCharIndex = token.startIndex + consumedPrefixLen;
196
- for (const seg of segmenter.segment(remainingText)) {
197
- const grapheme = seg.segment;
198
- const graphemeWidth = visibleWidth(grapheme);
199
- if (tokenChunkWidth + graphemeWidth > maxWidth && tokenChunk) {
200
- chunks.push({
201
- text: tokenChunk,
202
- startIndex: tokenChunkStart,
203
- endIndex: tokenCharIndex,
204
- });
205
- tokenChunk = grapheme;
206
- tokenChunkWidth = graphemeWidth;
207
- tokenChunkStart = tokenCharIndex;
218
+ let tcStart = token.startIndex + consumedPrefixLen;
219
+ let tcEnd = tcStart;
220
+ let tcWidth = 0;
221
+ for (let g = consumedPrefixEndG; g < token.endG; g++) {
222
+ const w = graphemeWidth(g);
223
+ const gEnd = gStart[g + 1] ?? line.length;
224
+ if (tcWidth + w > maxWidth && tcEnd > tcStart) {
225
+ pushChunk(line.slice(tcStart, tcEnd), tcStart, tcEnd);
226
+ tcStart = tcEnd;
227
+ tcWidth = w;
208
228
  } else {
209
- tokenChunk += grapheme;
210
- tokenChunkWidth += graphemeWidth;
229
+ tcWidth += w;
211
230
  }
212
- tokenCharIndex += grapheme.length;
231
+ tcEnd = gEnd;
213
232
  }
214
233
  // Keep remainder as start of next chunk
215
- if (tokenChunk) {
216
- currentChunk = tokenChunk;
217
- currentWidth = tokenChunkWidth;
218
- chunkStartIndex = tokenChunkStart;
234
+ if (tcEnd > tcStart) {
235
+ chunkStart = tcStart;
236
+ chunkEnd = tcEnd;
237
+ currentWidth = tcWidth;
219
238
  }
220
239
  continue;
221
240
  }
@@ -224,36 +243,34 @@ function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
224
243
  if (currentWidth + tokenWidth > maxWidth) {
225
244
  // For wide-character tokens (e.g., CJK runs), prefer using remaining width before wrapping
226
245
  // the whole token to the next line. This avoids leaving a short ASCII word alone.
227
- if (currentChunk && !token.isWhitespace && currentWidth < maxWidth && hasWideGrapheme(token.text)) {
246
+ if (
247
+ chunkEnd > chunkStart &&
248
+ !token.isWhitespace &&
249
+ currentWidth < maxWidth &&
250
+ hasWideGrapheme(token.startG, token.endG)
251
+ ) {
228
252
  const remainingWidth = maxWidth - currentWidth;
229
- const consumed = consumePrefixToWidth(token.text, remainingWidth);
230
- if (consumed.text) {
231
- chunks.push({
232
- text: currentChunk + consumed.text,
233
- startIndex: chunkStartIndex,
234
- endIndex: token.startIndex + consumed.len,
235
- });
236
- const remainder = token.text.slice(consumed.len);
237
- currentChunk = remainder;
253
+ const consumed = consumePrefixToWidth(token.startG, token.endG, remainingWidth);
254
+ if (consumed.len > 0) {
255
+ const endIndex = token.startIndex + consumed.len;
256
+ pushChunk(line.slice(chunkStart, endIndex), chunkStart, endIndex);
257
+ const remainder = line.slice(endIndex, token.endIndex);
258
+ chunkStart = endIndex;
259
+ chunkEnd = token.endIndex;
238
260
  currentWidth = visibleWidth(remainder);
239
- chunkStartIndex = token.startIndex + consumed.len;
240
261
  atLineStart = false;
241
262
  continue;
242
263
  }
243
264
  }
244
265
  // Push current chunk (trimming trailing whitespace for display)
245
- const trimmedChunk = currentChunk.trimEnd();
266
+ const trimmedChunk = line.slice(chunkStart, chunkEnd).trimEnd();
246
267
  if (trimmedChunk || chunks.length === 0) {
247
- chunks.push({
248
- text: trimmedChunk,
249
- startIndex: chunkStartIndex,
250
- endIndex: chunkStartIndex + currentChunk.length,
251
- });
268
+ pushChunk(trimmedChunk, chunkStart, chunkEnd);
252
269
  } else {
253
270
  // All-whitespace chunk collapsed away: keep its span mapped on the
254
271
  // previous chunk so cursor positions inside it stay addressable.
255
272
  const prev = chunks[chunks.length - 1];
256
- if (prev) prev.endIndex = chunkStartIndex + currentChunk.length;
273
+ if (prev) prev.endIndex = chunkEnd;
257
274
  }
258
275
  // Start new line - skip leading whitespace
259
276
  atLineStart = true;
@@ -262,32 +279,29 @@ function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
262
279
  // point; otherwise cursor positions inside it map to no layout line.
263
280
  const prev = chunks[chunks.length - 1];
264
281
  if (prev) prev.endIndex = token.endIndex;
265
- currentChunk = "";
282
+ chunkStart = token.endIndex;
283
+ chunkEnd = token.endIndex;
266
284
  currentWidth = 0;
267
- chunkStartIndex = token.endIndex;
268
285
  } else {
269
- currentChunk = token.text;
286
+ chunkStart = token.startIndex;
287
+ chunkEnd = token.endIndex;
270
288
  currentWidth = tokenWidth;
271
- chunkStartIndex = token.startIndex;
272
289
  atLineStart = false;
273
290
  }
274
291
  } else {
275
292
  // Add token to current chunk
276
- currentChunk += token.text;
293
+ if (chunkEnd === chunkStart) chunkStart = token.startIndex;
294
+ chunkEnd = token.endIndex;
277
295
  currentWidth += tokenWidth;
278
296
  }
279
297
  }
280
298
 
281
299
  // Push final chunk
282
- if (currentChunk) {
283
- chunks.push({
284
- text: currentChunk,
285
- startIndex: chunkStartIndex,
286
- endIndex: line.length,
287
- });
300
+ if (chunkEnd > chunkStart) {
301
+ pushChunk(line.slice(chunkStart, chunkEnd), chunkStart, line.length);
288
302
  }
289
303
 
290
- return chunks.length > 0 ? chunks : [{ text: "", startIndex: 0, endIndex: 0 }];
304
+ return chunks.length > 0 ? chunks : [{ text: "", startIndex: 0, endIndex: 0, width: 0 }];
291
305
  }
292
306
 
293
307
  /** Visual cell column of code-unit `offset` within `text`, counted by grapheme walk. */
@@ -339,10 +353,19 @@ interface EditorState {
339
353
 
340
354
  interface LayoutLine {
341
355
  text: string;
356
+ /** Exact `visibleWidth(text)` carried from wrap/layout, never re-derived. */
357
+ width: number;
342
358
  hasCursor: boolean;
343
359
  cursorPos?: number;
344
360
  }
345
361
 
362
+ /** Per-line measurement carried across renders: exact visible width plus
363
+ * lazily-built wrap chunks (only populated once the line needs wrapping). */
364
+ interface WrapEntry {
365
+ width: number;
366
+ chunks: TextChunk[] | null;
367
+ }
368
+
346
369
  export interface EditorTheme {
347
370
  borderColor: (str: string) => string;
348
371
  selectList: SelectListTheme;
@@ -396,11 +419,14 @@ export class Editor implements Component, Focusable {
396
419
 
397
420
  // Store last layout width for cursor navigation
398
421
  #lastLayoutWidth: number = 80;
399
- // Word-wrap result cache shared by #layoutText, #buildVisualLineMap, and key
400
- // handlers within a frame. Line text is a sound key (strings are immutable);
401
- // cleared on width change and size-bounded so stale lines don't accumulate.
402
- #wrapCache = new Map<string, TextChunk[]>();
422
+ // Line measurement + word-wrap cache shared by #layoutText,
423
+ // #buildVisualLineMap, and key handlers within a frame. Line text is a
424
+ // sound key (strings are immutable); cleared on layout-width or
425
+ // width-config (Hangul jamo setting) change and size-bounded so stale
426
+ // lines don't accumulate.
427
+ #wrapCache = new Map<string, WrapEntry>();
403
428
  #wrapCacheWidth = -1;
429
+ #wrapCacheEpoch = -1;
404
430
  #paddingXOverride: number | undefined;
405
431
  #maxHeight?: number;
406
432
  #scrollOffset: number = 0;
@@ -895,7 +921,7 @@ export class Editor implements Component, Focusable {
895
921
  for (let visibleIndex = 0; visibleIndex < visibleLayoutLines.length; visibleIndex++) {
896
922
  const layoutLine = visibleLayoutLines[visibleIndex]!;
897
923
  let displayText = layoutLine.text;
898
- let displayWidth = visibleWidth(layoutLine.text);
924
+ let displayWidth = layoutLine.width;
899
925
  let cursorPaddingOverflow = 0;
900
926
  let decorated = false;
901
927
  let imeSafeCursorTail = false;
@@ -1041,7 +1067,9 @@ export class Editor implements Component, Focusable {
1041
1067
  displayText = this.#decorate(displayText);
1042
1068
  }
1043
1069
  if (!hasCursor) {
1044
- displayWidth = visibleWidth(displayText);
1070
+ // Undecorated, unsliced lines keep their carried width; any
1071
+ // transform above produced a new string and must be re-measured.
1072
+ displayWidth = displayText === layoutLine.text ? layoutLine.width : visibleWidth(displayText);
1045
1073
  if (displayWidth > lineContentWidth) {
1046
1074
  displayText = truncateToWidth(displayText, lineContentWidth);
1047
1075
  displayWidth = visibleWidth(displayText);
@@ -1489,20 +1517,29 @@ export class Editor implements Component, Focusable {
1489
1517
  }
1490
1518
  }
1491
1519
 
1492
- #wrapLine(line: string, width: number): TextChunk[] {
1493
- if (width !== this.#wrapCacheWidth) {
1520
+ /** Cached per-line measurement: exact visible width now, wrap chunks on demand. */
1521
+ #lineEntry(line: string, width: number): WrapEntry {
1522
+ const epoch = getWidthConfigEpoch();
1523
+ if (width !== this.#wrapCacheWidth || epoch !== this.#wrapCacheEpoch) {
1494
1524
  this.#wrapCache.clear();
1495
1525
  this.#wrapCacheWidth = width;
1526
+ this.#wrapCacheEpoch = epoch;
1496
1527
  }
1497
- let chunks = this.#wrapCache.get(line);
1498
- if (chunks === undefined) {
1528
+ let entry = this.#wrapCache.get(line);
1529
+ if (entry === undefined) {
1499
1530
  if (this.#wrapCache.size >= 256) {
1500
1531
  this.#wrapCache.clear();
1501
1532
  }
1502
- chunks = wordWrapLine(line, width);
1503
- this.#wrapCache.set(line, chunks);
1533
+ entry = { width: visibleWidth(line), chunks: null };
1534
+ this.#wrapCache.set(line, entry);
1504
1535
  }
1505
- return chunks;
1536
+ return entry;
1537
+ }
1538
+
1539
+ #wrapLine(line: string, width: number): TextChunk[] {
1540
+ const entry = this.#lineEntry(line, width);
1541
+ entry.chunks ??= wordWrapLine(line, width, entry.width);
1542
+ return entry.chunks;
1506
1543
  }
1507
1544
 
1508
1545
  #layoutText(contentWidth: number): LayoutLine[] {
@@ -1512,6 +1549,7 @@ export class Editor implements Component, Focusable {
1512
1549
  // Empty editor
1513
1550
  layoutLines.push({
1514
1551
  text: "",
1552
+ width: 0,
1515
1553
  hasCursor: true,
1516
1554
  cursorPos: 0,
1517
1555
  });
@@ -1522,19 +1560,21 @@ export class Editor implements Component, Focusable {
1522
1560
  for (let i = 0; i < this.#state.lines.length; i++) {
1523
1561
  const line = this.#state.lines[i] || "";
1524
1562
  const isCurrentLine = i === this.#state.cursorLine;
1525
- const lineVisibleWidth = visibleWidth(line);
1563
+ const lineVisibleWidth = this.#lineEntry(line, contentWidth).width;
1526
1564
 
1527
1565
  if (lineVisibleWidth <= contentWidth) {
1528
1566
  // Line fits in one layout line
1529
1567
  if (isCurrentLine) {
1530
1568
  layoutLines.push({
1531
1569
  text: line,
1570
+ width: lineVisibleWidth,
1532
1571
  hasCursor: true,
1533
1572
  cursorPos: this.#state.cursorCol,
1534
1573
  });
1535
1574
  } else {
1536
1575
  layoutLines.push({
1537
1576
  text: line,
1577
+ width: lineVisibleWidth,
1538
1578
  hasCursor: false,
1539
1579
  });
1540
1580
  }
@@ -1575,12 +1615,14 @@ export class Editor implements Component, Focusable {
1575
1615
  if (hasCursorInChunk) {
1576
1616
  layoutLines.push({
1577
1617
  text: chunk.text,
1618
+ width: chunk.width,
1578
1619
  hasCursor: true,
1579
1620
  cursorPos: adjustedCursorPos,
1580
1621
  });
1581
1622
  } else {
1582
1623
  layoutLines.push({
1583
1624
  text: chunk.text,
1625
+ width: chunk.width,
1584
1626
  hasCursor: false,
1585
1627
  });
1586
1628
  }
@@ -2716,7 +2758,7 @@ export class Editor implements Component, Focusable {
2716
2758
 
2717
2759
  for (let i = 0; i < this.#state.lines.length; i++) {
2718
2760
  const line = this.#state.lines[i] || "";
2719
- const lineVisWidth = visibleWidth(line);
2761
+ const lineVisWidth = this.#lineEntry(line, width).width;
2720
2762
  if (line.length === 0) {
2721
2763
  // Empty line still takes one visual line
2722
2764
  visualLines.push({ logicalLine: i, startCol: 0, length: 0 });
@@ -1,5 +1,14 @@
1
1
  import type { Component } from "../tui";
2
- import { applyBackgroundToLine, getPaddingX, padding, replaceTabs, visibleWidth, wrapTextWithAnsi } from "../utils";
2
+ import {
3
+ applyBackgroundToLine,
4
+ getPaddingX,
5
+ getWidthConfigEpoch,
6
+ padding,
7
+ publishLineWidths,
8
+ replaceTabs,
9
+ visibleWidth,
10
+ wrapTextWithAnsi,
11
+ } from "../utils";
3
12
 
4
13
  /**
5
14
  * Text component - displays multi-line text with word wrapping
@@ -21,6 +30,7 @@ export class Text implements Component {
21
30
  // Cache for rendered output
22
31
  #cachedText?: string;
23
32
  #cachedWidth?: number;
33
+ #cachedWidthEpoch?: number;
24
34
  #cachedLines?: string[];
25
35
 
26
36
  constructor(text: string = "", paddingX: number = 1, paddingY: number = 1, customBgFn?: (text: string) => string) {
@@ -41,6 +51,7 @@ export class Text implements Component {
41
51
  this.#text = text;
42
52
  this.#cachedText = undefined;
43
53
  this.#cachedWidth = undefined;
54
+ this.#cachedWidthEpoch = undefined;
44
55
  this.#cachedLines = undefined;
45
56
  return true;
46
57
  }
@@ -49,18 +60,25 @@ export class Text implements Component {
49
60
  this.#customBgFn = customBgFn;
50
61
  this.#cachedText = undefined;
51
62
  this.#cachedWidth = undefined;
63
+ this.#cachedWidthEpoch = undefined;
52
64
  this.#cachedLines = undefined;
53
65
  }
54
66
 
55
67
  invalidate(): void {
56
68
  this.#cachedText = undefined;
57
69
  this.#cachedWidth = undefined;
70
+ this.#cachedWidthEpoch = undefined;
58
71
  this.#cachedLines = undefined;
59
72
  }
60
73
 
61
74
  render(width: number): readonly string[] {
62
75
  // Check cache
63
- if (this.#cachedLines && this.#cachedText === this.#text && this.#cachedWidth === width) {
76
+ if (
77
+ this.#cachedLines &&
78
+ this.#cachedText === this.#text &&
79
+ this.#cachedWidth === width &&
80
+ this.#cachedWidthEpoch === getWidthConfigEpoch()
81
+ ) {
64
82
  return this.#cachedLines;
65
83
  }
66
84
 
@@ -69,6 +87,7 @@ export class Text implements Component {
69
87
  const result: string[] = [];
70
88
  this.#cachedText = this.#text;
71
89
  this.#cachedWidth = width;
90
+ this.#cachedWidthEpoch = getWidthConfigEpoch();
72
91
  this.#cachedLines = result;
73
92
  return result;
74
93
  }
@@ -86,6 +105,9 @@ export class Text implements Component {
86
105
  const leftMargin = padding(paddingX);
87
106
  const rightMargin = padding(paddingX);
88
107
  const contentLines: string[] = [];
108
+ // Exact visible widths of `result` rows, published only when rows are
109
+ // `content + spaces` (customBgFn output width is not knowable here).
110
+ const resultWidths: number[] | undefined = this.#customBgFn ? undefined : [];
89
111
 
90
112
  for (const line of wrappedLines) {
91
113
  // Add margins
@@ -99,6 +121,7 @@ export class Text implements Component {
99
121
  const visibleLen = visibleWidth(lineWithMargins);
100
122
  const paddingNeeded = Math.max(0, width - visibleLen);
101
123
  contentLines.push(lineWithMargins + padding(paddingNeeded));
124
+ resultWidths?.push(visibleLen + paddingNeeded);
102
125
  }
103
126
  }
104
127
 
@@ -111,10 +134,16 @@ export class Text implements Component {
111
134
  }
112
135
 
113
136
  const result = [...emptyLines, ...contentLines, ...emptyLines];
137
+ if (resultWidths !== undefined) {
138
+ // Pad rows are exactly `width` cells wide.
139
+ const emptyWidths = new Array<number>(emptyLines.length).fill(width);
140
+ publishLineWidths(result, [...emptyWidths, ...resultWidths, ...emptyWidths]);
141
+ }
114
142
 
115
143
  // Update cache
116
144
  this.#cachedText = this.#text;
117
145
  this.#cachedWidth = width;
146
+ this.#cachedWidthEpoch = getWidthConfigEpoch();
118
147
  this.#cachedLines = result;
119
148
 
120
149
  return result.length > 0 ? result : [""];
package/src/tui.ts CHANGED
@@ -194,17 +194,22 @@ export interface OverlayFocusOwner {
194
194
  * FINAL — byte-stable at the current width for the component's lifetime — and
195
195
  * commit to native scrollback as exact, audited content. Rows at/after the
196
196
  * boundary repaint in place inside the visible window; when they scroll above
197
- * the window top they still commit the tape records what was on screen —
198
- * but as frozen visual snapshots that are permanently audit-exempt: later
199
- * re-layout of their source never re-anchors or recommits them. A root that
200
- * reports no seam commits everything that scrolls as final (shell semantics).
197
+ * the window top they normally commit as frozen visual snapshots.
198
+ *
199
+ * A viewport-pinned region opts out of those mutable snapshot commits. Its
200
+ * offscreen mutable rows are virtually clipped until the boundary advances;
201
+ * use this for fixed-height dashboards whose frames replace each other rather
202
+ * than append. A root that reports no seam commits everything that scrolls as
203
+ * final (shell semantics).
201
204
  *
202
205
  * When several root children report a seam in the same frame, the topmost one
203
- * defines the boundary: exactness is prefix-only, so everything below the
204
- * first seam is already excluded.
206
+ * defines the boundary and pinning policy: commits are prefix-only, so
207
+ * everything below the first seam is already excluded.
205
208
  */
206
209
  export interface NativeScrollbackLiveRegion {
207
210
  getNativeScrollbackLiveRegionStart(): number | undefined;
211
+ /** Keeps the mutable suffix viewport-local instead of recording frozen snapshots. */
212
+ isNativeScrollbackLiveRegionPinned?(): boolean;
208
213
  }
209
214
 
210
215
  export interface NativeScrollbackCommittedRows {
@@ -639,11 +644,9 @@ interface CursorControlResult extends HardwareCursorUpdate {
639
644
  }
640
645
 
641
646
  /**
642
- * One root child's contribution to the composed frame: the array reference its
643
- * render() returned, the frame row it starts at, the row count recorded at
644
- * compose time (in-place mutators keep the reference but may change length),
645
- * and the child-local seam report captured at render time — replayed verbatim
646
- * when a component-scoped frame reuses this segment without re-rendering.
647
+ * One root child's contribution to the composed frame: its rendered rows,
648
+ * frame span, and live-region report captured at render time. Component-scoped
649
+ * frames replay the seam and viewport-pinning policy without re-rendering.
647
650
  */
648
651
  interface FrameSegment {
649
652
  component: Component;
@@ -651,6 +654,7 @@ interface FrameSegment {
651
654
  start: number;
652
655
  rowCount: number;
653
656
  liveLocalStart?: number;
657
+ liveRegionPinned: boolean;
654
658
  }
655
659
 
656
660
  /** Depth-first identity search through `Container`-shaped children. */
@@ -1042,6 +1046,7 @@ export class TUI extends Container {
1042
1046
  // Exactly what is painted on the screen rows (post-composite, prepared).
1043
1047
  #previousWindow: string[] = [];
1044
1048
  #nativeScrollbackLiveRegionStart: number | undefined;
1049
+ #nativeScrollbackLiveRegionPinned = false;
1045
1050
  #fullRedrawCount = 0;
1046
1051
  // Caps how many inline images render as live graphics; older ones fall back
1047
1052
  // to text via a purge + full redraw. Cap is configured by the host app.
@@ -1133,6 +1138,7 @@ export class TUI extends Container {
1133
1138
  // Target component -> containing root child, so animation-rate requests do
1134
1139
  // not re-walk a huge transcript subtree every frame.
1135
1140
  #componentRootCache = new WeakMap<Component, Component>();
1141
+ #scopedInputRenderComponents = new WeakSet<Component>();
1136
1142
 
1137
1143
  // Persistent prepared frame, row-aligned with #composedFrame. Entries store
1138
1144
  // normalized, width-fitted content rows without the per-line terminal
@@ -1165,6 +1171,7 @@ export class TUI extends Container {
1165
1171
  override render(width: number): readonly string[] {
1166
1172
  width = Math.max(1, width);
1167
1173
  this.#nativeScrollbackLiveRegionStart = undefined;
1174
+ this.#nativeScrollbackLiveRegionPinned = false;
1168
1175
  const children = this.children;
1169
1176
  const previousSegments = this.#frameSegments;
1170
1177
  const segments: FrameSegment[] = new Array(children.length);
@@ -1185,10 +1192,12 @@ export class TUI extends Container {
1185
1192
  partialRoots !== null && previous !== undefined && previous.component === child && !partialRoots.has(child);
1186
1193
  let childLines: readonly string[];
1187
1194
  let liveLocalStart: number | undefined;
1195
+ let liveRegionPinned = false;
1188
1196
  let reported: number | undefined;
1189
1197
  if (reuse) {
1190
1198
  childLines = previous.lines;
1191
1199
  liveLocalStart = previous.liveLocalStart;
1200
+ liveRegionPinned = previous.liveRegionPinned;
1192
1201
  } else {
1193
1202
  // Feed the engine's committed-row claim (from the previous frame's
1194
1203
  // emit) before rendering so the child can skip re-deriving blocks
@@ -1208,6 +1217,11 @@ export class TUI extends Container {
1208
1217
  ? Math.max(0, Math.min(childLines.length, Math.trunc(liveRegionStart)))
1209
1218
  : childLines.length;
1210
1219
  }
1220
+ if (liveLocalStart !== undefined) {
1221
+ liveRegionPinned =
1222
+ (child as Component & Partial<NativeScrollbackLiveRegion>).isNativeScrollbackLiveRegionPinned?.() ===
1223
+ true;
1224
+ }
1211
1225
  // Consume the stability report unconditionally for implementers:
1212
1226
  // reading re-bases the component's baseline to the state this
1213
1227
  // compose is about to ingest (used or not, the current rows are
@@ -1224,6 +1238,7 @@ export class TUI extends Container {
1224
1238
  // history.
1225
1239
  if (liveLocalStart !== undefined && this.#nativeScrollbackLiveRegionStart === undefined) {
1226
1240
  this.#nativeScrollbackLiveRegionStart = offset + liveLocalStart;
1241
+ this.#nativeScrollbackLiveRegionPinned = liveRegionPinned;
1227
1242
  }
1228
1243
  if (chainStable) {
1229
1244
  if (previous !== undefined && previous.component === child && previous.start === offset) {
@@ -1252,6 +1267,7 @@ export class TUI extends Container {
1252
1267
  start: offset,
1253
1268
  rowCount: childLines.length,
1254
1269
  liveLocalStart,
1270
+ liveRegionPinned,
1255
1271
  };
1256
1272
  offset += childLines.length;
1257
1273
  }
@@ -1347,6 +1363,20 @@ export class TUI extends Container {
1347
1363
  this.#imageBudget.setCap(cap);
1348
1364
  }
1349
1365
 
1366
+ /** Delete every tracked Kitty image from the terminal graphics store. */
1367
+ clearInlineImages(): void {
1368
+ if (this.#stopped) return;
1369
+ this.#purgeInlineImages();
1370
+ }
1371
+
1372
+ #purgeInlineImages(): void {
1373
+ const transmittedIds = this.#imageBudget.takeAllTransmittedIds();
1374
+ if (TERMINAL.imageProtocol !== ImageProtocol.Kitty) return;
1375
+ for (const id of transmittedIds) {
1376
+ this.terminal.write(encodeKittyDeleteImage(id));
1377
+ }
1378
+ }
1379
+
1350
1380
  /**
1351
1381
  * Get whether scrollback divergence rebuild is enabled.
1352
1382
  */
@@ -1771,11 +1801,7 @@ export class TUI extends Container {
1771
1801
  this.#altPreviousLines = [];
1772
1802
  this.#pendingAltExit = "";
1773
1803
  }
1774
- if (TERMINAL.imageProtocol === ImageProtocol.Kitty) {
1775
- for (const id of this.#imageBudget.takeAllTransmittedIds()) {
1776
- this.terminal.write(encodeKittyDeleteImage(id));
1777
- }
1778
- }
1804
+ this.#purgeInlineImages();
1779
1805
  this.#clearSixelProbeState();
1780
1806
  this.#stopped = true;
1781
1807
  this.#watchdog.stop();
@@ -1889,6 +1915,17 @@ export class TUI extends Container {
1889
1915
  this.#requestOrdinaryRender();
1890
1916
  }
1891
1917
 
1918
+ /**
1919
+ * Opt `component` into subtree-only renders when input leaves focus stable.
1920
+ *
1921
+ * The host must explicitly request renders for every sibling mutated by the
1922
+ * component's input callbacks. Components without this opt-in retain the
1923
+ * legacy full-root render after input.
1924
+ */
1925
+ enableScopedInputRender(component: Component): void {
1926
+ this.#scopedInputRenderComponents.add(component);
1927
+ }
1928
+
1892
1929
  /**
1893
1930
  * Schedule a render on behalf of `component` after a self-contained change
1894
1931
  * (spinner frame, blink) that cannot have affected any other component.
@@ -2368,15 +2405,23 @@ export class TUI extends Container {
2368
2405
  }
2369
2406
  }
2370
2407
 
2371
- // Pass input to focused component (including Ctrl+C)
2372
- // The focused component can decide how to handle Ctrl+C
2373
- if (this.#focusedComponent?.handleInput) {
2408
+ // Pass input to focused component (including Ctrl+C).
2409
+ // The focused component can decide how to handle Ctrl+C.
2410
+ // Opted-in components only dirty their focused subtree. Unregistered
2411
+ // components retain the legacy full compose because their callbacks may
2412
+ // mutate siblings; focus changes also require the new surface to paint.
2413
+ const focused = this.#focusedComponent;
2414
+ if (focused?.handleInput) {
2374
2415
  // Filter out key release events unless component opts in
2375
- if (isKeyRelease(data) && !this.#focusedComponent.wantsKeyRelease) {
2416
+ if (isKeyRelease(data) && !focused.wantsKeyRelease) {
2376
2417
  return;
2377
2418
  }
2378
- this.#focusedComponent.handleInput(data);
2379
- this.requestRender();
2419
+ focused.handleInput(data);
2420
+ if (this.#focusedComponent === focused && this.#scopedInputRenderComponents.has(focused)) {
2421
+ this.requestComponentRender(focused);
2422
+ } else {
2423
+ this.requestRender();
2424
+ }
2380
2425
  }
2381
2426
  }
2382
2427
 
@@ -2861,6 +2906,7 @@ export class TUI extends Container {
2861
2906
  // known. Ascending by frame row.
2862
2907
  const cursorMarkers = this.#frameCursorMarkers;
2863
2908
  const liveRegionStart = this.#nativeScrollbackLiveRegionStart;
2909
+ const liveRegionPinned = this.#nativeScrollbackLiveRegionPinned;
2864
2910
 
2865
2911
  // Exactness boundary (used by the audit-zone math below). Rows below it
2866
2912
  // are declared FINAL by the component seam: when they commit, they enter
@@ -2987,7 +3033,7 @@ export class TUI extends Container {
2987
3033
  if (fullPaint) {
2988
3034
  committedPrefixResliced = true;
2989
3035
  windowTop = Math.max(0, frameLength - height);
2990
- chunkTo = windowTop;
3036
+ chunkTo = liveRegionPinned ? Math.min(windowTop, finalBoundary) : windowTop;
2991
3037
  } else if (
2992
3038
  frameLength <= this.#committedRows ||
2993
3039
  (committedRowsResynced &&
@@ -3007,9 +3053,19 @@ export class TUI extends Container {
3007
3053
  // "duplication, never loss" is the ED3-unsafe fallback contract.
3008
3054
  committedPrefixResliced = true;
3009
3055
  windowTop = Math.max(0, frameLength - height);
3010
- chunkTo = windowTop;
3056
+ chunkTo = liveRegionPinned ? Math.min(windowTop, finalBoundary) : windowTop;
3011
3057
  this.#committedRows = chunkTo;
3012
3058
  this.#committedPrefix = rawFrame.slice(0, chunkTo);
3059
+ } else if (geometryChanged && Math.max(0, frameLength - height) < this.#committedRows) {
3060
+ // Pane growth/reflow can pull rows back out of mux scrollback and into
3061
+ // the viewport. Rebase the commit seam to that exposed frame tail before
3062
+ // the forced rewrite; flooring at the old seam would paint only the live
3063
+ // suffix followed by blanks, then preserve that gap on every stream tick.
3064
+ committedPrefixResliced = true;
3065
+ windowTop = Math.max(0, frameLength - height);
3066
+ chunkTo = windowTop;
3067
+ this.#committedRows = windowTop;
3068
+ this.#committedPrefix = rawFrame.slice(0, windowTop);
3013
3069
  } else {
3014
3070
  // Re-anchor to the frame tail, floored at the committed boundary: a
3015
3071
  // shrink (or overlay close) pulls the window back down, but never
@@ -3026,7 +3082,12 @@ export class TUI extends Container {
3026
3082
  // history — and re-bases the audit prefix at the new width so the
3027
3083
  // accepted wrap drift does not read as a violation on the next
3028
3084
  // ordinary frame.
3029
- chunkTo = hasVisibleOverlay || geometryChanged ? this.#committedRows : windowTop;
3085
+ chunkTo =
3086
+ hasVisibleOverlay || geometryChanged
3087
+ ? this.#committedRows
3088
+ : liveRegionPinned
3089
+ ? Math.min(windowTop, Math.max(this.#committedRows, finalBoundary))
3090
+ : windowTop;
3030
3091
  if (geometryChanged) {
3031
3092
  committedPrefixResliced = true;
3032
3093
  this.#committedPrefix = rawFrame.slice(0, this.#committedRows);
@@ -3246,6 +3307,25 @@ export class TUI extends Container {
3246
3307
  }
3247
3308
 
3248
3309
  const code = raw.charCodeAt(i);
3310
+ if (code >= 0x20 && code <= 0x7e) {
3311
+ // Printable-ASCII run: every char here is exactly one cell wide, so
3312
+ // the run is copied with a single slice instead of a per-char
3313
+ // slice + visibleWidth call. Stop conditions mirror the general
3314
+ // path: width budget (cells), source budget (maxSourceLength).
3315
+ if (output.length >= maxSourceLength) break;
3316
+ const cap = i + Math.min(safeWidth - cells, maxSourceLength - output.length);
3317
+ let j = i + 1;
3318
+ while (j < raw.length && j < cap) {
3319
+ const c = raw.charCodeAt(j);
3320
+ if (c < 0x20 || c > 0x7e) break;
3321
+ j++;
3322
+ }
3323
+ output += raw.slice(i, j);
3324
+ cells += j - i;
3325
+ i = j;
3326
+ continue;
3327
+ }
3328
+
3249
3329
  const next = code >= 0xd800 && code <= 0xdbff && i + 1 < raw.length ? i + 2 : i + 1;
3250
3330
  const char = raw.slice(i, next);
3251
3331
  const charWidth = visibleWidth(char);
package/src/utils.ts CHANGED
@@ -30,14 +30,62 @@ export function getHangulCompatibilityJamoWidth(): HangulCompatibilityJamoWidth
30
30
  return hangulCompatibilityJamoWidth;
31
31
  }
32
32
 
33
+ // Monotonic epoch for width-affecting runtime configuration. Any cache or
34
+ // carried-width sidecar derived from `visibleWidth` results must be stamped
35
+ // with the epoch at computation time and discarded on mismatch, so a Hangul
36
+ // Compatibility Jamo width change invalidates every derived width.
37
+ let widthConfigEpoch = 0;
38
+
39
+ export function getWidthConfigEpoch(): number {
40
+ return widthConfigEpoch;
41
+ }
42
+
43
+ interface LineWidthsEntry {
44
+ epoch: number;
45
+ lines: readonly string[];
46
+ widths: readonly number[];
47
+ }
48
+
49
+ // Per-render-result visible widths, keyed by the exact lines array a component
50
+ // returned. The copied strings and widths are the single publication snapshot:
51
+ // they cannot be changed through either publisher array and do not retain the
52
+ // WeakMap key. Entries therefore die with their lines-array owners.
53
+ const lineWidthSidecar = new WeakMap<readonly string[], LineWidthsEntry>();
54
+
55
+ /** Publish exact per-line visible widths for a rendered lines array. */
56
+ export function publishLineWidths(lines: readonly string[], widths: readonly number[]): void {
57
+ if (lines.length !== widths.length) {
58
+ throw new RangeError(`Cannot publish ${widths.length} widths for ${lines.length} lines`);
59
+ }
60
+ lineWidthSidecar.set(lines, {
61
+ epoch: widthConfigEpoch,
62
+ lines: [...lines],
63
+ widths: Object.freeze([...widths]),
64
+ });
65
+ }
66
+
67
+ /** Exact per-line visible widths for an unchanged `lines` array under the current width config. */
68
+ export function getPublishedLineWidths(lines: readonly string[]): readonly number[] | undefined {
69
+ const entry = lineWidthSidecar.get(lines);
70
+ if (entry === undefined || entry.epoch !== widthConfigEpoch || entry.lines.length !== lines.length) {
71
+ return undefined;
72
+ }
73
+ for (let i = 0; i < lines.length; i++) {
74
+ if (entry.lines[i] !== lines[i]) return undefined;
75
+ }
76
+ return entry.widths;
77
+ }
78
+
33
79
  export function setHangulCompatibilityJamoWidth(width: HangulCompatibilityJamoWidth): boolean {
34
80
  const changed = hangulCompatibilityJamoWidth !== width;
35
81
  hangulCompatibilityJamoWidth = width;
82
+ if (changed) widthConfigEpoch++;
36
83
  nativeSetHangulCompatJamoWidthOverride(nativeHangulCompatibilityJamoOverride(width));
37
84
  return changed;
38
85
  }
39
86
 
40
87
  export function resetHangulCompatibilityJamoWidthForTests(): void {
88
+ if (hangulCompatibilityJamoWidth !== "platform") widthConfigEpoch++;
41
89
  hangulCompatibilityJamoWidth = "platform";
42
90
  nativeSetHangulCompatJamoWidthOverride(0);
43
91
  }