jeopi-tui 16.4.2 → 16.4.4

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
@@ -73,6 +73,13 @@ export interface AutocompleteProvider {
73
73
  shouldTriggerFileCompletion?(lines: string[], cursorLine: number, cursorCol: number): boolean;
74
74
  }
75
75
  type CommandEntry = SlashCommand | AutocompleteItem;
76
+ /**
77
+ * Whether a mid-prompt slash token is constrained enough to surface a skill.
78
+ * Mid-prompt prose must not keep the popup alive through fuzzy description
79
+ * matches; only namespace prefixes, explicit `skill:` queries, and bare-name
80
+ * prefixes qualify.
81
+ */
82
+ export declare function midPromptSkillTokenMatches(lowerToken: string, name: string, description?: string): boolean;
76
83
  export declare class CombinedAutocompleteProvider implements AutocompleteProvider {
77
84
  #private;
78
85
  constructor(commands?: CommandEntry[], basePath?: string);
@@ -420,4 +420,14 @@ export declare class TUI extends Container {
420
420
  * cheaper.
421
421
  */
422
422
  requestComponentRender(component: Component): void;
423
+ /**
424
+ * Rewrite a quiet, visible component segment directly.
425
+ *
426
+ * Loader-style animation changes one already-positioned segment at a fixed
427
+ * size. When the current frame geometry is still valid, rewrite just those
428
+ * rows and update the diff baseline instead of scheduling a full render
429
+ * cycle. Unsafe states fall back to `requestComponentRender()`, preserving
430
+ * the ordinary renderer as the correctness path.
431
+ */
432
+ requestDirectWrite(component: Component): void;
423
433
  }
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.4",
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.4",
41
+ "jeopi-utils": "16.4.4",
42
42
  "lru-cache": "11.5.1",
43
43
  "marked": "^18.0.5"
44
44
  },
@@ -370,9 +370,33 @@ function hasPromptTextBeforeSlash(
370
370
  return textBeforeCursor.slice(0, slashStart).trim() !== "";
371
371
  }
372
372
 
373
+ const SKILL_NAMESPACE = "skill:";
374
+
375
+ /**
376
+ * Whether a mid-prompt slash token is constrained enough to surface a skill.
377
+ * Mid-prompt prose must not keep the popup alive through fuzzy description
378
+ * matches; only namespace prefixes, explicit `skill:` queries, and bare-name
379
+ * prefixes qualify.
380
+ */
381
+ export function midPromptSkillTokenMatches(lowerToken: string, name: string, description?: string): boolean {
382
+ if (SKILL_NAMESPACE.startsWith(lowerToken)) return true;
383
+ const lowerName = name.toLowerCase();
384
+ if (lowerToken.startsWith(SKILL_NAMESPACE)) {
385
+ if (scoreCommandTextMatch(lowerToken, lowerName) > 0) return true;
386
+ return !!description && scoreCommandTextMatch(lowerToken, description.toLowerCase()) > 0;
387
+ }
388
+ return lowerName.startsWith(SKILL_NAMESPACE) && lowerName.slice(SKILL_NAMESPACE.length).startsWith(lowerToken);
389
+ }
390
+
373
391
  function buildMidPromptSkillCompletions(commands: CommandEntry[], lowerPrefix: string): AutocompleteItem[] {
374
392
  return buildSlashCommandCompletions(
375
- commands.filter(cmd => getCommandName(cmd)?.startsWith("skill:")),
393
+ commands.filter(cmd => {
394
+ const name = getCommandName(cmd);
395
+ return (
396
+ name?.startsWith(SKILL_NAMESPACE) &&
397
+ midPromptSkillTokenMatches(lowerPrefix, name, getStaticCommandDescription(cmd))
398
+ );
399
+ }),
376
400
  lowerPrefix,
377
401
  );
378
402
  }
@@ -3,6 +3,7 @@ import {
3
3
  type AutocompleteProvider,
4
4
  findLeadingSlashCommandStart,
5
5
  findTrailingSlashCommandStart,
6
+ midPromptSkillTokenMatches,
6
7
  } from "../autocomplete";
7
8
  import { BracketedPasteHandler, decodeReencodedPasteControls } from "../bracketed-paste";
8
9
  import { getKeybindings, type KeybindingsManager } from "../keybindings";
@@ -21,7 +22,7 @@ import {
21
22
  truncateToWidth,
22
23
  visibleWidth,
23
24
  } from "../utils";
24
- import { SelectList, type SelectListLayoutOptions, type SelectListTheme } from "./select-list";
25
+ import { type SelectItem, SelectList, type SelectListLayoutOptions, type SelectListTheme } from "./select-list";
25
26
 
26
27
  const AUTOCOMPLETE_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
27
28
  overflowSearch: false,
@@ -1116,10 +1117,21 @@ export class Editor implements Component, Focusable {
1116
1117
  this.onAutocompleteUpdate?.();
1117
1118
  return;
1118
1119
  }
1119
-
1120
- // If Tab was pressed, always apply the selection
1120
+ // If Tab was pressed, apply the selection only while it still maps to
1121
+ // the live buffer. This matters for debounced mid-prompt skill refreshes.
1121
1122
  if (kb.matches(data, "tui.input.tab")) {
1122
1123
  const selected = this.#autocompleteList.getSelectedItem();
1124
+ const shouldGuardSkillCompletion =
1125
+ selected?.value.startsWith("skill:") &&
1126
+ findTrailingSlashCommandStart(this.#autocompletePrefix) !== null;
1127
+ if (shouldGuardSkillCompletion) {
1128
+ const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
1129
+ const currentTextBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1130
+ if (!this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor, selected)) {
1131
+ this.#cancelAutocomplete();
1132
+ return;
1133
+ }
1134
+ }
1123
1135
  if (selected && this.#autocompleteProvider) {
1124
1136
  const shouldChainSlashCommandAutocomplete = this.#isSlashCommandNameAutocompleteSelection();
1125
1137
  const result = this.#autocompleteProvider.applyCompletion(
@@ -1149,7 +1161,6 @@ export class Editor implements Component, Focusable {
1149
1161
  }
1150
1162
  return;
1151
1163
  }
1152
-
1153
1164
  // If Enter was pressed on a slash command, apply completion and submit
1154
1165
  if (
1155
1166
  (kb.matches(data, "tui.input.submit") || data === "\n") &&
@@ -1158,11 +1169,11 @@ export class Editor implements Component, Focusable {
1158
1169
  // Check for stale autocomplete state due to debounce
1159
1170
  const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
1160
1171
  const currentTextBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1161
- if (!this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor)) {
1172
+ const selected = this.#autocompleteList.getSelectedItem();
1173
+ if (!this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor, selected)) {
1162
1174
  // Autocomplete is stale - cancel and fall through to normal submission
1163
1175
  this.#cancelAutocomplete();
1164
1176
  } else {
1165
- const selected = this.#autocompleteList.getSelectedItem();
1166
1177
  if (selected && this.#autocompleteProvider) {
1167
1178
  const result = this.#autocompleteProvider.applyCompletion(
1168
1179
  this.#state.lines,
@@ -2844,8 +2855,21 @@ export class Editor implements Component, Focusable {
2844
2855
  return this.#isInSubmittedSlashCommandContext() || this.#isInMidPromptSkillSlashContext();
2845
2856
  }
2846
2857
 
2847
- #autocompletePrefixMatchesCursorText(currentTextBeforeCursor: string): boolean {
2858
+ #autocompletePrefixMatchesCursorText(currentTextBeforeCursor: string, item?: SelectItem | null): boolean {
2848
2859
  if (currentTextBeforeCursor === this.#autocompletePrefix) return true;
2860
+
2861
+ if (item?.value.startsWith("skill:") && findTrailingSlashCommandStart(this.#autocompletePrefix) !== null) {
2862
+ const currentTrailingStart = findTrailingSlashCommandStart(currentTextBeforeCursor);
2863
+ if (currentTrailingStart !== null) {
2864
+ const token = currentTextBeforeCursor.slice(currentTrailingStart);
2865
+ if (!token.includes(" ") && !token.slice(1).includes("/")) {
2866
+ const lowerToken = token.slice(1).toLowerCase();
2867
+ if (midPromptSkillTokenMatches(lowerToken, item.value, item.description)) return true;
2868
+ }
2869
+ }
2870
+ return false;
2871
+ }
2872
+
2849
2873
  if (findTrailingSlashCommandStart(this.#autocompletePrefix) !== 0) return false;
2850
2874
  const slashStart = findTrailingSlashCommandStart(currentTextBeforeCursor);
2851
2875
  return slashStart !== null && currentTextBeforeCursor.slice(slashStart) === this.#autocompletePrefix;
@@ -93,11 +93,15 @@ export class Loader extends Text {
93
93
  const frame = this.#frames[this.#currentFrame];
94
94
  const text = `${this.spinnerColorFn(frame)} ${this.messageColorFn(this.message)}`;
95
95
  if (this.setText(text) && this.#ui) {
96
- // Component-scoped: a spinner tick changes only this component, so
97
- // the TUI may reuse every other root subtree instead of re-walking
98
- // the whole tree (full repaints at 12.5 Hz made huge transcripts
99
- // lag as soon as the loader appeared).
100
- this.#ui.requestComponentRender(this);
96
+ // Direct write: a loader tick changes only this component, so the TUI
97
+ // can update the already-positioned rows without driving the full
98
+ // compose/prepare/diff pipeline. Lightweight test stubs may not carry
99
+ // the newer API; keep their legacy component-scoped path working.
100
+ if (typeof this.#ui.requestDirectWrite === "function") {
101
+ this.#ui.requestDirectWrite(this);
102
+ } else {
103
+ this.#ui.requestComponentRender(this);
104
+ }
101
105
  }
102
106
  }
103
107
  }
@@ -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
@@ -1903,6 +1903,154 @@ export class TUI extends Container {
1903
1903
  this.#componentRenderTargets.add(component);
1904
1904
  this.#requestOrdinaryRender();
1905
1905
  }
1906
+ /**
1907
+ * Rewrite a quiet, visible component segment directly.
1908
+ *
1909
+ * Loader-style animation changes one already-positioned segment at a fixed
1910
+ * size. When the current frame geometry is still valid, rewrite just those
1911
+ * rows and update the diff baseline instead of scheduling a full render
1912
+ * cycle. Unsafe states fall back to `requestComponentRender()`, preserving
1913
+ * the ordinary renderer as the correctness path.
1914
+ */
1915
+ requestDirectWrite(component: Component): void {
1916
+ if (this.#stopped) return;
1917
+ if (
1918
+ this.#renderRequested ||
1919
+ this.#postFullPaintSettleTimer !== undefined ||
1920
+ this.#postFullPaintSettleUntilMs > 0
1921
+ ) {
1922
+ this.requestComponentRender(component);
1923
+ return;
1924
+ }
1925
+
1926
+ const width = this.terminal.columns;
1927
+ const height = this.terminal.rows;
1928
+ if (!this.#hasEverRendered || this.#resizeEventPending) {
1929
+ this.requestComponentRender(component);
1930
+ return;
1931
+ }
1932
+ if (width !== this.#previousWidth || height !== this.#previousHeight || width !== this.#composeWidth) {
1933
+ this.requestComponentRender(component);
1934
+ return;
1935
+ }
1936
+ if (this.#clearScrollbackOnNextRender || this.#forceViewportRepaintOnNextRender) {
1937
+ this.requestComponentRender(component);
1938
+ return;
1939
+ }
1940
+ if (this.overlayStack.length > 0 || this.#altActive || !this.#imageBudget.quiescent) {
1941
+ this.requestComponentRender(component);
1942
+ return;
1943
+ }
1944
+
1945
+ const children = this.children;
1946
+ const segments = this.#frameSegments;
1947
+ if (segments.length !== children.length) {
1948
+ this.requestComponentRender(component);
1949
+ return;
1950
+ }
1951
+ for (let i = 0; i < children.length; i++) {
1952
+ if (segments[i]!.component !== children[i]) {
1953
+ this.requestComponentRender(component);
1954
+ return;
1955
+ }
1956
+ }
1957
+
1958
+ const root = this.#resolveComponentRoot(component);
1959
+ if (root === null) {
1960
+ this.requestComponentRender(component);
1961
+ return;
1962
+ }
1963
+ const segmentIndex = segments.findIndex(segment => segment.component === root);
1964
+ if (segmentIndex === -1) {
1965
+ this.requestComponentRender(component);
1966
+ return;
1967
+ }
1968
+ const segment = segments[segmentIndex]!;
1969
+ if (segment.liveLocalStart !== undefined || segment.start < this.#committedRows) {
1970
+ this.requestComponentRender(component);
1971
+ return;
1972
+ }
1973
+
1974
+ const windowTop = Math.max(this.#committedRows, this.#composedFrame.length - height, 0);
1975
+ if (windowTop !== this.#windowTopRow) {
1976
+ this.requestComponentRender(component);
1977
+ return;
1978
+ }
1979
+ const screenStart = segment.start - windowTop;
1980
+ if (screenStart < 0 || screenStart + segment.rowCount > height) {
1981
+ this.requestComponentRender(component);
1982
+ return;
1983
+ }
1984
+
1985
+ const nextLines = root.render(width);
1986
+ if (nextLines.length !== segment.rowCount) {
1987
+ this.requestComponentRender(component);
1988
+ return;
1989
+ }
1990
+ for (const line of nextLines) {
1991
+ if (line.includes(CURSOR_MARKER)) {
1992
+ this.requestComponentRender(component);
1993
+ return;
1994
+ }
1995
+ }
1996
+
1997
+ let firstChanged = -1;
1998
+ let lastChanged = -1;
1999
+ const previousWindow = this.#previousWindow;
2000
+ for (let i = 0; i < nextLines.length; i++) {
2001
+ const frameRow = segment.start + i;
2002
+ const raw = nextLines[i]!;
2003
+ const prepared = this.#prepareLine(raw, width);
2004
+ this.#composedFrame[frameRow] = raw;
2005
+ this.#preparedMeta[frameRow] = prepared;
2006
+ this.#preparedFrame[frameRow] = prepared.line;
2007
+ if (previousWindow[screenStart + i] === prepared.line) continue;
2008
+ previousWindow[screenStart + i] = prepared.line;
2009
+ if (firstChanged === -1) firstChanged = i;
2010
+ lastChanged = i;
2011
+ }
2012
+ segments[segmentIndex] = { ...segment, lines: nextLines };
2013
+ this.#preparedValidRows = Math.max(this.#preparedValidRows, segment.start + nextLines.length);
2014
+ this.#renderStablePrefixRows = Math.min(this.#renderStablePrefixRows, segment.start);
2015
+
2016
+ let cursorPos: { row: number; col: number } | null = null;
2017
+ for (let i = this.#frameCursorMarkers.length - 1; i >= 0; i--) {
2018
+ const marker = this.#frameCursorMarkers[i]!;
2019
+ if (marker.row >= windowTop) {
2020
+ cursorPos = marker;
2021
+ break;
2022
+ }
2023
+ }
2024
+
2025
+ if (firstChanged === -1) {
2026
+ this.#writeCursorPosition(cursorPos, this.#composedFrame.length);
2027
+ this.#previousWidth = width;
2028
+ this.#previousHeight = height;
2029
+ return;
2030
+ }
2031
+
2032
+ const currentScreenRow = Math.max(0, Math.min(height - 1, this.#hardwareCursorRow - windowTop));
2033
+ const targetScreenRow = screenStart + firstChanged;
2034
+ const rowDelta = targetScreenRow - currentScreenRow;
2035
+ let buffer = this.#paintBeginSequence;
2036
+ if (rowDelta > 0) buffer += `\x1b[${rowDelta}B`;
2037
+ else if (rowDelta < 0) buffer += `\x1b[${-rowDelta}A`;
2038
+ buffer += "\r";
2039
+ for (let i = firstChanged; i <= lastChanged; i++) {
2040
+ if (i > firstChanged) buffer += "\r\n";
2041
+ buffer += this.#lineRewriteSequence(this.#preparedFrame[segment.start + i] ?? "", width);
2042
+ }
2043
+ const cursorControl = this.#cursorControlSequence(
2044
+ cursorPos,
2045
+ this.#composedFrame.length,
2046
+ segment.start + lastChanged,
2047
+ );
2048
+ buffer += cursorControl.seq;
2049
+ buffer += this.#paintEndSequence;
2050
+ this.terminal.write(buffer);
2051
+ this.#windowTopRow = windowTop;
2052
+ this.#commit(this.#composedFrame, previousWindow, width, height, cursorControl);
2053
+ }
1906
2054
 
1907
2055
  /** Ordinary (non-forced) scheduling shared by full and component-scoped requests. */
1908
2056
  #requestOrdinaryRender(): void {
@@ -2155,12 +2303,12 @@ export class TUI extends Container {
2155
2303
  }
2156
2304
 
2157
2305
  #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;
2306
+ // Ctrl+C/Esc use app-level double-press windows. Give those gestures one
2307
+ // frame to drain queued input before an ordinary repaint; delaying every
2308
+ // key would make idle navigation pay a full frame of latency.
2309
+ if (matchesKey(data, "ctrl+c") || matchesKey(data, "escape")) {
2310
+ this.#inputRenderGraceUntilMs = this.#renderScheduler.now() + TUI.#INPUT_RENDER_GRACE_MS;
2311
+ }
2164
2312
  if (this.#inputListeners.size > 0) {
2165
2313
  let current = data;
2166
2314
  for (const listener of this.#inputListeners) {
@@ -2612,18 +2760,19 @@ export class TUI extends Container {
2612
2760
  // alternate screen to repaint the whole transcript on the normal
2613
2761
  // screen — then the next SIGWINCH re-enters the alt screen and paints
2614
2762
  // 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
- ) {
2763
+ // A FORCED render mid-drag (tool finalization, resetDisplay, image
2764
+ // reconciliation) also stays on the fast path: preempting would leave
2765
+ // the borrowed alternate screen and run the geometry-rebuild full paint
2766
+ // on the normal screen ED3 plus an O(history) replay that visibly
2767
+ // scrolls the whole transcript through the viewport, once per forced
2768
+ // render and once more at settle. The forced intent is not lost: the
2769
+ // fast path consumes neither #forceViewportRepaintOnNextRender nor
2770
+ // #clearScrollbackOnNextRender, and the settle's authoritative
2771
+ // requestRender(true) honors both — same fold-into-the-settle contract
2772
+ // as the multiplexer resize debounce. A visible overlay composites over
2773
+ // the transcript and needs the whole window, so it falls through
2774
+ // (overlay resizes are not on the drag-cost hot path).
2775
+ if (this.#resizeViewportActive && this.#hasEverRendered && this.#getTopmostVisibleOverlay() === undefined) {
2627
2776
  this.#componentRenderTargets.clear();
2628
2777
  this.#renderResizeViewport(width, height);
2629
2778
  return;
package/src/utils.ts CHANGED
@@ -394,6 +394,7 @@ export function getWordNavKind(grapheme: string): WordNavKind {
394
394
  const ch = firstCodePointChar(grapheme);
395
395
  if (!ch) return "other";
396
396
  if (WORD_NAV_RE_WHITESPACE.test(ch)) return "whitespace";
397
+ if (ch === "_") return "word";
397
398
  if (WORD_NAV_RE_PUNCT.test(ch) || WORD_NAV_RE_SYMBOL.test(ch)) return "delimiter";
398
399
  if (
399
400
  WORD_NAV_RE_HAN.test(ch) ||
@@ -403,7 +404,7 @@ export function getWordNavKind(grapheme: string): WordNavKind {
403
404
  ) {
404
405
  return "cjk";
405
406
  }
406
- if (ch === "_" || WORD_NAV_RE_LETTER.test(ch) || WORD_NAV_RE_NUMBER.test(ch)) return "word";
407
+ if (WORD_NAV_RE_LETTER.test(ch) || WORD_NAV_RE_NUMBER.test(ch)) return "word";
407
408
  return "other";
408
409
  }
409
410