@linxiraos/pi-tui 1.0.4 → 1.0.6

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 (43) hide show
  1. package/CHANGELOG.md +18 -5
  2. package/dist/types/autocomplete.d.ts +116 -0
  3. package/dist/types/bracketed-paste.d.ts +51 -0
  4. package/dist/types/components/box.d.ts +31 -0
  5. package/dist/types/components/cancellable-loader.d.ts +21 -0
  6. package/dist/types/components/editor.d.ts +166 -0
  7. package/dist/types/components/image.d.ts +165 -0
  8. package/dist/types/components/input.d.ts +25 -0
  9. package/dist/types/components/loader.d.ts +25 -0
  10. package/dist/types/components/markdown.d.ts +92 -0
  11. package/dist/types/components/scroll-view.d.ts +62 -0
  12. package/dist/types/components/select-list.d.ts +69 -0
  13. package/dist/types/components/settings-list.d.ts +123 -0
  14. package/dist/types/components/spacer.d.ts +11 -0
  15. package/dist/types/components/tab-bar.d.ts +89 -0
  16. package/dist/types/components/text.d.ts +28 -0
  17. package/dist/types/components/truncated-text.d.ts +10 -0
  18. package/dist/types/deccara.d.ts +49 -0
  19. package/dist/types/desktop-notify.d.ts +52 -0
  20. package/dist/types/editor-component.d.ts +38 -0
  21. package/dist/types/fuzzy.d.ts +48 -0
  22. package/dist/types/index.d.ts +32 -0
  23. package/dist/types/keybindings.d.ts +197 -0
  24. package/dist/types/keys.d.ts +210 -0
  25. package/dist/types/kill-ring.d.ts +20 -0
  26. package/dist/types/kitty-graphics.d.ts +76 -0
  27. package/dist/types/latex-block.d.ts +8 -0
  28. package/dist/types/latex-to-unicode.d.ts +50 -0
  29. package/dist/types/loop-watchdog.d.ts +49 -0
  30. package/dist/types/mouse.d.ts +67 -0
  31. package/dist/types/stdin-buffer.d.ts +60 -0
  32. package/dist/types/symbols.d.ts +25 -0
  33. package/dist/types/terminal-capabilities.d.ts +324 -0
  34. package/dist/types/terminal.d.ts +175 -0
  35. package/dist/types/tmux.d.ts +6 -0
  36. package/dist/types/ttyid.d.ts +9 -0
  37. package/dist/types/tui.d.ts +480 -0
  38. package/dist/types/utils.d.ts +112 -0
  39. package/package.json +70 -69
  40. package/src/keys.ts +1 -1
  41. package/src/loop-watchdog.ts +32 -6
  42. package/src/terminal.ts +15 -8
  43. package/src/tui.ts +70 -16
@@ -0,0 +1,112 @@
1
+ import { Ellipsis, type ExtractSegmentsResult, type SliceResult } from "@linxiraos/pi-natives";
2
+ export { Ellipsis } from "@linxiraos/pi-natives";
3
+ export { DEFAULT_TAB_WIDTH } from "@linxiraos/pi-utils";
4
+ export type HangulCompatibilityJamoWidth = "platform" | "unicode" | 1 | 2;
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;
11
+ export declare function setHangulCompatibilityJamoWidth(width: HangulCompatibilityJamoWidth): boolean;
12
+ export declare function resetHangulCompatibilityJamoWidthForTests(): void;
13
+ export type TextSizingScale = 1 | 2 | 3;
14
+ export type TextSizingVerticalAlign = "top" | "bottom" | "center";
15
+ export type TextSizingHorizontalAlign = "left" | "right" | "center";
16
+ export interface TextSizingOptions {
17
+ scale?: TextSizingScale;
18
+ widthCells?: number;
19
+ verticalAlign?: TextSizingVerticalAlign;
20
+ horizontalAlign?: TextSizingHorizontalAlign;
21
+ }
22
+ /**
23
+ * Encode a plain-text span using Kitty's OSC 66 text-sizing protocol. The TUI
24
+ * emits only safe UTF-8 payloads and ST terminators so its ANSI parser and the
25
+ * terminal agree on span boundaries.
26
+ */
27
+ export declare function encodeTextSized(text: string, options?: TextSizingOptions): string;
28
+ export declare function sliceWithWidth(line: string, startCol: number, length: number, strict?: boolean | null): SliceResult;
29
+ export declare function truncateToWidth(text: string, maxWidth: number, ellipsisKind?: Ellipsis | null | "", pad?: boolean | null): string;
30
+ export declare function wrapTextWithAnsi(text: string, width: number): string[];
31
+ export declare function extractSegments(line: string, beforeEnd: number, afterStart: number, afterLen: number, strictAfter: boolean): ExtractSegmentsResult;
32
+ export declare function replaceTabs(text: string): string;
33
+ /**
34
+ * Returns a string of n spaces. Uses a pre-allocated buffer for efficiency.
35
+ */
36
+ export declare function padding(n: number): string;
37
+ /**
38
+ * Get the shared grapheme segmenter instance.
39
+ */
40
+ export declare function getSegmenter(): Intl.Segmenter;
41
+ /**
42
+ * Visible width of a string in terminal columns, excluding ANSI/OSC escapes.
43
+ *
44
+ * `Bun.stringWidth` does the heavy lifting (UAX#11 width tables + ANSI/OSC
45
+ * stripping); this adds the two corrections it omits — tabs (expanded to
46
+ * `tabWidth` cells) and OSC 66 text-sizing payloads (scaled by `s=`).
47
+ */
48
+ export declare function visibleWidth(str: string): number;
49
+ /**
50
+ * True when a row carries a Kitty OSC 66 text-sizing span (`\x1b]66;…`).
51
+ * Scaled spans must bypass wrapping/padding and, when scaled up, reserve the
52
+ * terminal rows their multicell glyphs flow into.
53
+ */
54
+ export declare function isOsc66Line(line: string): boolean;
55
+ /**
56
+ * Largest `s=` scale among the OSC 66 spans in a line (1 when none is scaled).
57
+ * A scale-`s` heading occupies `s` terminal rows, so the `s - 1` blank rows
58
+ * beneath it are the glyph's lower half and must never be erased or overdrawn.
59
+ */
60
+ export declare function osc66MaxScale(line: string): number;
61
+ /**
62
+ * Normalize text for terminal output without changing logical editor content.
63
+ * Some terminals render precomposed Thai/Lao AM vowels inconsistently during
64
+ * differential repaint. Their compatibility decompositions have the same cell
65
+ * width but avoid stale-cell artifacts in terminal renderers.
66
+ */
67
+ export declare function normalizeTerminalOutput(str: string): string;
68
+ /**
69
+ * Check if a character is whitespace.
70
+ */
71
+ export declare function isWhitespaceChar(char: string): boolean;
72
+ /**
73
+ * Check if a character is punctuation.
74
+ */
75
+ export declare function isPunctuationChar(char: string): boolean;
76
+ export type WordNavKind = "whitespace" | "delimiter" | "cjk" | "word" | "other";
77
+ /**
78
+ * Coarse Unicode-aware character classification for word navigation (Option/Alt + Left/Right).
79
+ * This intentionally avoids language-specific word segmentation for predictability across scripts.
80
+ */
81
+ export declare function getWordNavKind(grapheme: string): WordNavKind;
82
+ export declare function isWordNavJoiner(grapheme: string): boolean;
83
+ /**
84
+ * Move the cursor one "word" to the left using Unicode-aware coarse navigation.
85
+ *
86
+ * Returns a new cursor index in the range [0, text.length].
87
+ */
88
+ export declare function moveWordLeft(text: string, cursor: number): number;
89
+ /**
90
+ * Move the cursor one "word" to the right using Unicode-aware coarse navigation.
91
+ *
92
+ * Returns a new cursor index in the range [0, text.length].
93
+ */
94
+ export declare function moveWordRight(text: string, cursor: number): number;
95
+ /**
96
+ * Apply background color to a line, padding to full width.
97
+ *
98
+ * @param line - Line of text (may contain ANSI codes)
99
+ * @param width - Total width to pad to
100
+ * @param bgFn - Background color function
101
+ * @returns Line with background applied and padded to width
102
+ */
103
+ export declare function applyBackgroundToLine(line: string, width: number, bgFn: (text: string) => string): string;
104
+ /**
105
+ * Extract a range of visible columns from a line. Handles ANSI codes and wide chars.
106
+ *
107
+ * @param strict - If true, exclude wide chars at boundary that would extend past the range
108
+ */
109
+ export declare function sliceByColumn(line: string, startCol: number, length: number, strict?: boolean): string;
110
+ export declare function setTuiTight(tight: boolean): void;
111
+ export declare function isTuiTight(): boolean;
112
+ export declare function getPaddingX(basePadding: number): number;
package/package.json CHANGED
@@ -1,69 +1,70 @@
1
- {
2
- "type": "module",
3
- "name": "@linxiraos/pi-tui",
4
- "version": "1.0.4",
5
- "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
- "homepage": "https://linxira-os.github.io",
7
- "author": "Can Boluk",
8
- "contributors": [
9
- "Mario Zechner"
10
- ],
11
- "license": "MIT",
12
- "repository": {
13
- "type": "git",
14
- "url": "git+https://github.com/Linxira-OS/linxira-zeta.git",
15
- "directory": "packages/tui"
16
- },
17
- "bugs": {
18
- "url": "https://github.com/can1357/oh-my-pi/issues"
19
- },
20
- "keywords": [
21
- "tui",
22
- "terminal",
23
- "ui",
24
- "text-editor",
25
- "differential-rendering",
26
- "typescript",
27
- "cli"
28
- ],
29
- "main": "./src/index.ts",
30
- "types": "./src/index.ts",
31
- "scripts": {
32
- "check": "biome check . && bun run check:types",
33
- "check:types": "tsgo -p tsconfig.json --noEmit",
34
- "lint": "biome lint .",
35
- "test": "bun test --parallel test/*.test.ts",
36
- "fix": "biome check --write --unsafe .",
37
- "fmt": "biome format --write ."
38
- },
39
- "dependencies": {
40
- "@linxiraos/pi-natives": "1.0.2",
41
- "@linxiraos/pi-utils": "1.0.4"
42
- },
43
- "devDependencies": {
44
- "ghostty-web": "^0.4.0"
45
- },
46
- "engines": {
47
- "bun": ">=1.3.14"
48
- },
49
- "files": [
50
- "src",
51
- "README.md",
52
- "CHANGELOG.md"
53
- ],
54
- "exports": {
55
- ".": {
56
- "types": "./src/index.ts",
57
- "import": "./src/index.ts"
58
- },
59
- "./*": {
60
- "types": "./src/*.ts",
61
- "import": "./src/*.ts"
62
- },
63
- "./components/*": {
64
- "types": "./src/components/*.ts",
65
- "import": "./src/components/*.ts"
66
- },
67
- "./*.js": "./src/*.ts"
68
- }
69
- }
1
+ {
2
+ "type": "module",
3
+ "name": "@linxiraos/pi-tui",
4
+ "version": "1.0.6",
5
+ "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
+ "homepage": "https://linxira-os.github.io",
7
+ "author": "Can Boluk",
8
+ "contributors": [
9
+ "Mario Zechner"
10
+ ],
11
+ "license": "MIT",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/Linxira-OS/linxira-zeta.git",
15
+ "directory": "packages/tui"
16
+ },
17
+ "bugs": {
18
+ "url": "https://github.com/can1357/oh-my-pi/issues"
19
+ },
20
+ "keywords": [
21
+ "tui",
22
+ "terminal",
23
+ "ui",
24
+ "text-editor",
25
+ "differential-rendering",
26
+ "typescript",
27
+ "cli"
28
+ ],
29
+ "main": "./src/index.ts",
30
+ "types": "./dist/types/index.d.ts",
31
+ "scripts": {
32
+ "check": "biome check . && bun run check:types",
33
+ "check:types": "tsgo -p tsconfig.json --noEmit",
34
+ "lint": "biome lint .",
35
+ "test": "bun test --parallel test/*.test.ts",
36
+ "fix": "biome check --write --unsafe .",
37
+ "fmt": "biome format --write ."
38
+ },
39
+ "dependencies": {
40
+ "@linxiraos/pi-natives": "1.0.2",
41
+ "@linxiraos/pi-utils": "1.0.6"
42
+ },
43
+ "devDependencies": {
44
+ "ghostty-web": "^0.4.0"
45
+ },
46
+ "engines": {
47
+ "bun": ">=1.3.14"
48
+ },
49
+ "files": [
50
+ "src",
51
+ "README.md",
52
+ "CHANGELOG.md",
53
+ "dist/types"
54
+ ],
55
+ "exports": {
56
+ ".": {
57
+ "types": "./dist/types/index.d.ts",
58
+ "import": "./src/index.ts"
59
+ },
60
+ "./*": {
61
+ "types": "./dist/types/*.d.ts",
62
+ "import": "./src/*.ts"
63
+ },
64
+ "./components/*": {
65
+ "types": "./dist/types/components/*.d.ts",
66
+ "import": "./src/components/*.ts"
67
+ },
68
+ "./*.js": "./src/*.ts"
69
+ }
70
+ }
package/src/keys.ts CHANGED
@@ -198,7 +198,7 @@ export type KeyId = BaseKey | ModifiedKeyId<BaseKey>;
198
198
  * modifier methods return precisely-typed concatenations (e.g. `Key.ctrl("c")`
199
199
  * is `"ctrl+c"`, not just `string`). This mirrors the upstream
200
200
  * `@mariozechner/pi-tui` `Key` export verbatim so plugins built against any
201
- * scope alias (`@mariozechner`, `@earendil-works`, `@oh-my-pi`) keep working
201
+ * scope alias (`@mariozechner`, `@earendil-works`, `@linxiraos`) keep working
202
202
  * once the specifier shim remaps them to this package.
203
203
  */
204
204
  export const Key = {
@@ -6,10 +6,12 @@ export interface LoopWatchdogOptions {
6
6
  intervalMs?: number;
7
7
  /** A tick later than this past its deadline counts as a block. Default 250. */
8
8
  thresholdMs?: number;
9
- /** Overshoot beyond this likely includes system sleep, so it is suppressed. Default 60_000. */
9
+ /** Overshoot beyond this is suppressed only when the process burned negligible CPU. Default 60_000. */
10
10
  sleepMs?: number;
11
11
  /** Monotonic clock source; injectable for tests. Default `performance.now`. */
12
12
  now?: () => number;
13
+ /** Process CPU time in ms; injectable for tests. Default `process.cpuUsage`. */
14
+ cpuNow?: () => number;
13
15
  /** Timer source; injectable for tests. Default `setTimeout`. */
14
16
  schedule?: (cb: () => void, ms: number) => LoopWatchdogTimer;
15
17
  }
@@ -23,6 +25,15 @@ interface LoopWatchdogTimer {
23
25
  cancel?(): void;
24
26
  }
25
27
 
28
+ /**
29
+ * Fraction of a missed interval that may be process CPU time while the gap is
30
+ * still treated as system sleep. Keep this near zero: cgroup throttling and
31
+ * scheduler contention can make a CPU-bound loop consume far less CPU than wall
32
+ * time. One percent allows a little measurement/background jitter while erring
33
+ * toward reporting a severe stall instead of hiding it.
34
+ */
35
+ const CPU_BUSY_RATIO = 0.01;
36
+
26
37
  /**
27
38
  * Always-on event-loop lag probe. Each tick is scheduled `intervalMs` ahead of
28
39
  * a recorded deadline; a tick that fires `thresholdMs` past its deadline means
@@ -35,18 +46,23 @@ interface LoopWatchdogTimer {
35
46
  * The handle is `unref`'d so the probe never keeps the process alive, and stop()
36
47
  * cancels the armed timer when the handle exposes `cancel` (the default
37
48
  * `setTimeout` handle does, via `clearTimeout`). The `#generation` guard remains
38
- * as a fallback for injected handles that cannot cancel. An overshoot beyond
39
- * `sleepMs` is treated as system sleep rather than a synchronous stall: the
40
- * process could not have run JS during the missed interval, and one resume
41
- * should not produce a multi-minute `ui.loop-blocked` record.
49
+ * as a fallback for injected handles that cannot cancel.
50
+ *
51
+ * A long overshoot is classified by CPU time rather than by duration. System
52
+ * sleep and a CPU-bound wedge both produce an arbitrarily large gap, so duration
53
+ * alone cannot tell them apart, and suppressing on duration discards exactly the
54
+ * worst stalls. Only a gap the process spent negligible CPU on is treated as
55
+ * sleep. CPU accounting is process-wide, so worker activity errs toward logging.
42
56
  */
43
57
  export class LoopWatchdog {
44
58
  #intervalMs: number;
45
59
  #thresholdMs: number;
46
60
  #sleepMs: number;
47
61
  #now: () => number;
62
+ #cpuNow: () => number;
48
63
  #schedule: (cb: () => void, ms: number) => LoopWatchdogTimer;
49
64
  #expected = 0;
65
+ #expectedCpu = 0;
50
66
  #wasBlocked = false;
51
67
  #running = false;
52
68
  // Bumped by stop(); each scheduled tick captures the generation it was armed
@@ -60,6 +76,12 @@ export class LoopWatchdog {
60
76
  this.#thresholdMs = options.thresholdMs ?? 250;
61
77
  this.#sleepMs = options.sleepMs ?? 60_000;
62
78
  this.#now = options.now ?? (() => performance.now());
79
+ this.#cpuNow =
80
+ options.cpuNow ??
81
+ (() => {
82
+ const usage = process.cpuUsage();
83
+ return (usage.user + usage.system) / 1000;
84
+ });
63
85
  this.#schedule =
64
86
  options.schedule ??
65
87
  ((cb, ms) => {
@@ -86,6 +108,7 @@ export class LoopWatchdog {
86
108
  #armTick(): void {
87
109
  const generation = this.#generation;
88
110
  this.#expected = this.#now() + this.#intervalMs;
111
+ this.#expectedCpu = this.#cpuNow();
89
112
  this.#handle = this.#schedule(() => this.#tick(generation), this.#intervalMs);
90
113
  this.#handle.unref?.();
91
114
  }
@@ -93,17 +116,20 @@ export class LoopWatchdog {
93
116
  #tick(generation: number): void {
94
117
  if (!this.#running || generation !== this.#generation) return;
95
118
  const blockedMs = this.#now() - this.#expected;
119
+ const cpuMs = this.#cpuNow() - this.#expectedCpu;
96
120
  // Consume the recent phase every tick (block or not) so attribution is
97
121
  // scoped to the just-elapsed interval and never carries a stale phase
98
122
  // forward to a later, phase-less block.
99
123
  const phase = takeRecentLoopPhase();
100
124
  if (blockedMs > this.#thresholdMs) {
101
- if (blockedMs > this.#sleepMs) {
125
+ if (blockedMs > this.#sleepMs && cpuMs < blockedMs * CPU_BUSY_RATIO) {
126
+ // A long gap the process did not spend CPU on: it was suspended.
102
127
  this.#wasBlocked = false;
103
128
  } else if (!this.#wasBlocked) {
104
129
  this.#wasBlocked = true;
105
130
  logger.warn("ui.loop-blocked", {
106
131
  blockedMs: Math.round(blockedMs),
132
+ cpuMs: Math.round(cpuMs),
107
133
  phase: phase ?? "unknown",
108
134
  });
109
135
  }
package/src/terminal.ts CHANGED
@@ -945,10 +945,11 @@ export class ProcessTerminal implements Terminal {
945
945
  // flush timeout elapses mid-sequence, the prefix `\x1b[?<digits>` arrives as
946
946
  // one event and the tail `;...<terminator>` arrives as individual character
947
947
  // events that would otherwise leak into the prompt as keystrokes. See #1238.
948
- if (
949
- this.#privateCsiResponseBuffer ||
950
- (privateCsiPartialPattern.test(sequence) && this.#da1SentinelOwners.length > 0)
951
- ) {
948
+ // A private CSI (`\x1b[?…`) is a terminal->host report, never a keystroke, so
949
+ // reassembly stays armed for the whole session — not just while a probe
950
+ // sentinel is outstanding — otherwise a reply that lands after the sentinel
951
+ // FIFO drains (slow SSH/PTY links) leaks its tail into the composer (#8542).
952
+ if (this.#privateCsiResponseBuffer || privateCsiPartialPattern.test(sequence)) {
952
953
  if (this.#privateCsiResponseBuffer && sequence.startsWith("\x1b")) {
953
954
  // New escape arrived mid-reassembly — abandon partial and re-process the new sequence.
954
955
  this.#privateCsiResponseBuffer = "";
@@ -1038,10 +1039,16 @@ export class ProcessTerminal implements Terminal {
1038
1039
  }
1039
1040
 
1040
1041
  // DA1 response: swallow our sentinel reply regardless of whether an
1041
- // earlier capability-specific response already succeeded. Other terminal
1042
- // probes should never see these replies.
1043
- if (da1ResponsePattern.test(sequence) && this.#da1SentinelOwners.length > 0) {
1044
- const owner = this.#da1SentinelOwners.shift()!;
1042
+ // earlier capability-specific response already succeeded. `CSI ? … c` is
1043
+ // exclusively a terminal->host report, so it is swallowed even with no
1044
+ // outstanding sentinel — a reply that arrives after the FIFO drains (slow
1045
+ // SSH/PTY links) must never reach the composer as literal text (#8542).
1046
+ if (da1ResponsePattern.test(sequence)) {
1047
+ const owner = this.#da1SentinelOwners.shift();
1048
+ if (!owner) {
1049
+ // Late/unowned reply: nothing to resolve, just drop the bytes.
1050
+ return;
1051
+ }
1045
1052
  switch (owner.kind) {
1046
1053
  case "osc11": {
1047
1054
  if (this.#osc11Pending) {
package/src/tui.ts CHANGED
@@ -1772,13 +1772,76 @@ export class TUI extends Container {
1772
1772
  markers.length = keep;
1773
1773
  }
1774
1774
 
1775
+ /**
1776
+ * Replace the cursor markers contributed by one fixed-size root segment.
1777
+ * The ledger stays sorted by absolute frame row; entries belonging to later
1778
+ * siblings move as one tail and otherwise retain their identity.
1779
+ */
1780
+ #replaceFrameCursorMarkers(startRow: number, lines: readonly string[]): void {
1781
+ const markers = this.#frameCursorMarkers;
1782
+ const endRow = startRow + lines.length;
1783
+ let intervalStart = 0;
1784
+ while (intervalStart < markers.length && markers[intervalStart]!.row < startRow) intervalStart++;
1785
+ let intervalEnd = intervalStart;
1786
+ while (intervalEnd < markers.length && markers[intervalEnd]!.row < endRow) intervalEnd++;
1787
+
1788
+ let nextCount = 0;
1789
+ for (let row = 0; row < lines.length; row++) {
1790
+ if (lines[row]!.includes(CURSOR_MARKER)) nextCount++;
1791
+ }
1792
+
1793
+ const previousCount = intervalEnd - intervalStart;
1794
+ const delta = nextCount - previousCount;
1795
+ const previousLength = markers.length;
1796
+ if (delta > 0) {
1797
+ markers.length = previousLength + delta;
1798
+ for (let index = previousLength - 1; index >= intervalEnd; index--) {
1799
+ markers[index + delta] = markers[index]!;
1800
+ }
1801
+ } else if (delta < 0) {
1802
+ for (let index = intervalEnd; index < previousLength; index++) {
1803
+ markers[index + delta] = markers[index]!;
1804
+ }
1805
+ markers.length = previousLength + delta;
1806
+ }
1807
+
1808
+ const reusableCount = Math.min(previousCount, nextCount);
1809
+ let markerSlot = intervalStart;
1810
+ for (let row = 0; row < lines.length; row++) {
1811
+ const line = lines[row]!;
1812
+ const markerIndex = line.indexOf(CURSOR_MARKER);
1813
+ if (markerIndex === -1) continue;
1814
+ const absoluteRow = startRow + row;
1815
+ const col = visibleWidth(line.slice(0, markerIndex));
1816
+ if (markerSlot < intervalStart + reusableCount) {
1817
+ const marker = markers[markerSlot]!;
1818
+ marker.row = absoluteRow;
1819
+ marker.col = col;
1820
+ } else {
1821
+ markers[markerSlot] = { row: absoluteRow, col };
1822
+ }
1823
+ markerSlot++;
1824
+ }
1825
+ }
1826
+
1827
+ /** Strip every internal cursor sentinel from one rendered row. */
1828
+ #stripCursorMarkers(line: string, markerIndex = line.indexOf(CURSOR_MARKER)): string {
1829
+ if (markerIndex === -1) return line;
1830
+ let stripped = line;
1831
+ while (markerIndex !== -1) {
1832
+ stripped = stripped.slice(0, markerIndex) + stripped.slice(markerIndex + CURSOR_MARKER.length);
1833
+ markerIndex = stripped.indexOf(CURSOR_MARKER, markerIndex);
1834
+ }
1835
+ return stripped;
1836
+ }
1837
+
1775
1838
  /**
1776
1839
  * Append one row to the composed frame, stripping CURSOR_MARKER occurrences
1777
1840
  * (internal sentinels that must never reach the terminal, the committed
1778
1841
  * prefix, or the resync audit) and recording the first marker's position.
1779
1842
  */
1780
1843
  #ingestFrameRow(line: string): void {
1781
- let markerIndex = line.indexOf(CURSOR_MARKER);
1844
+ const markerIndex = line.indexOf(CURSOR_MARKER);
1782
1845
  if (markerIndex === -1) {
1783
1846
  this.#composedFrame.push(line);
1784
1847
  return;
@@ -1787,12 +1850,7 @@ export class TUI extends Container {
1787
1850
  row: this.#composedFrame.length,
1788
1851
  col: visibleWidth(line.slice(0, markerIndex)),
1789
1852
  });
1790
- let stripped = line;
1791
- while (markerIndex !== -1) {
1792
- stripped = stripped.slice(0, markerIndex) + stripped.slice(markerIndex + CURSOR_MARKER.length);
1793
- markerIndex = stripped.indexOf(CURSOR_MARKER, markerIndex);
1794
- }
1795
- this.#composedFrame.push(stripped);
1853
+ this.#composedFrame.push(this.#stripCursorMarkers(line, markerIndex));
1796
1854
  }
1797
1855
 
1798
1856
  #syncTerminalCursorMode(component: Component | null): void {
@@ -2558,12 +2616,6 @@ export class TUI extends Container {
2558
2616
  this.requestComponentRender(component);
2559
2617
  return;
2560
2618
  }
2561
- for (const line of nextLines) {
2562
- if (line.includes(CURSOR_MARKER)) {
2563
- this.requestComponentRender(component);
2564
- return;
2565
- }
2566
- }
2567
2619
 
2568
2620
  let firstChanged = -1;
2569
2621
  let lastChanged = -1;
@@ -2571,8 +2623,9 @@ export class TUI extends Container {
2571
2623
  for (let i = 0; i < nextLines.length; i++) {
2572
2624
  const frameRow = segment.start + i;
2573
2625
  const raw = nextLines[i]!;
2574
- const prepared = this.#prepareLine(raw, width);
2575
- this.#composedFrame[frameRow] = raw;
2626
+ const composed = this.#stripCursorMarkers(raw);
2627
+ const prepared = this.#prepareLine(composed, width);
2628
+ this.#composedFrame[frameRow] = composed;
2576
2629
  this.#preparedMeta[frameRow] = prepared;
2577
2630
  this.#preparedFrame[frameRow] = prepared.line;
2578
2631
  if (previousWindow[screenStart + i] === prepared.line) continue;
@@ -2580,7 +2633,8 @@ export class TUI extends Container {
2580
2633
  if (firstChanged === -1) firstChanged = i;
2581
2634
  lastChanged = i;
2582
2635
  }
2583
- segments[segmentIndex] = { ...segment, lines: nextLines };
2636
+ this.#replaceFrameCursorMarkers(segment.start, nextLines);
2637
+ segment.lines = nextLines;
2584
2638
  this.#preparedValidRows = Math.max(this.#preparedValidRows, segment.start + nextLines.length);
2585
2639
  this.#renderStablePrefixRows = Math.min(this.#renderStablePrefixRows, segment.start);
2586
2640