@oh-my-pi/pi-tui 16.1.23 → 16.2.0
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 +13 -0
- package/README.md +1 -1
- package/dist/types/components/select-list.d.ts +3 -1
- package/dist/types/mouse.d.ts +26 -0
- package/dist/types/terminal-capabilities.d.ts +7 -0
- package/package.json +3 -3
- package/src/components/markdown.ts +167 -46
- package/src/components/select-list.ts +6 -1
- package/src/mouse.ts +50 -0
- package/src/terminal-capabilities.ts +23 -10
- package/src/tui.ts +1 -1
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;
|
package/dist/types/mouse.d.ts
CHANGED
|
@@ -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.
|
|
4
|
+
"version": "16.2.0",
|
|
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.
|
|
41
|
-
"@oh-my-pi/pi-utils": "16.
|
|
40
|
+
"@oh-my-pi/pi-natives": "16.2.0",
|
|
41
|
+
"@oh-my-pi/pi-utils": "16.2.0",
|
|
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(
|
|
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
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
|
|
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, "
|
|
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
|
-
|
|
113
|
+
setImmediate(callback);
|
|
114
114
|
},
|
|
115
115
|
scheduleRender: (callback, delayMs) => {
|
|
116
116
|
const timer = setTimeout(callback, delayMs);
|