@oh-my-pi/pi-tui 15.11.1 → 15.11.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.11.3] - 2026-06-11
6
+
7
+ ### Fixed
8
+
9
+ - Fixed the root compose letting a lower child's native-scrollback live seam overwrite a higher one: the topmost seam (and its commit-safe extension) now defines the commit boundary, so a status loader below a streaming transcript can no longer cause still-mutable transcript rows to be committed as stale history ([#2328](https://github.com/can1357/oh-my-pi/pull/2328)).
10
+
11
+ ## [15.11.2] - 2026-06-11
12
+
13
+ ### Fixed
14
+
15
+ - Fixed Ctrl+C/exit corrupting the parent shell on Windows: `emergencyTerminalRestore()` wrote `\x1b[?1049l` (leave alternate screen) unconditionally on every exit path, and conhost/Windows Terminal execute an unconditional cursor restore for it even when the alt buffer was never entered — with no prior save the cursor jumped to the viewport home, so the shell prompt landed on top of the dead frame. The leave sequence is now gated on tracked alt-screen state (set/cleared by the TUI's fullscreen-overlay enter/leave and stop paths).
16
+ - Skipped native syntax highlighting for transient markdown streaming renders, including nested list code blocks, leaving code blocks plain until their content stabilizes to avoid main-thread highlighter spikes.
17
+
5
18
  ## [15.11.1] - 2026-06-11
6
19
  ### Added
7
20
 
@@ -49,13 +49,11 @@ export interface MarkdownTheme {
49
49
  }
50
50
  export declare class Markdown implements Component {
51
51
  #private;
52
- /** When true, skip the module-level LRU (lookup and insert) for this instance's
53
- * renders. Set for in-flight streaming partials whose text changes every frame —
54
- * caching those churns the LRU with near-duplicate full-message snapshots. */
55
- transientRenderCache: boolean;
56
52
  constructor(text: string, paddingX: number, paddingY: number, theme: MarkdownTheme, defaultTextStyle?: DefaultTextStyle, codeBlockIndent?: number);
57
53
  setText(text: string): void;
58
54
  invalidate(): void;
55
+ get transientRenderCache(): boolean;
56
+ set transientRenderCache(value: boolean);
59
57
  render(width: number): readonly string[];
60
58
  }
61
59
  /**
@@ -19,6 +19,8 @@
19
19
  * sole production caller.
20
20
  */
21
21
  export declare function chunkForConPTY(data: string, maxChunkBytes?: number): string[];
22
+ /** Record alternate-screen state (called by the TUI on `?1049h`/`?1049l` writes). */
23
+ export declare function setAltScreenActive(active: boolean): void;
22
24
  /**
23
25
  * Emergency terminal restore - call this from signal/crash handlers
24
26
  * Resets terminal state without requiring access to the ProcessTerminal instance
@@ -83,6 +83,10 @@ export interface Component {
83
83
  * of history until it finalizes. Volatile live blocks (tool previews that
84
84
  * collapse) omit it. Defaults to `liveRegionStart` when absent; a root that
85
85
  * reports no seam at all commits everything that scrolls (shell semantics).
86
+ *
87
+ * When several root children report a seam in the same frame, the topmost
88
+ * one (and its commit-safe extension) defines the boundary: commits are
89
+ * prefix-only, so everything below the first seam is already excluded.
86
90
  */
87
91
  export interface NativeScrollbackLiveRegion {
88
92
  getNativeScrollbackLiveRegionStart(): number | undefined;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "15.11.1",
4
+ "version": "15.11.3",
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": "15.11.1",
41
- "@oh-my-pi/pi-utils": "15.11.1",
40
+ "@oh-my-pi/pi-natives": "15.11.3",
41
+ "@oh-my-pi/pi-utils": "15.11.3",
42
42
  "lru-cache": "11.5.1",
43
43
  "marked": "^18.0.4"
44
44
  },
@@ -295,10 +295,7 @@ export class Markdown implements Component {
295
295
  #cachedText?: string;
296
296
  #cachedWidth?: number;
297
297
  #cachedLines?: readonly string[];
298
- /** When true, skip the module-level LRU (lookup and insert) for this instance's
299
- * renders. Set for in-flight streaming partials whose text changes every frame —
300
- * caching those churns the LRU with near-duplicate full-message snapshots. */
301
- transientRenderCache = false;
298
+ #transientRenderCache = false;
302
299
 
303
300
  constructor(
304
301
  text: string,
@@ -326,6 +323,16 @@ export class Markdown implements Component {
326
323
  this.#cachedWidth = undefined;
327
324
  this.#cachedLines = undefined;
328
325
  }
326
+ get transientRenderCache(): boolean {
327
+ return this.#transientRenderCache;
328
+ }
329
+
330
+ set transientRenderCache(value: boolean) {
331
+ const next = value === true;
332
+ if (this.#transientRenderCache === next) return;
333
+ this.#transientRenderCache = next;
334
+ this.invalidate();
335
+ }
329
336
 
330
337
  render(width: number): readonly string[] {
331
338
  // L1: per-instance cache — fastest path for repeated renders of the same
@@ -618,7 +625,7 @@ export class Markdown implements Component {
618
625
 
619
626
  const codeIndent = padding(this.#codeBlockIndent);
620
627
  lines.push(this.#theme.codeBlockBorder(`\`\`\`${token.lang || ""}`));
621
- if (this.#theme.highlightCode) {
628
+ if (this.#theme.highlightCode && !this.transientRenderCache) {
622
629
  const highlightedLines = this.#theme.highlightCode(token.text, token.lang);
623
630
  for (const hlLine of highlightedLines) {
624
631
  lines.push(`${codeIndent}${hlLine}`);
@@ -909,7 +916,7 @@ export class Markdown implements Component {
909
916
  // Code block in list item
910
917
  const codeIndent = padding(this.#codeBlockIndent);
911
918
  lines.push({ text: this.#theme.codeBlockBorder(`\`\`\`${token.lang || ""}`), nested: false });
912
- if (this.#theme.highlightCode) {
919
+ if (this.#theme.highlightCode && !this.transientRenderCache) {
913
920
  const highlightedLines = this.#theme.highlightCode(token.text, token.lang);
914
921
  for (const hlLine of highlightedLines) {
915
922
  lines.push({ text: `${codeIndent}${hlLine}`, nested: false });
package/src/terminal.ts CHANGED
@@ -122,6 +122,19 @@ export function chunkForConPTY(data: string, maxChunkBytes: number = MAX_CONPTY_
122
122
  let activeTerminal: ProcessTerminal | null = null;
123
123
  // Track if a terminal was ever started (for emergency restore logic)
124
124
  let terminalEverStarted = false;
125
+ // Whether the alternate screen buffer is currently active (mirrors the TUI's
126
+ // overlay enter/leave writes). Consulted by emergencyTerminalRestore: DECRST
127
+ // 1049 must never be written blindly, because Windows' shared VT dispatcher
128
+ // (conhost and Windows Terminal both use AdaptDispatch) executes an
129
+ // unconditional cursor restore on it — with no prior DECSC save the cursor
130
+ // jumps to the viewport home, dropping the parent shell prompt on top of the
131
+ // dead frame after exit.
132
+ let altScreenActive = false;
133
+
134
+ /** Record alternate-screen state (called by the TUI on `?1049h`/`?1049l` writes). */
135
+ export function setAltScreenActive(active: boolean): void {
136
+ altScreenActive = active;
137
+ }
125
138
 
126
139
  const stdoutErrorHandlers = new Set<(err: Error) => void>();
127
140
  let stdoutErrorListenerInstalled = false;
@@ -226,10 +239,15 @@ export function emergencyTerminalRestore(): void {
226
239
  if (terminal) {
227
240
  terminal.stop();
228
241
  // stop() never touches the alternate screen — the TUI owns that
229
- // state and exits it on the normal shutdown path. A crash while a
230
- // fullscreen overlay is up would otherwise strand the shell on the
231
- // alt buffer. Safe no-op when the alt screen is not active.
232
- terminal.write("\x1b[?1049l");
242
+ // state and exits it on the normal shutdown path. Only crash paths
243
+ // with a fullscreen overlay still hold the alt buffer here. The
244
+ // leave sequence is gated on the tracked state because it is NOT a
245
+ // universally safe no-op: Windows' VT dispatcher homes the cursor
246
+ // on DECRST 1049 even when the alt buffer is inactive.
247
+ if (altScreenActive) {
248
+ terminal.write("\x1b[?1049l");
249
+ altScreenActive = false;
250
+ }
233
251
  terminal.showCursor();
234
252
  } else if (terminalEverStarted) {
235
253
  // Blind restore only if we know a terminal was started but lost track of it
@@ -244,9 +262,14 @@ export function emergencyTerminalRestore(): void {
244
262
  "\x1b[<u" + // Pop kitty keyboard protocol
245
263
  "\x1b[>4;0m" + // Disable modifyOtherKeys fallback
246
264
  "\x1b[?1006l\x1b[?1003l\x1b[?1000l" + // Disable mouse tracking (fullscreen overlays)
247
- "\x1b[?1049l" + // Leave the alternate screen (fullscreen overlays)
265
+ // Leave the alternate screen only when a fullscreen overlay
266
+ // actually holds it — on Windows, DECRST 1049 on the main
267
+ // buffer homes the cursor (unconditional CursorRestoreState
268
+ // with no prior save), corrupting the shell handoff on exit.
269
+ (altScreenActive ? "\x1b[?1049l" : "") +
248
270
  "\x1b[?25h", // Show cursor
249
271
  );
272
+ altScreenActive = false;
250
273
  if (process.stdin.setRawMode) {
251
274
  process.stdin.setRawMode(false);
252
275
  }
package/src/tui.ts CHANGED
@@ -16,7 +16,7 @@ import { $flag, getDebugLogPath } from "@oh-my-pi/pi-utils";
16
16
  import { DEFAULT_MAX_INLINE_IMAGES, ImageBudget } from "./components/image";
17
17
  import { planDeccaraFills } from "./deccara";
18
18
  import { isKeyRelease, matchesKey } from "./keys";
19
- import { isConPTYHosted, type Terminal } from "./terminal";
19
+ import { isConPTYHosted, setAltScreenActive, type Terminal } from "./terminal";
20
20
  import {
21
21
  encodeKittyDeleteImage,
22
22
  ImageProtocol,
@@ -185,6 +185,10 @@ export interface Component {
185
185
  * of history until it finalizes. Volatile live blocks (tool previews that
186
186
  * collapse) omit it. Defaults to `liveRegionStart` when absent; a root that
187
187
  * reports no seam at all commits everything that scrolls (shell semantics).
188
+ *
189
+ * When several root children report a seam in the same frame, the topmost
190
+ * one (and its commit-safe extension) defines the boundary: commits are
191
+ * prefix-only, so everything below the first seam is already excluded.
188
192
  */
189
193
  export interface NativeScrollbackLiveRegion {
190
194
  getNativeScrollbackLiveRegionStart(): number | undefined;
@@ -833,7 +837,13 @@ export class TUI extends Container {
833
837
  // the last render the engine actually observed.
834
838
  reported = getRenderStablePrefixRows(child);
835
839
  }
836
- if (liveLocalStart !== undefined) {
840
+ // Topmost seam wins. Commits are prefix-only: the first child that
841
+ // reports a live region (plus its own commit-safe extension) already
842
+ // bounds everything below it, so a lower sibling's seam (e.g. a
843
+ // status loader under a streaming transcript) must never overwrite
844
+ // it — moving the boundary down would commit the earlier child's
845
+ // still-mutable rows as stale history.
846
+ if (liveLocalStart !== undefined && this.#nativeScrollbackLiveRegionStart === undefined) {
837
847
  this.#nativeScrollbackLiveRegionStart = offset + liveLocalStart;
838
848
  if (commitLocalEnd !== undefined) {
839
849
  this.#nativeScrollbackCommitSafeEnd = offset + commitLocalEnd;
@@ -1326,6 +1336,7 @@ export class TUI extends Container {
1326
1336
  // the restored normal screen (which #previousLines still describes).
1327
1337
  if (this.#altActive) {
1328
1338
  this.terminal.write(`${MOUSE_TRACKING_OFF}\x1b[?1049l`);
1339
+ setAltScreenActive(false);
1329
1340
  this.#altActive = false;
1330
1341
  this.#altPreviousLines = [];
1331
1342
  }
@@ -2049,6 +2060,7 @@ export class TUI extends Container {
2049
2060
  const wantAlt = this.#wantsAltScreen();
2050
2061
  if (wantAlt && !this.#altActive) {
2051
2062
  this.terminal.write(`\x1b[?1049h${MOUSE_TRACKING_ON}`);
2063
+ setAltScreenActive(true);
2052
2064
  this.terminal.hideCursor();
2053
2065
  this.#forgetHardwareCursorState();
2054
2066
  this.#recordHardwareCursorHidden();
@@ -2058,6 +2070,7 @@ export class TUI extends Container {
2058
2070
  this.#altEnterHeight = height;
2059
2071
  } else if (!wantAlt && this.#altActive) {
2060
2072
  this.terminal.write(`${MOUSE_TRACKING_OFF}\x1b[?1049l`);
2073
+ setAltScreenActive(false);
2061
2074
  this.#forgetHardwareCursorState();
2062
2075
  this.#altActive = false;
2063
2076
  this.#altPreviousLines = [];