@oh-my-pi/pi-tui 16.1.23 → 16.2.1

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
+ ## [16.2.0] - 2026-06-27
6
+
7
+ ### Added
8
+
9
+ - Added support for rendering HTML <code>, <hr>, and <blockquote> tags with proper theme styling, entity decoding, and layout consistency across Markdown transcripts, table cells, list items, and option labels.
10
+ - Added first-class support for Warp terminal (TERM_PROGRAM=WarpTerminal), enabling true color, platform-specific Kitty graphics protocol negotiation for inline images, and safe defaults for OSC 8 hyperlinks and synchronized output.
11
+ - Added SelectList.routeMouse() and shared SGR mouse input routing helpers to support fullscreen overlay hit-testing.
12
+
13
+ ### Fixed
14
+
15
+ - Fixed issues where stray, unmatched, or raw HTML tags would leak into the rendered output.
16
+ - Fixed render scheduling to yield behind queued terminal input, preventing delayed Escape key delivery during heavy streaming paints.
17
+
5
18
  ## [16.1.20] - 2026-06-25
6
19
 
7
20
  ### Fixed
package/README.md CHANGED
@@ -412,7 +412,7 @@ const spacer = new Spacer(2); // 2 empty lines (default: 1)
412
412
 
413
413
  ### Image
414
414
 
415
- Renders images inline for terminals that support the Kitty graphics protocol (Kitty, Ghostty, WezTerm) or iTerm2 inline images. Falls back to a text placeholder on unsupported terminals.
415
+ Renders images inline for terminals that support the Kitty graphics protocol (Kitty, Ghostty, WezTerm, and Warp on macOS/Linux) or iTerm2 inline images. Falls back to a text placeholder on unsupported terminals.
416
416
 
417
417
  ```typescript
418
418
  interface ImageTheme {
@@ -1,3 +1,4 @@
1
+ import { type MouseRoutable, type SgrMouseEvent } from "../mouse";
1
2
  import type { SymbolTheme } from "../symbols";
2
3
  import type { Component } from "../tui";
3
4
  export interface SelectItem {
@@ -39,7 +40,7 @@ export interface SelectListLayoutOptions {
39
40
  */
40
41
  wrapDescription?: boolean;
41
42
  }
42
- export declare class SelectList implements Component {
43
+ export declare class SelectList implements Component, MouseRoutable {
43
44
  #private;
44
45
  private readonly items;
45
46
  private readonly maxVisible;
@@ -59,6 +60,7 @@ export declare class SelectList implements Component {
59
60
  handleWheel(delta: -1 | 1): void;
60
61
  /** Mouse click: select the item under the pointer and confirm it. */
61
62
  clickItem(index: number): void;
63
+ routeMouse(event: SgrMouseEvent, line: number, _col: number): void;
62
64
  invalidate(): void;
63
65
  render(width: number): readonly string[];
64
66
  handleInput(keyData: string): void;
@@ -30,6 +30,32 @@ export interface SgrMouseEvent {
30
30
  * before paying for the regex.
31
31
  */
32
32
  export declare function parseSgrMouse(data: string): SgrMouseEvent | null;
33
+ /** Handler invoked with a decoded SGR event; returning `false` reports unhandled. */
34
+ export type SgrMouseHandler = (event: SgrMouseEvent) => boolean | undefined;
35
+ /**
36
+ * Decode an SGR mouse report and forward it to `handler`. Returns `false` when
37
+ * `data` is not an SGR mouse report (or fails to parse), so callers can fall
38
+ * through to other input handling. Centralizes the repeated
39
+ * `data.startsWith("\x1b[<")` + `parseSgrMouse()` pattern.
40
+ */
41
+ export declare function routeSgrMouseInput(data: string, handler: SgrMouseHandler): boolean;
42
+ /**
43
+ * Structural view of a SelectList-like target for mouse routing. Declared here
44
+ * (rather than importing the component) to keep this core module free of any
45
+ * component-to-core import cycle.
46
+ */
47
+ export interface SelectListMouseTarget {
48
+ handleWheel(delta: -1 | 1): void;
49
+ hitTest(line: number): number | undefined;
50
+ setHoverIndex(index: number | null): void;
51
+ clickItem(index: number): void;
52
+ }
53
+ /**
54
+ * Route a decoded mouse event against a SelectList-like target at the given
55
+ * 0-based frame-local `line`. Centralizes the repeated wheel/hit-test/hover/
56
+ * click pattern. Returns `true` when the event was consumed.
57
+ */
58
+ export declare function routeSelectListMouse(target: SelectListMouseTarget, event: SgrMouseEvent, line: number): boolean;
33
59
  /**
34
60
  * Implemented by components that accept routed mouse events at frame-local
35
61
  * coordinates. Hosts translate screen coordinates to the component's own
@@ -136,6 +136,13 @@ export declare function hyperlinksUserOverride(env?: NodeJS.ProcessEnv): boolean
136
136
  * 7. Otherwise honor the static terminal capability.
137
137
  */
138
138
  export declare function shouldEnableHyperlinksByDefault(env?: NodeJS.ProcessEnv, terminalId?: TerminalId): boolean;
139
+ /**
140
+ * Warp implements the Kitty graphics protocol only on macOS/Linux; its Windows
141
+ * build (including Warp-hosted WSL shells) renders the same APC sequences as
142
+ * visible garbage. Keep platform/env injectable so the carve-out is testable
143
+ * without mutating `process.platform`.
144
+ */
145
+ export declare function resolveWarpImageProtocol(platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv): ImageProtocol | null;
139
146
  /** Resolve terminal identity from environment markers used by common emulators. */
140
147
  export declare function detectTerminalId(env?: NodeJS.ProcessEnv): TerminalId;
141
148
  export declare const TERMINAL_ID: TerminalId;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "16.1.23",
4
+ "version": "16.2.1",
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": "16.1.23",
41
- "@oh-my-pi/pi-utils": "16.1.23",
40
+ "@oh-my-pi/pi-natives": "16.2.1",
41
+ "@oh-my-pi/pi-utils": "16.2.1",
42
42
  "lru-cache": "11.5.1",
43
43
  "marked": "^18.0.5"
44
44
  },
@@ -86,7 +86,11 @@ function createHtmlNormalizationState(): HtmlNormalizationState {
86
86
  return { lists: [], openItems: [], itemHasContent: [] };
87
87
  }
88
88
 
89
- const HTML_TAG_REGEX = /<\/?(?:br|p|ol|ul|li|span|text)\b(?:\s[^>]*)?\s*\/?>/gi;
89
+ const HTML_TAG_REGEX = /<\/?(?:br|p|ol|ul|li|span|text|code|hr|blockquote)\b(?:\s[^>]*)?\s*\/?>/gi;
90
+ // Block-level HTML that needs structural (not just textual) rendering: standalone
91
+ // `<hr>` becomes a rule and balanced `<blockquote>…</blockquote>` renders with
92
+ // quote styling. Group 1 captures blockquote inner content; it is undefined for hr.
93
+ const BLOCK_HTML_REGEX = /<hr\b[^>]*\/?>|<blockquote\b[^>]*>([\s\S]*?)<\/blockquote>/gi;
90
94
 
91
95
  function htmlTagName(tag: string): string {
92
96
  const match = /^<\/?\s*([A-Za-z][A-Za-z0-9:-]*)/.exec(tag);
@@ -124,25 +128,32 @@ function isAtEmptyHtmlListItem(state: HtmlNormalizationState): boolean {
124
128
  return state.openItems[itemIndex] === true && state.itemHasContent[itemIndex] !== true;
125
129
  }
126
130
 
127
- function normalizeHtmlForTerminal(raw: string, state: HtmlNormalizationState = createHtmlNormalizationState()): string {
131
+ function normalizeHtmlForTerminal(
132
+ raw: string,
133
+ state: HtmlNormalizationState = createHtmlNormalizationState(),
134
+ codeHook?: (text: string) => string,
135
+ ): string {
128
136
  let output = "";
129
137
  let lastIndex = 0;
138
+ let inCode = false;
130
139
 
131
140
  for (const match of raw.matchAll(HTML_TAG_REGEX)) {
132
141
  const tag = match[0];
133
142
  const index = match.index ?? 0;
134
143
  const textBeforeTag = normalizeHtmlEntitiesForTerminal(raw.slice(lastIndex, index));
135
144
  const name = htmlTagName(tag);
136
- // Every tag handled here is block-level EXCEPT span and text. For block-level tags,
137
- // HTML formatting whitespace between block/list tags (e.g. the newlines and
138
- // indentation in pretty-printed `<ul>\n <li>…`) is not rendered content;
139
- // appending it literally would leak source indentation before bullets and
140
- // blank rows between items. A whitespace-only slice is always insignificant formatting
141
- // and is dropped. But for inline tags like span and text, surrounding whitespace
142
- // is significant and must NOT be dropped.
145
+ // Most tags handled here are block-level. Inline contexts — span, text, and
146
+ // the content inside a `<code>` run keep their surrounding whitespace
147
+ // verbatim because it is significant. For block-level tags, HTML formatting
148
+ // whitespace between tags (e.g. the newlines and indentation in
149
+ // pretty-printed `<ul>\n <li>…`) is not rendered content; appending it
150
+ // literally would leak source indentation before bullets and blank rows
151
+ // between items, so a whitespace-only slice is dropped. Text inside a
152
+ // `<code>` run is routed through `codeHook` so the inline-code theme is
153
+ // applied without leaking the raw `<code>`/`</code>` tags.
143
154
  const isInlineTag = name === "span" || name === "text";
144
- if (isInlineTag || textBeforeTag.trim() !== "") {
145
- output += textBeforeTag;
155
+ if (isInlineTag || inCode || textBeforeTag.trim() !== "") {
156
+ output += inCode && codeHook ? codeHook(textBeforeTag) : textBeforeTag;
146
157
  markCurrentHtmlItemContent(state, textBeforeTag);
147
158
  }
148
159
  lastIndex = index + tag.length;
@@ -154,10 +165,16 @@ function normalizeHtmlForTerminal(raw: string, state: HtmlNormalizationState = c
154
165
  case "span":
155
166
  case "text":
156
167
  break;
168
+ case "code":
169
+ if (isClosing) inCode = false;
170
+ else if (!isSelfClosing) inCode = true;
171
+ break;
157
172
  case "br":
173
+ case "hr":
158
174
  output = appendHtmlLineBreak(output, true);
159
175
  break;
160
176
  case "p":
177
+ case "blockquote":
161
178
  if (isClosing) {
162
179
  output = appendHtmlLineBreak(output);
163
180
  } else if (output.trim() !== "" && !output.endsWith("\n") && !isAtEmptyHtmlListItem(state)) {
@@ -223,7 +240,7 @@ function normalizeHtmlForTerminal(raw: string, state: HtmlNormalizationState = c
223
240
 
224
241
  const remainingText = normalizeHtmlEntitiesForTerminal(raw.slice(lastIndex));
225
242
  markCurrentHtmlItemContent(state, remainingText);
226
- return output + remainingText;
243
+ return output + (inCode && codeHook ? codeHook(remainingText) : remainingText);
227
244
  }
228
245
 
229
246
  function splitTerminalLines(text: string): string[] {
@@ -623,6 +640,59 @@ function plainInlineTokens(tokens: Token[]): string {
623
640
  return result;
624
641
  }
625
642
 
643
+ /**
644
+ * Classify an inline `html` token by tag name and whether it is a closing tag.
645
+ * Returns null for non-html tokens or raw that isn't a recognizable HTML tag.
646
+ */
647
+ function inlineHtmlTag(token: Token): { name: string; closing: boolean } | null {
648
+ if ((token as { type: string }).type !== "html") return null;
649
+ const raw = (token as { raw?: unknown }).raw;
650
+ if (typeof raw !== "string") return null;
651
+ const name = htmlTagName(raw);
652
+ if (!name) return null;
653
+ return { name, closing: /^<\s*\//.test(raw) };
654
+ }
655
+
656
+ /**
657
+ * Collapse inline `<code>…</code>` runs — which marked emits as separate `html`
658
+ * open/close tokens around the literal content — into a single synthetic
659
+ * `codespan` token, so they render with the theme's inline-code styling instead
660
+ * of leaking the raw tags. HTML entities inside the run are decoded. Stray or
661
+ * unmatched code tags are dropped; other inline html tokens pass through for the
662
+ * `html` render path to normalize. Returns the original array when no `<code>`
663
+ * tag is present (the common case).
664
+ */
665
+ function collapseInlineHtml(tokens: Token[]): Token[] {
666
+ let hasCode = false;
667
+ for (const token of tokens) {
668
+ if (inlineHtmlTag(token)?.name === "code") {
669
+ hasCode = true;
670
+ break;
671
+ }
672
+ }
673
+ if (!hasCode) return tokens;
674
+
675
+ const out: Token[] = [];
676
+ for (let i = 0; i < tokens.length; i++) {
677
+ const tag = inlineHtmlTag(tokens[i]);
678
+ if (tag?.name === "code") {
679
+ if (tag.closing) continue; // stray `</code>` — drop it
680
+ let j = i + 1;
681
+ for (; j < tokens.length; j++) {
682
+ const close = inlineHtmlTag(tokens[j]);
683
+ if (close?.name === "code" && close.closing) break;
684
+ }
685
+ if (j >= tokens.length) continue; // unmatched `<code>` — drop it, render the rest normally
686
+ const text = normalizeHtmlEntitiesForTerminal(plainInlineTokens(tokens.slice(i + 1, j)));
687
+ out.push({ type: "codespan", raw: text, text } as Token);
688
+ i = j;
689
+ continue;
690
+ }
691
+ out.push(tokens[i]);
692
+ }
693
+ return out;
694
+ }
695
+
626
696
  // ---------------------------------------------------------------------------
627
697
  // Inline hex-color swatches
628
698
  // ---------------------------------------------------------------------------
@@ -1194,19 +1264,6 @@ export class Markdown implements Component {
1194
1264
  }
1195
1265
 
1196
1266
  case "blockquote": {
1197
- const quoteStyle = (text: string) => this.#theme.quote(this.#theme.italic(text));
1198
- const quoteStylePrefix = this.#getStylePrefix(quoteStyle);
1199
- const applyQuoteStyle = (line: string): string => {
1200
- if (!quoteStylePrefix) {
1201
- return quoteStyle(line);
1202
- }
1203
-
1204
- const lineWithReappliedStyle = line.replace(/\x1b\[0m/g, `\x1b[0m${quoteStylePrefix}`);
1205
- return quoteStyle(lineWithReappliedStyle);
1206
- };
1207
-
1208
- // Blockquotes contain block-level tokens (paragraph, list, code, etc.), so render
1209
- // children recursively and keep default message styling out of nested content.
1210
1267
  const quoteInlineStyleContext: InlineStyleContext = {
1211
1268
  applyText: (text: string) => text,
1212
1269
  stylePrefix: "",
@@ -1227,13 +1284,7 @@ export class Markdown implements Component {
1227
1284
  renderedQuoteLines.pop();
1228
1285
  }
1229
1286
 
1230
- for (const quoteLine of renderedQuoteLines) {
1231
- const styledLine = applyQuoteStyle(quoteLine);
1232
- const wrappedLines = wrapTextWithAnsi(styledLine, quoteContentWidth);
1233
- for (const wrappedLine of wrappedLines) {
1234
- lines.push(this.#theme.quoteBorder(`${this.#theme.symbols.quoteBorder} `) + wrappedLine);
1235
- }
1236
- }
1287
+ lines.push(...this.#applyQuoteBorder(renderedQuoteLines, width));
1237
1288
  if (nextTokenType && nextTokenType !== "space") {
1238
1289
  lines.push(""); // Add spacing after blockquotes (unless space token follows)
1239
1290
  }
@@ -1242,9 +1293,7 @@ export class Markdown implements Component {
1242
1293
 
1243
1294
  case "hr": {
1244
1295
  const raw = "raw" in token && typeof token.raw === "string" ? token.raw.trim() : "";
1245
- const char = raw[0] || "";
1246
- const fillChar = getHrChar(char, this.#theme.symbols.hrChar);
1247
- lines.push(this.#theme.hr(fillChar.repeat(Math.min(width, 80))));
1296
+ lines.push(this.#renderHrLine(width, raw[0] || ""));
1248
1297
  if (nextTokenType && nextTokenType !== "space") {
1249
1298
  lines.push(""); // Add spacing after horizontal rules (unless space token follows)
1250
1299
  }
@@ -1253,12 +1302,7 @@ export class Markdown implements Component {
1253
1302
 
1254
1303
  case "html":
1255
1304
  if ("raw" in token && typeof token.raw === "string") {
1256
- const cleaned = normalizeHtmlForTerminal(token.raw);
1257
- const blockLines = splitTerminalLines(cleaned);
1258
- for (const line of blockLines) {
1259
- const trimmed = line.trimEnd();
1260
- lines.push(trimmed.trim() === "" ? "" : this.#applyDefaultStyle(trimmed));
1261
- }
1305
+ lines.push(...this.#renderHtmlBlock(token.raw, width));
1262
1306
  }
1263
1307
  break;
1264
1308
 
@@ -1277,6 +1321,78 @@ export class Markdown implements Component {
1277
1321
  return lines;
1278
1322
  }
1279
1323
 
1324
+ /** Render a horizontal rule line themed to `width`, matching `sourceChar` when given. */
1325
+ #renderHrLine(width: number, sourceChar = ""): string {
1326
+ const fillChar = getHrChar(sourceChar, this.#theme.symbols.hrChar);
1327
+ return this.#theme.hr(fillChar.repeat(Math.min(width, 80)));
1328
+ }
1329
+
1330
+ /**
1331
+ * Wrap already-rendered lines in the blockquote border and quote styling.
1332
+ * `width` is the full content width; the border reserves two cells.
1333
+ */
1334
+ #applyQuoteBorder(renderedLines: string[], width: number): string[] {
1335
+ const quoteStyle = (text: string) => this.#theme.quote(this.#theme.italic(text));
1336
+ const quoteStylePrefix = this.#getStylePrefix(quoteStyle);
1337
+ const applyQuoteStyle = (line: string): string => {
1338
+ if (!quoteStylePrefix) {
1339
+ return quoteStyle(line);
1340
+ }
1341
+ const lineWithReappliedStyle = line.replace(/\x1b\[0m/g, `\x1b[0m${quoteStylePrefix}`);
1342
+ return quoteStyle(lineWithReappliedStyle);
1343
+ };
1344
+ const quoteContentWidth = Math.max(1, width - 2);
1345
+ const lines: string[] = [];
1346
+ for (const quoteLine of renderedLines) {
1347
+ const styledLine = applyQuoteStyle(quoteLine);
1348
+ for (const wrappedLine of wrapTextWithAnsi(styledLine, quoteContentWidth)) {
1349
+ lines.push(this.#theme.quoteBorder(`${this.#theme.symbols.quoteBorder} `) + wrappedLine);
1350
+ }
1351
+ }
1352
+ return lines;
1353
+ }
1354
+
1355
+ /**
1356
+ * Render a block-level `html` token to styled lines. Standalone `<hr>` tags
1357
+ * become rules and balanced `<blockquote>…</blockquote>` regions render with
1358
+ * quote styling; the remaining markup is normalized to terminal text (entities
1359
+ * decoded, `<code>` themed, lists/`<br>`/`<p>` laid out).
1360
+ */
1361
+ #renderHtmlBlock(raw: string, width: number): string[] {
1362
+ const lines: string[] = [];
1363
+ const state = createHtmlNormalizationState();
1364
+ const codeHook = (text: string): string => this.#theme.code(text) + this.#getDefaultStylePrefix();
1365
+ const flushText = (chunk: string): void => {
1366
+ const cleaned = normalizeHtmlForTerminal(chunk, state, codeHook);
1367
+ if (cleaned.trim() === "") return;
1368
+ for (const line of splitTerminalLines(cleaned)) {
1369
+ const trimmed = line.trimEnd();
1370
+ lines.push(trimmed.trim() === "" ? "" : this.#applyDefaultStyle(trimmed));
1371
+ }
1372
+ };
1373
+ let lastIndex = 0;
1374
+ BLOCK_HTML_REGEX.lastIndex = 0;
1375
+ for (let match = BLOCK_HTML_REGEX.exec(raw); match !== null; match = BLOCK_HTML_REGEX.exec(raw)) {
1376
+ flushText(raw.slice(lastIndex, match.index));
1377
+ lastIndex = match.index + match[0].length;
1378
+ if (match[1] !== undefined) {
1379
+ lines.push(...this.#renderHtmlBlockquote(match[1], width));
1380
+ } else {
1381
+ lines.push(this.#renderHrLine(width));
1382
+ }
1383
+ }
1384
+ flushText(raw.slice(lastIndex));
1385
+ return lines;
1386
+ }
1387
+
1388
+ /** Render the inner content of an HTML `<blockquote>` with quote styling. */
1389
+ #renderHtmlBlockquote(inner: string, width: number): string[] {
1390
+ const cleaned = normalizeHtmlForTerminal(inner, createHtmlNormalizationState(), text => this.#theme.code(text));
1391
+ const innerLines = splitTerminalLines(cleaned).map(line => line.trimEnd());
1392
+ while (innerLines.length > 0 && innerLines[innerLines.length - 1] === "") innerLines.pop();
1393
+ return this.#applyQuoteBorder(innerLines, width);
1394
+ }
1395
+
1280
1396
  #renderInlineTokens(tokens: Token[], styleContext?: InlineStyleContext): string {
1281
1397
  let result = "";
1282
1398
  const resolvedStyleContext = styleContext ?? this.#getDefaultInlineStyleContext();
@@ -1292,7 +1408,7 @@ export class Markdown implements Component {
1292
1408
  markCurrentHtmlItemContent(htmlState, text);
1293
1409
  };
1294
1410
 
1295
- for (const token of tokens) {
1411
+ for (const token of collapseInlineHtml(tokens)) {
1296
1412
  if (isMathToken(token)) {
1297
1413
  markHtmlItemWhenContent(token.text);
1298
1414
  result += applyTextWithNewlines(renderMathToken(token.text));
@@ -1773,7 +1889,7 @@ export function renderInlineMarkdown(text: string, mdTheme: MarkdownTheme, baseC
1773
1889
  })
1774
1890
  .join(applyText(" "));
1775
1891
  } else if ("text" in token && typeof token.text === "string") {
1776
- result += applyText(token.text);
1892
+ result += applyText(normalizeHtmlEntitiesForTerminal(token.text));
1777
1893
  }
1778
1894
  }
1779
1895
  return result;
@@ -1782,7 +1898,7 @@ export function renderInlineMarkdown(text: string, mdTheme: MarkdownTheme, baseC
1782
1898
  function renderInlineTokens(tokens: Token[], mdTheme: MarkdownTheme, applyText: (t: string) => string): string {
1783
1899
  let result = "";
1784
1900
  const styleReset = applyText("");
1785
- for (const token of tokens) {
1901
+ for (const token of collapseInlineHtml(tokens)) {
1786
1902
  if (isMathToken(token)) {
1787
1903
  result += applyText(renderMathToken(token.text));
1788
1904
  continue;
@@ -1792,7 +1908,7 @@ function renderInlineTokens(tokens: Token[], mdTheme: MarkdownTheme, applyText:
1792
1908
  if (token.tokens && token.tokens.length > 0) {
1793
1909
  result += renderInlineTokens(token.tokens, mdTheme, applyText);
1794
1910
  } else {
1795
- result += applyText(token.text);
1911
+ result += applyText(normalizeHtmlEntitiesForTerminal(token.text));
1796
1912
  }
1797
1913
  break;
1798
1914
  case "strong":
@@ -1812,9 +1928,14 @@ function renderInlineTokens(tokens: Token[], mdTheme: MarkdownTheme, applyText:
1812
1928
  result += mdTheme.link(mdTheme.underline(linkText)) + styleReset;
1813
1929
  break;
1814
1930
  }
1931
+ case "html":
1932
+ if ("raw" in token && typeof token.raw === "string") {
1933
+ result += applyText(normalizeHtmlForTerminal(token.raw));
1934
+ }
1935
+ break;
1815
1936
  default:
1816
1937
  if ("text" in token && typeof token.text === "string") {
1817
- result += applyText(token.text);
1938
+ result += applyText(normalizeHtmlEntitiesForTerminal(token.text));
1818
1939
  }
1819
1940
  break;
1820
1941
  }
@@ -2,6 +2,7 @@ import { popLoopPhase, pushLoopPhase } from "@oh-my-pi/pi-utils";
2
2
  import { fuzzyFilter } from "../fuzzy";
3
3
  import { getKeybindings } from "../keybindings";
4
4
  import { extractPrintableText } from "../keys";
5
+ import { type MouseRoutable, routeSelectListMouse, type SgrMouseEvent } from "../mouse";
5
6
  import type { SymbolTheme } from "../symbols";
6
7
  import type { Component } from "../tui";
7
8
  import { Ellipsis, padding, replaceTabs, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils";
@@ -80,7 +81,7 @@ type SelectItemLayout =
80
81
  spacing: "";
81
82
  };
82
83
 
83
- export class SelectList implements Component {
84
+ export class SelectList implements Component, MouseRoutable {
84
85
  #filteredItems: ReadonlyArray<SelectItem>;
85
86
  #filterQuery = "";
86
87
  #selectedIndex: number = 0;
@@ -139,6 +140,10 @@ export class SelectList implements Component {
139
140
  this.onSelect?.(item);
140
141
  }
141
142
 
143
+ routeMouse(event: SgrMouseEvent, line: number, _col: number): void {
144
+ routeSelectListMouse(this, event, line);
145
+ }
146
+
142
147
  invalidate(): void {
143
148
  // No cached state to invalidate currently
144
149
  }
package/src/mouse.ts CHANGED
@@ -44,6 +44,56 @@ export function parseSgrMouse(data: string): SgrMouseEvent | null {
44
44
  return { button, col, row, release, wheel, motion, leftClick };
45
45
  }
46
46
 
47
+ /** Handler invoked with a decoded SGR event; returning `false` reports unhandled. */
48
+ export type SgrMouseHandler = (event: SgrMouseEvent) => boolean | undefined;
49
+
50
+ /**
51
+ * Decode an SGR mouse report and forward it to `handler`. Returns `false` when
52
+ * `data` is not an SGR mouse report (or fails to parse), so callers can fall
53
+ * through to other input handling. Centralizes the repeated
54
+ * `data.startsWith("\x1b[<")` + `parseSgrMouse()` pattern.
55
+ */
56
+ export function routeSgrMouseInput(data: string, handler: SgrMouseHandler): boolean {
57
+ if (!data.startsWith("\x1b[<")) return false;
58
+ const event = parseSgrMouse(data);
59
+ if (!event) return false;
60
+ return handler(event) !== false;
61
+ }
62
+
63
+ /**
64
+ * Structural view of a SelectList-like target for mouse routing. Declared here
65
+ * (rather than importing the component) to keep this core module free of any
66
+ * component-to-core import cycle.
67
+ */
68
+ export interface SelectListMouseTarget {
69
+ handleWheel(delta: -1 | 1): void;
70
+ hitTest(line: number): number | undefined;
71
+ setHoverIndex(index: number | null): void;
72
+ clickItem(index: number): void;
73
+ }
74
+
75
+ /**
76
+ * Route a decoded mouse event against a SelectList-like target at the given
77
+ * 0-based frame-local `line`. Centralizes the repeated wheel/hit-test/hover/
78
+ * click pattern. Returns `true` when the event was consumed.
79
+ */
80
+ export function routeSelectListMouse(target: SelectListMouseTarget, event: SgrMouseEvent, line: number): boolean {
81
+ if (event.wheel !== null) {
82
+ target.handleWheel(event.wheel);
83
+ return true;
84
+ }
85
+ const index = target.hitTest(line);
86
+ if (event.motion) {
87
+ target.setHoverIndex(index ?? null);
88
+ return true;
89
+ }
90
+ if (event.leftClick && index !== undefined) {
91
+ target.clickItem(index);
92
+ return true;
93
+ }
94
+ return false;
95
+ }
96
+
47
97
  /**
48
98
  * Implemented by components that accept routed mouse events at frame-local
49
99
  * coordinates. Hosts translate screen coordinates to the component's own
@@ -383,18 +383,24 @@ function getFallbackImageProtocol(terminalId: TerminalId): ImageProtocol | null
383
383
  }
384
384
  return null;
385
385
  }
386
- function getWarpTerminalInfo(platform: NodeJS.Platform, env: NodeJS.ProcessEnv = Bun.env): TerminalInfo {
387
- // Warp for Windows still drives WSL shells from the Windows renderer, where
388
- // the Kitty APC sequences print as visible garbage. Detect that case via the
389
- // WSL host markers (Bun reports `process.platform === "linux"` inside WSL)
390
- // and treat it the same as native win32.
386
+ /**
387
+ * Warp implements the Kitty graphics protocol only on macOS/Linux; its Windows
388
+ * build (including Warp-hosted WSL shells) renders the same APC sequences as
389
+ * visible garbage. Keep platform/env injectable so the carve-out is testable
390
+ * without mutating `process.platform`.
391
+ */
392
+ export function resolveWarpImageProtocol(
393
+ platform: NodeJS.Platform = process.platform,
394
+ env: NodeJS.ProcessEnv = Bun.env,
395
+ ): ImageProtocol | null {
391
396
  const windowsHost =
392
397
  platform === "win32" || (platform === "linux" && Boolean(env.WSL_DISTRO_NAME || env.WSL_INTEROP));
393
- return windowsHost
394
- ? new TerminalInfo("warp", null, true, false, NotifyProtocol.Bell)
395
- : new TerminalInfo("warp", ImageProtocol.Kitty, true, false, NotifyProtocol.Bell);
398
+ return windowsHost ? null : ImageProtocol.Kitty;
396
399
  }
397
400
 
401
+ function getWarpTerminalInfo(platform: NodeJS.Platform, env: NodeJS.ProcessEnv = Bun.env): TerminalInfo {
402
+ return new TerminalInfo("warp", resolveWarpImageProtocol(platform, env), true, false, NotifyProtocol.Bell);
403
+ }
398
404
  const KNOWN_TERMINALS = Object.freeze({
399
405
  // Fallback terminals
400
406
  base: new TerminalInfo("base", null, false, false, NotifyProtocol.Bell),
@@ -406,7 +412,11 @@ const KNOWN_TERMINALS = Object.freeze({
406
412
  iterm2: new TerminalInfo("iterm2", ImageProtocol.Iterm2, true, true, NotifyProtocol.Osc9),
407
413
  vscode: new TerminalInfo("vscode", null, true, true, NotifyProtocol.Bell),
408
414
  alacritty: new TerminalInfo("alacritty", null, true, true, NotifyProtocol.Bell),
409
- warp: getWarpTerminalInfo(process.platform),
415
+ // Warp identifies via TERM_PROGRAM=WarpTerminal and ships the Kitty graphics
416
+ // protocol on macOS/Linux (direct placement only — no Unicode placeholders, so
417
+ // detectKittyUnicodePlaceholdersSupport correctly excludes it). It does not
418
+ // honor OSC 8 yet (the escape renders as visible text), so hyperlinks stay off.
419
+ warp: new TerminalInfo("warp", ImageProtocol.Kitty, true, false, NotifyProtocol.Bell),
410
420
  });
411
421
 
412
422
  /** Resolve terminal identity from environment markers used by common emulators. */
@@ -441,7 +451,7 @@ export function detectTerminalId(env: NodeJS.ProcessEnv = Bun.env): TerminalId {
441
451
  if (caseEq(TERM_PROGRAM, "iterm.app")) return "iterm2";
442
452
  if (caseEq(TERM_PROGRAM, "vscode")) return "vscode";
443
453
  if (caseEq(TERM_PROGRAM, "alacritty")) return "alacritty";
444
- if (caseEq(TERM_PROGRAM, "WarpTerminal")) return "warp";
454
+ if (caseEq(TERM_PROGRAM, "warpterminal")) return "warp";
445
455
  }
446
456
 
447
457
  if (TERM?.toLowerCase().includes("ghostty")) return "ghostty";
@@ -474,6 +484,9 @@ export const TERMINAL: RuntimeTerminal = (() => {
474
484
  const forcedImageProtocol = getForcedImageProtocol();
475
485
  if (forcedImageProtocol !== undefined) {
476
486
  resolved.imageProtocol = forcedImageProtocol;
487
+ } else if (resolved.id === "warp") {
488
+ // Warp advertises Kitty graphics on macOS/Linux only; drop it on win32.
489
+ resolved.imageProtocol = resolveWarpImageProtocol();
477
490
  } else if (!resolved.imageProtocol) {
478
491
  const fallbackImageProtocol = getFallbackImageProtocol(resolved.id);
479
492
  if (fallbackImageProtocol) resolved.imageProtocol = fallbackImageProtocol;
package/src/tui.ts CHANGED
@@ -110,7 +110,7 @@ export interface TUIStartOptions {
110
110
  const DEFAULT_RENDER_SCHEDULER: RenderScheduler = {
111
111
  now: () => performance.now(),
112
112
  scheduleImmediate: callback => {
113
- process.nextTick(callback);
113
+ setImmediate(callback);
114
114
  },
115
115
  scheduleRender: (callback, delayMs) => {
116
116
  const timer = setTimeout(callback, delayMs);