jeopi-tui 16.4.2 → 16.4.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,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.4.3] - 2026-07-22
6
+
7
+ ### Fixed
8
+
9
+ - Fixed ordinary navigation keys (arrows, page up/down, etc.) paying a full frame of input-render-grace latency meant only for the Ctrl+C/Esc double-press gesture window: the grace period now arms only when the incoming key is Ctrl+C or Esc, so idle-state keyboard navigation repaints immediately again. Ported from oh-my-pi (upstream `1822603b2`, TUI portion only — the accompanying Model Hub/model-browser keyboard-navigation changes are coupled to jeopi's not-yet-ported Model Hub feature).
10
+ - Fixed forced renders (tool finalization, `resetDisplay`, image reconciliation) landing during a resize drag preempting the alternate-screen viewport fast path: each one left the borrowed alt screen, erased native scrollback (ED3), and visibly replayed the whole transcript on the normal screen mid-drag — then the settle replayed it again. Forced intent now folds into the single authoritative settle paint. Ported from oh-my-pi (upstream `485d207a7`).
11
+ - Hid empty HTML comment separators in Markdown-rendered TUI output instead of showing `<!-- -->` literally. Ported from oh-my-pi (upstream `aeed4d10d`).
12
+ - Fixed unmanaged macOS stderr writes (libmalloc/framework diagnostics) corrupting the viewport: `ProcessTerminal` now suppresses fd 2 via the jeopi-utils stderr guard while it owns the terminal and restores it in `stop()` and the emergency-restore path. Ported from oh-my-pi (upstream `4eaca82fa` by @Kormákur).
13
+ - Fixed completed rows in transient `diff`/`patch`/`udiff` fences entering native terminal scrollback without semantic syntax colors: newline-complete rows are now highlighted incrementally while the final partial row remains lightweight; closed fences and blank completed rows preserve their final layout and styling. Ported from oh-my-pi (upstream `e41b32c87`, `936e83e3d`, `cf6d25f1b`).
14
+
5
15
  ## [16.2.25] - 2026-07-05
6
16
 
7
17
  ### Added
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "jeopi-tui",
4
- "version": "16.4.2",
4
+ "version": "16.4.3",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://github.com/akillness/jeopi",
7
7
  "author": "Can Boluk",
@@ -37,8 +37,8 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "jeopi-natives": "16.4.2",
41
- "jeopi-utils": "16.4.2",
40
+ "jeopi-natives": "16.4.3",
41
+ "jeopi-utils": "16.4.3",
42
42
  "lru-cache": "11.5.1",
43
43
  "marked": "^18.0.5"
44
44
  },
@@ -86,6 +86,7 @@ function createHtmlNormalizationState(): HtmlNormalizationState {
86
86
  return { lists: [], openItems: [], itemHasContent: [] };
87
87
  }
88
88
 
89
+ const HTML_COMMENT_REGEX = /<!--[\s\S]*?-->/g;
89
90
  const HTML_TAG_REGEX = /<\/?(?:br|p|ol|ul|li|span|text|code|hr|blockquote)\b(?:\s[^>]*)?\s*\/?>/gi;
90
91
  // Block-level HTML that needs structural (not just textual) rendering: standalone
91
92
  // `<hr>` becomes a rule and balanced `<blockquote>…</blockquote>` renders with
@@ -136,11 +137,12 @@ function normalizeHtmlForTerminal(
136
137
  let output = "";
137
138
  let lastIndex = 0;
138
139
  let inCode = false;
140
+ const withoutComments = raw.replace(HTML_COMMENT_REGEX, "");
139
141
 
140
- for (const match of raw.matchAll(HTML_TAG_REGEX)) {
142
+ for (const match of withoutComments.matchAll(HTML_TAG_REGEX)) {
141
143
  const tag = match[0];
142
144
  const index = match.index ?? 0;
143
- const textBeforeTag = normalizeHtmlEntitiesForTerminal(raw.slice(lastIndex, index));
145
+ const textBeforeTag = normalizeHtmlEntitiesForTerminal(withoutComments.slice(lastIndex, index));
144
146
  const name = htmlTagName(tag);
145
147
  // Most tags handled here are block-level. Inline contexts — span, text, and
146
148
  // the content inside a `<code>` run — keep their surrounding whitespace
@@ -238,7 +240,7 @@ function normalizeHtmlForTerminal(
238
240
  }
239
241
  }
240
242
 
241
- const remainingText = normalizeHtmlEntitiesForTerminal(raw.slice(lastIndex));
243
+ const remainingText = normalizeHtmlEntitiesForTerminal(withoutComments.slice(lastIndex));
242
244
  markCurrentHtmlItemContent(state, remainingText);
243
245
  return output + (inCode && codeHook ? codeHook(remainingText) : remainingText);
244
246
  }
@@ -789,6 +791,12 @@ interface StreamPrefixLineCache extends RenderSignature {
789
791
  lines: readonly string[];
790
792
  }
791
793
 
794
+ interface StreamingDiffLineCache extends RenderSignature {
795
+ lang: string | undefined;
796
+ text: string;
797
+ lines: readonly string[];
798
+ }
799
+
792
800
  export class Markdown implements Component {
793
801
  #text: string;
794
802
  #paddingX: number; // Left/right padding
@@ -817,6 +825,8 @@ export class Markdown implements Component {
817
825
  #streamPrefixText?: string;
818
826
  #streamPrefixTokens?: Token[];
819
827
  #streamPrefixLineCache?: StreamPrefixLineCache;
828
+ #streamingDiffLineCache?: StreamingDiffLineCache;
829
+ #activeRenderSignature?: RenderSignature;
820
830
 
821
831
  #ignoreTight = false;
822
832
 
@@ -1001,9 +1011,15 @@ export class Markdown implements Component {
1001
1011
 
1002
1012
  // Parse markdown to HTML-like tokens
1003
1013
  const tokens = this.#lexTokens(normalizedText);
1004
- const contentLines = this.transientRenderCache
1005
- ? this.#renderStreamingContentLines(tokens, normalizedText, signature, contentWidth)
1006
- : this.#renderContentLines(tokens, 0, tokens.length, contentWidth, signature);
1014
+ let contentLines: string[];
1015
+ this.#activeRenderSignature = signature;
1016
+ try {
1017
+ contentLines = this.transientRenderCache
1018
+ ? this.#renderStreamingContentLines(tokens, normalizedText, signature, contentWidth)
1019
+ : this.#renderContentLines(tokens, 0, tokens.length, contentWidth, signature);
1020
+ } finally {
1021
+ this.#activeRenderSignature = undefined;
1022
+ }
1007
1023
  const emptyLines = this.#renderEmptyPaddingLines(signature);
1008
1024
 
1009
1025
  // Combine top padding, content, and bottom padding
@@ -1177,6 +1193,120 @@ export class Markdown implements Component {
1177
1193
  return contentLines;
1178
1194
  }
1179
1195
 
1196
+ #renderCodeBodyLines(token: Token, codeIndent: string): string[] {
1197
+ const bodyLines: string[] = [];
1198
+ const tokenText = "text" in token && typeof token.text === "string" ? token.text : "";
1199
+ const lang = "lang" in token && typeof token.lang === "string" ? token.lang : undefined;
1200
+ const normalizedLang = lang?.toLowerCase();
1201
+ const canStreamDiff =
1202
+ this.transientRenderCache &&
1203
+ this.#theme.highlightCode &&
1204
+ (normalizedLang === "diff" || normalizedLang === "patch" || normalizedLang === "udiff");
1205
+
1206
+ if (this.#theme.highlightCode && !this.transientRenderCache) {
1207
+ for (const highlightedLine of this.#theme.highlightCode(tokenText, lang)) {
1208
+ bodyLines.push(`${codeIndent}${highlightedLine}`);
1209
+ }
1210
+ return bodyLines;
1211
+ }
1212
+
1213
+ if (canStreamDiff) {
1214
+ const closedFence = this.#codeTokenHasClosingFence(token);
1215
+ const lineEnd = tokenText.lastIndexOf("\n");
1216
+ if (closedFence || lineEnd >= 0) {
1217
+ const completedText = closedFence ? tokenText : tokenText.slice(0, lineEnd);
1218
+ for (const highlightedLine of this.#highlightStreamingDiffLines(completedText, lang)) {
1219
+ bodyLines.push(`${codeIndent}${highlightedLine}`);
1220
+ }
1221
+ if (!closedFence) {
1222
+ for (const codeLine of tokenText.slice(lineEnd + 1).split("\n")) {
1223
+ bodyLines.push(`${codeIndent}${this.#theme.codeBlock(codeLine)}`);
1224
+ }
1225
+ }
1226
+ return bodyLines;
1227
+ }
1228
+ }
1229
+
1230
+ for (const codeLine of tokenText.split("\n")) {
1231
+ bodyLines.push(`${codeIndent}${this.#theme.codeBlock(codeLine)}`);
1232
+ }
1233
+ return bodyLines;
1234
+ }
1235
+
1236
+ #codeTokenHasClosingFence(token: Token): boolean {
1237
+ const raw = "raw" in token && typeof token.raw === "string" ? token.raw : "";
1238
+ const firstLineEnd = raw.indexOf("\n");
1239
+ if (firstLineEnd < 0) return false;
1240
+ const openingLine = raw.slice(0, firstLineEnd);
1241
+ const openingTrimmed = openingLine.trimStart();
1242
+ const openingIndent = openingLine.length - openingTrimmed.length;
1243
+ if (openingIndent > 3) return false;
1244
+ const fenceChar = openingTrimmed.charAt(0);
1245
+ if (fenceChar !== "`" && fenceChar !== "~") return false;
1246
+ let fenceLength = 0;
1247
+ while (openingTrimmed.charAt(fenceLength) === fenceChar) fenceLength++;
1248
+ if (fenceLength < 3) return false;
1249
+
1250
+ let lineStart = firstLineEnd + 1;
1251
+ while (lineStart <= raw.length) {
1252
+ const lineEnd = raw.indexOf("\n", lineStart);
1253
+ const line = lineEnd >= 0 ? raw.slice(lineStart, lineEnd) : raw.slice(lineStart);
1254
+ const trimmed = line.trimStart();
1255
+ const indent = line.length - trimmed.length;
1256
+ let closingLength = 0;
1257
+ while (trimmed.charAt(closingLength) === fenceChar) closingLength++;
1258
+ if (indent <= 3 && closingLength >= fenceLength && trimmed.slice(closingLength).trim().length === 0) {
1259
+ return true;
1260
+ }
1261
+ if (lineEnd < 0) break;
1262
+ lineStart = lineEnd + 1;
1263
+ }
1264
+ return false;
1265
+ }
1266
+
1267
+ #highlightStreamingDiffLines(completedText: string, lang: string | undefined): readonly string[] {
1268
+ const highlightCode = this.#theme.highlightCode;
1269
+ if (!highlightCode) return [];
1270
+ const signature = this.#activeRenderSignature;
1271
+ const cache = this.#streamingDiffLineCache;
1272
+ if (
1273
+ signature &&
1274
+ cache &&
1275
+ completedText.startsWith(cache.text) &&
1276
+ (cache.text.length === completedText.length || completedText.charCodeAt(cache.text.length) === 0x0a) &&
1277
+ cache.lang === lang &&
1278
+ cache.width === signature.width &&
1279
+ cache.paddingX === signature.paddingX &&
1280
+ cache.paddingY === signature.paddingY &&
1281
+ cache.codeBlockIndent === signature.codeBlockIndent &&
1282
+ cache.themeId === signature.themeId &&
1283
+ cache.defaultTextStyleId === signature.defaultTextStyleId &&
1284
+ cache.imageProtocol === signature.imageProtocol &&
1285
+ cache.hyperlinks === signature.hyperlinks &&
1286
+ cache.textSizing === signature.textSizing &&
1287
+ cache.bgColorProbe === signature.bgColorProbe &&
1288
+ cache.headingProbe === signature.headingProbe
1289
+ ) {
1290
+ if (completedText.length === cache.text.length) return cache.lines;
1291
+ const lines = cache.lines.slice();
1292
+ const addedText = completedText.slice(cache.text.length === 0 ? 0 : cache.text.length + 1);
1293
+ for (const codeLine of addedText.split("\n")) {
1294
+ lines.push(...highlightCode(codeLine, lang));
1295
+ }
1296
+ this.#streamingDiffLineCache = { ...signature, lang, text: completedText, lines };
1297
+ return lines;
1298
+ }
1299
+
1300
+ const lines: string[] = [];
1301
+ for (const codeLine of completedText.split("\n")) {
1302
+ lines.push(...highlightCode(codeLine, lang));
1303
+ }
1304
+ if (signature) {
1305
+ this.#streamingDiffLineCache = { ...signature, lang, text: completedText, lines };
1306
+ }
1307
+ return lines;
1308
+ }
1309
+
1180
1310
  #renderEmptyPaddingLines(signature: RenderSignature): string[] {
1181
1311
  const emptyLine = padding(signature.width);
1182
1312
  const emptyLines: string[] = [];
@@ -1354,17 +1484,8 @@ export class Markdown implements Component {
1354
1484
 
1355
1485
  const codeIndent = padding(this.#codeBlockIndent);
1356
1486
  lines.push(this.#theme.codeBlockBorder(`\`\`\`${token.lang || ""}`));
1357
- if (this.#theme.highlightCode && !this.transientRenderCache) {
1358
- const highlightedLines = this.#theme.highlightCode(token.text, token.lang);
1359
- for (const hlLine of highlightedLines) {
1360
- lines.push(`${codeIndent}${hlLine}`);
1361
- }
1362
- } else {
1363
- // Split code by newlines and style each line
1364
- const codeLines = token.text.split("\n");
1365
- for (const codeLine of codeLines) {
1366
- lines.push(`${codeIndent}${this.#theme.codeBlock(codeLine)}`);
1367
- }
1487
+ for (const bodyLine of this.#renderCodeBodyLines(token, codeIndent)) {
1488
+ lines.push(bodyLine);
1368
1489
  }
1369
1490
  lines.push(this.#theme.codeBlockBorder("```"));
1370
1491
  if (nextTokenType && nextTokenType !== "space") {
@@ -1744,16 +1865,8 @@ export class Markdown implements Component {
1744
1865
  // Code block in list item
1745
1866
  const codeIndent = padding(this.#codeBlockIndent);
1746
1867
  lines.push({ text: this.#theme.codeBlockBorder(`\`\`\`${token.lang || ""}`), nested: false });
1747
- if (this.#theme.highlightCode && !this.transientRenderCache) {
1748
- const highlightedLines = this.#theme.highlightCode(token.text, token.lang);
1749
- for (const hlLine of highlightedLines) {
1750
- lines.push({ text: `${codeIndent}${hlLine}`, nested: false });
1751
- }
1752
- } else {
1753
- const codeLines = token.text.split("\n");
1754
- for (const codeLine of codeLines) {
1755
- lines.push({ text: `${codeIndent}${this.#theme.codeBlock(codeLine)}`, nested: false });
1756
- }
1868
+ for (const bodyLine of this.#renderCodeBodyLines(token, codeIndent)) {
1869
+ lines.push({ text: bodyLine, nested: false });
1757
1870
  }
1758
1871
  lines.push({ text: this.#theme.codeBlockBorder("```"), nested: false });
1759
1872
  } else if (isMathToken(token)) {
package/src/terminal.ts CHANGED
@@ -1,6 +1,13 @@
1
1
  import { dlopen, FFIType, ptr } from "bun:ffi";
2
2
  import * as fs from "node:fs";
3
- import { $env, isBunTestRuntime, isTerminalHeadless, logger } from "jeopi-utils";
3
+ import {
4
+ $env,
5
+ isBunTestRuntime,
6
+ isTerminalHeadless,
7
+ logger,
8
+ restoreTerminalStderr,
9
+ suppressTerminalStderr,
10
+ } from "jeopi-utils";
4
11
  import { setKittyProtocolActive } from "./keys";
5
12
  import { StdinBuffer } from "./stdin-buffer";
6
13
  import {
@@ -261,6 +268,9 @@ function createConsoleCodepageGuard(): (() => void) | null {
261
268
  */
262
269
  export function emergencyTerminalRestore(): void {
263
270
  try {
271
+ // Crash paths must surface subsequent stderr (fatal reports) on the
272
+ // real terminal; no-op when the stderr guard is inactive.
273
+ restoreTerminalStderr();
264
274
  const terminal = activeTerminal;
265
275
  if (terminal) {
266
276
  terminal.stop();
@@ -523,6 +533,11 @@ export class ProcessTerminal implements Terminal {
523
533
  activeTerminal = this;
524
534
  terminalEverStarted = true;
525
535
 
536
+ // Keep unmanaged fd-2 writes (macOS libmalloc/framework diagnostics) off
537
+ // the viewport while we own the terminal; released in stop(). See
538
+ // stderr-guard in jeopi-utils (mirrors openai/codex#24459).
539
+ suppressTerminalStderr();
540
+
526
541
  // Save previous state and enable raw mode
527
542
  this.#wasRaw = process.stdin.isRaw || false;
528
543
  if (process.stdin.setRawMode) {
@@ -1235,6 +1250,11 @@ export class ProcessTerminal implements Terminal {
1235
1250
  activeTerminal = null;
1236
1251
  }
1237
1252
 
1253
+ // Release terminal ownership of fd 2 first so external programs,
1254
+ // suspend, and shutdown see the real stderr even if a later teardown
1255
+ // step throws.
1256
+ restoreTerminalStderr();
1257
+
1238
1258
  if (this.#clearProgressTimer()) {
1239
1259
  this.#safeWrite(TERMINAL_PROGRESS_CLEAR_SEQUENCE);
1240
1260
  }
package/src/tui.ts CHANGED
@@ -2155,12 +2155,12 @@ export class TUI extends Container {
2155
2155
  }
2156
2156
 
2157
2157
  #handleInput(data: string): void {
2158
- // Raw-mode Ctrl+C/Esc arrive as stdin data, not process signals. If the
2159
- // first key in a double-key gesture schedules an immediate slow repaint,
2160
- // the queued second key can sit behind that repaint long enough for the
2161
- // app-level double-press window to expire. Give the input queue one frame
2162
- // before ordinary paints; forced repaints still bypass this path.
2163
- this.#inputRenderGraceUntilMs = this.#renderScheduler.now() + TUI.#INPUT_RENDER_GRACE_MS;
2158
+ // Ctrl+C/Esc use app-level double-press windows. Give those gestures one
2159
+ // frame to drain queued input before an ordinary repaint; delaying every
2160
+ // key would make idle navigation pay a full frame of latency.
2161
+ if (matchesKey(data, "ctrl+c") || matchesKey(data, "escape")) {
2162
+ this.#inputRenderGraceUntilMs = this.#renderScheduler.now() + TUI.#INPUT_RENDER_GRACE_MS;
2163
+ }
2164
2164
  if (this.#inputListeners.size > 0) {
2165
2165
  let current = data;
2166
2166
  for (const listener of this.#inputListeners) {
@@ -2612,18 +2612,19 @@ export class TUI extends Container {
2612
2612
  // alternate screen to repaint the whole transcript on the normal
2613
2613
  // screen — then the next SIGWINCH re-enters the alt screen and paints
2614
2614
  // only the tail, so the block flashes in for one frame and vanishes.
2615
- // A forced render (tool finalization, reset, image reconciliation) must
2616
- // still preempt: it set #forceViewportRepaintOnNextRender via
2617
- // #prepareForcedRender and owns the next authoritative paint, so it falls
2618
- // through. A visible overlay composites over the transcript and needs the
2619
- // whole window, so it also falls through (overlay resizes are not on the
2620
- // drag-cost hot path).
2621
- if (
2622
- this.#resizeViewportActive &&
2623
- !this.#forceViewportRepaintOnNextRender &&
2624
- this.#hasEverRendered &&
2625
- this.#getTopmostVisibleOverlay() === undefined
2626
- ) {
2615
+ // A FORCED render mid-drag (tool finalization, resetDisplay, image
2616
+ // reconciliation) also stays on the fast path: preempting would leave
2617
+ // the borrowed alternate screen and run the geometry-rebuild full paint
2618
+ // on the normal screen ED3 plus an O(history) replay that visibly
2619
+ // scrolls the whole transcript through the viewport, once per forced
2620
+ // render and once more at settle. The forced intent is not lost: the
2621
+ // fast path consumes neither #forceViewportRepaintOnNextRender nor
2622
+ // #clearScrollbackOnNextRender, and the settle's authoritative
2623
+ // requestRender(true) honors both — same fold-into-the-settle contract
2624
+ // as the multiplexer resize debounce. A visible overlay composites over
2625
+ // the transcript and needs the whole window, so it falls through
2626
+ // (overlay resizes are not on the drag-cost hot path).
2627
+ if (this.#resizeViewportActive && this.#hasEverRendered && this.#getTopmostVisibleOverlay() === undefined) {
2627
2628
  this.#componentRenderTargets.clear();
2628
2629
  this.#renderResizeViewport(width, height);
2629
2630
  return;