@oh-my-pi/pi-tui 18.0.6 → 18.0.8
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 +24 -0
- package/dist/types/components/markdown.d.ts +0 -2
- package/dist/types/latex-to-unicode.d.ts +0 -9
- package/dist/types/terminal-capabilities.d.ts +17 -0
- package/dist/types/terminal.d.ts +11 -0
- package/dist/types/tui.d.ts +7 -0
- package/package.json +3 -3
- package/src/autocomplete.ts +50 -8
- package/src/components/loader.ts +11 -5
- package/src/components/markdown.ts +16 -60
- package/src/latex-to-unicode.ts +15 -58
- package/src/terminal-capabilities.ts +54 -9
- package/src/terminal.ts +22 -2
- package/src/tui.ts +38 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.0.8] - 2026-08-27
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `ProcessTerminal` accepts a `conpty` option to force ConPTY-hosted behavior on or off, keeping terminal tests hermetic on WSL where live env detection would otherwise flip kitty-keyboard flags and write chunking ([#9887](https://github.com/can1357/oh-my-pi/issues/9887)).
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Fixed pending-work animations repeatedly composing expensive frames without applying their full render cost to CPU backpressure.
|
|
14
|
+
- Fixed unfinished live viewport rows entering tmux pane history and duplicating streamed output ([#9780](https://github.com/can1357/oh-my-pi/issues/9780)).
|
|
15
|
+
|
|
16
|
+
## [18.0.7] - 2026-08-26
|
|
17
|
+
|
|
18
|
+
### Breaking Changes
|
|
19
|
+
|
|
20
|
+
- Removed the `inlineMathSpanEnd` and `mathStartIndex` exports; the math delimiter grammar now lives in `@oh-my-pi/pi-utils/math-delimiters`.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- Math spans now end at the first unescaped delimiter, so a TeX row break no longer closes a span early: `\(a \\) b\)` renders as one equation, and an escaped `\$` no longer ends `$$…$$`.
|
|
25
|
+
- Fixed image previews displaying as garbled characters in Paseo terminals.
|
|
26
|
+
- Fixed terminal resizing from duplicating committed history in native scrollback.
|
|
27
|
+
- Fixed autocomplete suggestions for bare-name skills such as `/batch` when no command matches the prefix more strongly.
|
|
28
|
+
|
|
5
29
|
## [18.0.6] - 2026-08-26
|
|
6
30
|
|
|
7
31
|
### Added
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import type { SymbolTheme } from "../symbols.js";
|
|
2
2
|
import type { Component } from "../tui.js";
|
|
3
3
|
/** @internal exported for tests — must stay index-identical to the old regex scan. */
|
|
4
|
-
export declare function mathStartIndex(src: string): number | undefined;
|
|
5
|
-
/** @internal exported for tests — must stay index-identical to the old regex scan. */
|
|
6
4
|
export declare function autolinkSchemeScanIndex(src: string): number | undefined;
|
|
7
5
|
/** @internal exported for tests — must never return false for a src the built-in url regex matches. */
|
|
8
6
|
export declare function urlTokenPossible(src: string): boolean;
|
|
@@ -39,12 +39,3 @@ export declare function isBareMathEnvironment(env: string): boolean;
|
|
|
39
39
|
* $10" is left untouched.
|
|
40
40
|
*/
|
|
41
41
|
export declare function renderMathInText(text: string): string;
|
|
42
|
-
/**
|
|
43
|
-
* Index of the `$` that closes an inline math span opened at `open` (the index
|
|
44
|
-
* of the opening `$`), or -1 when the run is not inline math. Applies pandoc's
|
|
45
|
-
* anti-currency heuristics: the opener must not be followed by whitespace, the
|
|
46
|
-
* closer must not be preceded by whitespace nor followed by a digit, `\$` is a
|
|
47
|
-
* literal dollar, and the span may not span a newline. Shared by
|
|
48
|
-
* `renderMathInText` and the markdown math tokenizer so the rule has one home.
|
|
49
|
-
*/
|
|
50
|
-
export declare function inlineMathSpanEnd(text: string, open: number): number;
|
|
@@ -153,6 +153,23 @@ export declare function shouldEnableHyperlinksByDefault(env?: NodeJS.ProcessEnv,
|
|
|
153
153
|
* without mutating `process.platform`.
|
|
154
154
|
*/
|
|
155
155
|
export declare function resolveWarpImageProtocol(platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv): ImageProtocol | null;
|
|
156
|
+
/**
|
|
157
|
+
* Paseo (getpaseo/paseo) hardcodes `TERM_PROGRAM=kitty` into every PTY
|
|
158
|
+
* (`buildTerminalEnvironment`) while its xterm.js renderer implements neither
|
|
159
|
+
* Kitty graphics nor Unicode placeholders — trusting the advertisement turns
|
|
160
|
+
* image previews into literal PUA garbage (getpaseo/paseo#3850). Paseo
|
|
161
|
+
* injects `PASEO_TERMINAL_ID` into every terminal it hosts, which makes a
|
|
162
|
+
* reliable embedder signal.
|
|
163
|
+
*/
|
|
164
|
+
export declare function isPaseoEmbedder(env?: NodeJS.ProcessEnv): boolean;
|
|
165
|
+
/**
|
|
166
|
+
* Resolve the image protocol for a non-forced runtime: static per-terminal
|
|
167
|
+
* support (with Warp's platform carve-out), then the multiplexer fallback,
|
|
168
|
+
* then the Paseo embedder carve-out. `isTTY` is injectable because the
|
|
169
|
+
* fallback only fires on a real TTY — a piped subprocess cannot exercise
|
|
170
|
+
* that path, so regression tests call this directly.
|
|
171
|
+
*/
|
|
172
|
+
export declare function resolveImageProtocol(terminalId: TerminalId, env?: NodeJS.ProcessEnv, isTTY?: boolean): ImageProtocol | null;
|
|
156
173
|
/** Resolve terminal identity from environment markers used by common emulators. */
|
|
157
174
|
export declare function detectTerminalId(env?: NodeJS.ProcessEnv): TerminalId;
|
|
158
175
|
export declare const TERMINAL_ID: TerminalId;
|
package/dist/types/terminal.d.ts
CHANGED
|
@@ -172,11 +172,22 @@ export interface Terminal {
|
|
|
172
172
|
* single predicate.
|
|
173
173
|
*/
|
|
174
174
|
export declare function isConPTYHosted(): boolean;
|
|
175
|
+
/** Construction-time overrides for {@link ProcessTerminal}. */
|
|
176
|
+
export interface ProcessTerminalOptions {
|
|
177
|
+
/**
|
|
178
|
+
* Force ConPTY-hosted behavior on or off. Defaults to live detection via
|
|
179
|
+
* {@link isConPTYHosted}. Tests set this so the kitty-flag and write-chunking
|
|
180
|
+
* paths stay hermetic regardless of the ambient WSL env (`WSL_DISTRO_NAME` /
|
|
181
|
+
* `WSL_INTEROP`) — the suite must behave identically on WSL and on CI.
|
|
182
|
+
*/
|
|
183
|
+
conpty?: boolean;
|
|
184
|
+
}
|
|
175
185
|
/**
|
|
176
186
|
* Real terminal using process.stdin/stdout
|
|
177
187
|
*/
|
|
178
188
|
export declare class ProcessTerminal implements Terminal {
|
|
179
189
|
#private;
|
|
190
|
+
constructor(options?: ProcessTerminalOptions);
|
|
180
191
|
get kittyProtocolActive(): boolean;
|
|
181
192
|
get kittyEnableSequence(): string | null;
|
|
182
193
|
get keyboardEnhancementEnterSequence(): string | null;
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -292,6 +292,13 @@ export declare class TUI extends Container {
|
|
|
292
292
|
* positive report, disabled on a negative one.
|
|
293
293
|
*/
|
|
294
294
|
get synchronizedOutput(): boolean;
|
|
295
|
+
/**
|
|
296
|
+
* Cost in milliseconds of the most recently completed frame.
|
|
297
|
+
*
|
|
298
|
+
* Animation components use this to apply proportional backpressure after
|
|
299
|
+
* their render request is asynchronously composed and written.
|
|
300
|
+
*/
|
|
301
|
+
get lastFrameCostMs(): number;
|
|
295
302
|
setFocus(component: Component | null): void;
|
|
296
303
|
/** Component currently receiving keyboard input, if any. */
|
|
297
304
|
getFocused(): Component | null;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-tui",
|
|
4
|
-
"version": "18.0.
|
|
4
|
+
"version": "18.0.8",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Stencil Labs, Inc.",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"fmt": "biome format --write ."
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@oh-my-pi/pi-natives": "18.0.
|
|
41
|
-
"@oh-my-pi/pi-utils": "18.0.
|
|
40
|
+
"@oh-my-pi/pi-natives": "18.0.8",
|
|
41
|
+
"@oh-my-pi/pi-utils": "18.0.8"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"kitty-vt-wasm": "^0.2.0"
|
package/src/autocomplete.ts
CHANGED
|
@@ -331,10 +331,19 @@ function buildSlashCommandCompletions(
|
|
|
331
331
|
let best: (AutocompleteItem & { score: number; usage: number }) | undefined;
|
|
332
332
|
|
|
333
333
|
const isSkillCommand = name.startsWith(SKILL_NAMESPACE);
|
|
334
|
+
// Skills are matched by their bare name as well as the full
|
|
335
|
+
// `skill:` name so a broken-out or mid-prompt skill ranks at
|
|
336
|
+
// prefix strength (`/batch` → `skill:batch`) instead of a weak
|
|
337
|
+
// full-name fuzzy hit.
|
|
334
338
|
const nameScore =
|
|
335
339
|
lowerPrefix.length === 0 && isSkillCommand
|
|
336
340
|
? 950
|
|
337
|
-
:
|
|
341
|
+
: isSkillCommand
|
|
342
|
+
? Math.max(
|
|
343
|
+
scoreCommandTextMatch(lowerPrefix, name.toLowerCase()),
|
|
344
|
+
scoreCommandTextMatch(lowerPrefix, name.slice(SKILL_NAMESPACE.length).toLowerCase()),
|
|
345
|
+
)
|
|
346
|
+
: scoreCommandTextMatch(lowerPrefix, name.toLowerCase());
|
|
338
347
|
const lowerDesc = staticDesc.toLowerCase();
|
|
339
348
|
const descScore =
|
|
340
349
|
lowerDesc && fuzzyMatch(lowerPrefix, lowerDesc) ? fuzzyScore(lowerPrefix, lowerDesc) * 0.5 : 0;
|
|
@@ -391,23 +400,56 @@ function hasPromptTextBeforeSlash(
|
|
|
391
400
|
|
|
392
401
|
export const SKILL_NAMESPACE = "skill:";
|
|
393
402
|
|
|
403
|
+
/**
|
|
404
|
+
* Match tier used to compare a skill's bare name against non-skill command
|
|
405
|
+
* names when deciding whether the skill may break out of the collapsed
|
|
406
|
+
* `skill:` group: exact (1000) > prefix (900) > anything weaker (0). Fuzzy
|
|
407
|
+
* hits deliberately map to 0 — a fuzzy skill match is never strong enough to
|
|
408
|
+
* mix skills into the command popup.
|
|
409
|
+
*/
|
|
410
|
+
function skillBreakoutTier(lowerPrefix: string, lowerTarget: string): number {
|
|
411
|
+
if (lowerPrefix === lowerTarget) return 1000;
|
|
412
|
+
if (lowerTarget.startsWith(lowerPrefix)) return 900;
|
|
413
|
+
return 0;
|
|
414
|
+
}
|
|
415
|
+
|
|
394
416
|
/**
|
|
395
417
|
* Collapse `skill:*` commands into a single `/skill:` namespace row while the
|
|
396
|
-
* typed prefix has not committed to the namespace.
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
*
|
|
418
|
+
* typed prefix has not committed to the namespace. A lone group entry (shown
|
|
419
|
+
* only while the prefix is still a prefix of `skill:`) keeps the `/` popup
|
|
420
|
+
* readable. A skill breaks out of the group only when its bare name matches
|
|
421
|
+
* the prefix at a strictly stronger tier than every non-skill command name
|
|
422
|
+
* and alias (`/batch` → `skill:batch` while no command prefix-matches
|
|
423
|
+
* `batch`); a tie keeps the popup command-only, and fuzzy-only skill hits
|
|
424
|
+
* never surface. Accepting the group inserts `/skill:` without a trailing
|
|
425
|
+
* space so the reopened popup expands to the individual skills.
|
|
401
426
|
*/
|
|
402
427
|
function collapseSkillNamespace(commands: CommandEntry[], lowerPrefix: string): CommandEntry[] {
|
|
403
428
|
if (lowerPrefix.startsWith(SKILL_NAMESPACE)) return commands;
|
|
429
|
+
const approachesNamespace = SKILL_NAMESPACE.startsWith(lowerPrefix);
|
|
430
|
+
let commandTier = 0;
|
|
431
|
+
if (!approachesNamespace) {
|
|
432
|
+
for (const cmd of commands) {
|
|
433
|
+
const name = getCommandName(cmd);
|
|
434
|
+
if (!name || name.startsWith(SKILL_NAMESPACE)) continue;
|
|
435
|
+
commandTier = Math.max(commandTier, skillBreakoutTier(lowerPrefix, name.toLowerCase()));
|
|
436
|
+
for (const alias of getCommandAliases(cmd)) {
|
|
437
|
+
commandTier = Math.max(commandTier, skillBreakoutTier(lowerPrefix, alias.toLowerCase()));
|
|
438
|
+
}
|
|
439
|
+
if (commandTier === 1000) break;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
404
442
|
let skillCount = 0;
|
|
405
443
|
let skillIcon: string | undefined;
|
|
406
444
|
const rest = commands.filter(cmd => {
|
|
407
|
-
|
|
445
|
+
const name = getCommandName(cmd);
|
|
446
|
+
if (!name?.startsWith(SKILL_NAMESPACE)) return true;
|
|
408
447
|
skillCount += 1;
|
|
409
448
|
skillIcon ??= cmd.icon;
|
|
410
|
-
return
|
|
449
|
+
return (
|
|
450
|
+
!approachesNamespace &&
|
|
451
|
+
skillBreakoutTier(lowerPrefix, name.slice(SKILL_NAMESPACE.length).toLowerCase()) > commandTier
|
|
452
|
+
);
|
|
411
453
|
});
|
|
412
454
|
if (skillCount === 0) return commands;
|
|
413
455
|
if (!SKILL_NAMESPACE.startsWith(lowerPrefix)) return rest;
|
package/src/components/loader.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { Text } from "./text";
|
|
|
5
5
|
const RENDER_INTERVAL_MS = 1000 / 30;
|
|
6
6
|
const SPINNER_ADVANCE_MS = 80;
|
|
7
7
|
const RENDER_BACKPRESSURE_MULTIPLIER = 9;
|
|
8
|
+
const MAX_BACKPRESSURE_FRAME_COST_MS = 200;
|
|
8
9
|
|
|
9
10
|
type ColorFn = (str: string) => string;
|
|
10
11
|
|
|
@@ -139,12 +140,17 @@ export class Loader extends Text {
|
|
|
139
140
|
this.#requestPaint();
|
|
140
141
|
}
|
|
141
142
|
|
|
142
|
-
const
|
|
143
|
+
const completedFrameCostMs = this.#ui?.lastFrameCostMs ?? 0;
|
|
144
|
+
const requestCostMs = performance.now() - startedAt;
|
|
143
145
|
if (this.#intervalId !== timer) return;
|
|
144
|
-
const cadenceDelayMs = Math.max(0, intervalMs -
|
|
145
|
-
// Idle for nine times the
|
|
146
|
-
// 10% CPU
|
|
147
|
-
const
|
|
146
|
+
const cadenceDelayMs = Math.max(0, intervalMs - requestCostMs);
|
|
147
|
+
// Idle for nine times the full frame cost to keep animation at or
|
|
148
|
+
// below 10% CPU even though requestComponentRender() only enqueues.
|
|
149
|
+
const boundedFrameCostMs = Math.min(
|
|
150
|
+
MAX_BACKPRESSURE_FRAME_COST_MS,
|
|
151
|
+
Math.max(completedFrameCostMs, requestCostMs),
|
|
152
|
+
);
|
|
153
|
+
const backpressureDelayMs = boundedFrameCostMs * RENDER_BACKPRESSURE_MULTIPLIER;
|
|
148
154
|
this.#scheduleTick(intervalMs, Math.max(cadenceDelayMs, backpressureDelayMs));
|
|
149
155
|
}, delayMs);
|
|
150
156
|
this.#intervalId = timer;
|
|
@@ -7,8 +7,9 @@ import {
|
|
|
7
7
|
type TokenizerAndRendererExtension,
|
|
8
8
|
type Tokens,
|
|
9
9
|
} from "@oh-my-pi/pi-utils/marked";
|
|
10
|
+
import { mathBlockAt, mathSpanAt, mathStartIndex } from "@oh-my-pi/pi-utils/math-delimiters";
|
|
10
11
|
import { latexToBlock } from "../latex-block";
|
|
11
|
-
import {
|
|
12
|
+
import { isBareMathEnvironment, latexToUnicode } from "../latex-to-unicode";
|
|
12
13
|
import type { SymbolTheme } from "../symbols";
|
|
13
14
|
import { TERMINAL } from "../terminal-capabilities";
|
|
14
15
|
import type { Component } from "../tui";
|
|
@@ -528,7 +529,7 @@ markdownParser.setOptions({
|
|
|
528
529
|
// `math` inline token before markdown's escape/emphasis/link rules run, so
|
|
529
530
|
// backslash commands (`\frac`, `\alpha`) and intraword underscores (`x_i`)
|
|
530
531
|
// survive intact instead of being mangled or split. The `$…$` form uses
|
|
531
|
-
// pandoc's anti-currency heuristic (`
|
|
532
|
+
// pandoc's anti-currency heuristic (`mathSpanAt`) so "$5 and $10" is
|
|
532
533
|
// never math. Inline extensions run before marked's escape tokenizer, so
|
|
533
534
|
// `\(…\)` becomes math while a genuinely escaped `\$` is left to `escape` and
|
|
534
535
|
// renders as a literal dollar.
|
|
@@ -582,78 +583,33 @@ const customHrExtension: TokenizerAndRendererExtension = {
|
|
|
582
583
|
},
|
|
583
584
|
};
|
|
584
585
|
|
|
585
|
-
//
|
|
586
|
-
// marked calls start() on the remaining source at every inline position, so
|
|
587
|
-
// the regex alternation showed up in CPU profiles (part of a ~4.3% start()
|
|
588
|
-
// tail). Three indexOf scans yield the identical leftmost index.
|
|
589
|
-
/** @internal exported for tests — must stay index-identical to the old regex scan. */
|
|
590
|
-
export function mathStartIndex(src: string): number | undefined {
|
|
591
|
-
let best = src.indexOf("$");
|
|
592
|
-
const paren = src.indexOf("\\(");
|
|
593
|
-
if (paren !== -1 && (best === -1 || paren < best)) best = paren;
|
|
594
|
-
const bracket = src.indexOf("\\[");
|
|
595
|
-
if (bracket !== -1 && (best === -1 || bracket < best)) best = bracket;
|
|
596
|
-
return best === -1 ? undefined : best;
|
|
597
|
-
}
|
|
598
|
-
|
|
586
|
+
// Delimiters come from `@oh-my-pi/pi-utils/math-delimiters`; rendering policy stays here.
|
|
599
587
|
const mathExtension: TokenizerAndRendererExtension = {
|
|
600
588
|
name: "math",
|
|
601
589
|
level: "inline",
|
|
602
|
-
start
|
|
603
|
-
return mathStartIndex(src);
|
|
604
|
-
},
|
|
590
|
+
start: mathStartIndex,
|
|
605
591
|
tokenizer(src) {
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
return { type: "math", raw: src.slice(0, end + 2), text: src.slice(2, end), display: true };
|
|
610
|
-
}
|
|
611
|
-
return undefined;
|
|
612
|
-
}
|
|
613
|
-
if (src.startsWith("\\[")) {
|
|
614
|
-
const end = src.indexOf("\\]", 2);
|
|
615
|
-
if (end !== -1) return { type: "math", raw: src.slice(0, end + 2), text: src.slice(2, end), display: true };
|
|
616
|
-
return undefined;
|
|
617
|
-
}
|
|
618
|
-
if (src.startsWith("\\(")) {
|
|
619
|
-
const end = src.indexOf("\\)", 2);
|
|
620
|
-
if (end !== -1) return { type: "math", raw: src.slice(0, end + 2), text: src.slice(2, end), display: false };
|
|
621
|
-
return undefined;
|
|
622
|
-
}
|
|
623
|
-
if (src.charCodeAt(0) === 0x24 /* $ */) {
|
|
624
|
-
const end = inlineMathSpanEnd(src, 0);
|
|
625
|
-
if (end !== -1) return { type: "math", raw: src.slice(0, end + 1), text: src.slice(1, end), display: false };
|
|
626
|
-
}
|
|
627
|
-
return undefined;
|
|
592
|
+
const span = mathSpanAt(src, 0);
|
|
593
|
+
if (!span) return undefined;
|
|
594
|
+
return { type: "math", raw: src.slice(0, span.end), text: span.body, display: span.display };
|
|
628
595
|
},
|
|
629
596
|
renderer(token) {
|
|
630
|
-
return
|
|
597
|
+
return typeof token.text === "string" ? token.text : "";
|
|
631
598
|
},
|
|
632
599
|
};
|
|
633
600
|
|
|
634
|
-
// Display math blocks: opening `$$` / `\[` and closing `$$` / `\]` each alone on
|
|
635
|
-
// their own line (≤3 leading spaces). Matched at the block level — before
|
|
636
|
-
// paragraph/list parsing — so a multi-line equation (e.g. a matrix with `\\`
|
|
637
|
-
// row breaks) renders across several lines instead of being collapsed onto one,
|
|
638
|
-
// and blank lines inside the block don't split it. The own-line requirement
|
|
639
|
-
// keeps inline `$$…$$` inside prose for the inline tokenizer above.
|
|
640
|
-
const MATH_BLOCK_DOLLAR = /^ {0,3}\$\$[ \t]*\n([\s\S]+?)\n {0,3}\$\$[ \t]*(?:\n|$)/;
|
|
641
|
-
const MATH_BLOCK_BRACKET = /^ {0,3}\\\[[ \t]*\n([\s\S]+?)\n {0,3}\\\][ \t]*(?:\n|$)/;
|
|
642
|
-
const MATH_BLOCK_START = /(?:^|\n) {0,3}(?:\$\$|\\\[)[ \t]*\n/;
|
|
643
601
|
const mathBlockExtension: TokenizerAndRendererExtension = {
|
|
644
602
|
name: "mathBlock",
|
|
645
603
|
level: "block",
|
|
646
|
-
start
|
|
647
|
-
|
|
648
|
-
return m ? m.index : undefined;
|
|
649
|
-
},
|
|
604
|
+
// No `start` hint: marked only probes block extensions at a block boundary
|
|
605
|
+
// here and never consults their hints.
|
|
650
606
|
tokenizer(src) {
|
|
651
|
-
const
|
|
652
|
-
if (!
|
|
653
|
-
return { type: "math", raw:
|
|
607
|
+
const block = mathBlockAt(src);
|
|
608
|
+
if (!block) return undefined;
|
|
609
|
+
return { type: "math", raw: block.raw, text: block.body, display: true };
|
|
654
610
|
},
|
|
655
611
|
renderer(token) {
|
|
656
|
-
return
|
|
612
|
+
return typeof token.text === "string" ? token.text : "";
|
|
657
613
|
},
|
|
658
614
|
};
|
|
659
615
|
|
|
@@ -718,7 +674,7 @@ const mathEnvBlockExtension: TokenizerAndRendererExtension = {
|
|
|
718
674
|
// (return undefined) to marked's own autolink handling unchanged.
|
|
719
675
|
const AUTOLINK_SCHEME_REGEX = /^(?:www\.|https?:\/\/|ftp:\/\/)/i;
|
|
720
676
|
// Case-insensitive scheme scan replacing /www\.|https?:\/\/|ftp:\/\//i in
|
|
721
|
-
// boundedAutolinkExtension.start — like mathStartIndex
|
|
677
|
+
// boundedAutolinkExtension.start — like `mathStartIndex`, this runs on the
|
|
722
678
|
// remaining source at every inline position (part of a ~4.3% CPU start() scan
|
|
723
679
|
// tail in profiles). charCode-only: no allocation, no toLowerCase copies.
|
|
724
680
|
// `| 32` lower-cases ASCII letters; `.`/`:`/`/` are compared exactly, matching
|
package/src/latex-to-unicode.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { mathSpanAt } from "@oh-my-pi/pi-utils/math-delimiters";
|
|
1
2
|
import { TERMINAL } from "./terminal-capabilities";
|
|
2
3
|
|
|
3
4
|
// LaTeX → Unicode/ANSI converter.
|
|
@@ -1934,47 +1935,34 @@ export function renderMathInText(text: string): string {
|
|
|
1934
1935
|
i += 2;
|
|
1935
1936
|
continue;
|
|
1936
1937
|
}
|
|
1937
|
-
if (d === "
|
|
1938
|
-
const close = text.indexOf("\\)", i + 2);
|
|
1939
|
-
if (close !== -1) {
|
|
1940
|
-
out += conv(text.slice(i + 2, close));
|
|
1941
|
-
i = close + 2;
|
|
1942
|
-
continue;
|
|
1943
|
-
}
|
|
1944
|
-
} else if (d === "[") {
|
|
1945
|
-
const close = text.indexOf("\\]", i + 2);
|
|
1946
|
-
if (close !== -1) {
|
|
1947
|
-
out += conv(text.slice(i + 2, close));
|
|
1948
|
-
i = close + 2;
|
|
1949
|
-
continue;
|
|
1950
|
-
}
|
|
1951
|
-
} else if (d === "$") {
|
|
1938
|
+
if (d === "$") {
|
|
1952
1939
|
out += "$";
|
|
1953
1940
|
i += 2;
|
|
1954
1941
|
continue;
|
|
1955
1942
|
}
|
|
1943
|
+
// `i` as the escape bound: the walk above already consumed `\\` and `\$`.
|
|
1944
|
+
const span = mathSpanAt(text, i, i);
|
|
1945
|
+
if (span) {
|
|
1946
|
+
out += conv(span.body);
|
|
1947
|
+
i = span.end;
|
|
1948
|
+
continue;
|
|
1949
|
+
}
|
|
1956
1950
|
out += c;
|
|
1957
1951
|
i++;
|
|
1958
1952
|
continue;
|
|
1959
1953
|
}
|
|
1960
1954
|
if (c === "$") {
|
|
1955
|
+
const span = mathSpanAt(text, i, i);
|
|
1956
|
+
if (span) {
|
|
1957
|
+
out += conv(span.body);
|
|
1958
|
+
i = span.end;
|
|
1959
|
+
continue;
|
|
1960
|
+
}
|
|
1961
1961
|
if (text[i + 1] === "$") {
|
|
1962
|
-
const close = text.indexOf("$$", i + 2);
|
|
1963
|
-
if (close !== -1 && text.slice(i + 2, close).trim().length > 0) {
|
|
1964
|
-
out += conv(text.slice(i + 2, close));
|
|
1965
|
-
i = close + 2;
|
|
1966
|
-
continue;
|
|
1967
|
-
}
|
|
1968
1962
|
out += "$$";
|
|
1969
1963
|
i += 2;
|
|
1970
1964
|
continue;
|
|
1971
1965
|
}
|
|
1972
|
-
const close = inlineMathSpanEnd(text, i);
|
|
1973
|
-
if (close !== -1) {
|
|
1974
|
-
out += conv(text.slice(i + 1, close));
|
|
1975
|
-
i = close + 1;
|
|
1976
|
-
continue;
|
|
1977
|
-
}
|
|
1978
1966
|
out += "$";
|
|
1979
1967
|
i++;
|
|
1980
1968
|
continue;
|
|
@@ -1984,34 +1972,3 @@ export function renderMathInText(text: string): string {
|
|
|
1984
1972
|
}
|
|
1985
1973
|
return renderBareMathInText(out);
|
|
1986
1974
|
}
|
|
1987
|
-
|
|
1988
|
-
/**
|
|
1989
|
-
* Index of the `$` that closes an inline math span opened at `open` (the index
|
|
1990
|
-
* of the opening `$`), or -1 when the run is not inline math. Applies pandoc's
|
|
1991
|
-
* anti-currency heuristics: the opener must not be followed by whitespace, the
|
|
1992
|
-
* closer must not be preceded by whitespace nor followed by a digit, `\$` is a
|
|
1993
|
-
* literal dollar, and the span may not span a newline. Shared by
|
|
1994
|
-
* `renderMathInText` and the markdown math tokenizer so the rule has one home.
|
|
1995
|
-
*/
|
|
1996
|
-
export function inlineMathSpanEnd(text: string, open: number): number {
|
|
1997
|
-
const after = text[open + 1];
|
|
1998
|
-
if (after === undefined || after === " " || after === "\t" || after === "\n" || after === "$") {
|
|
1999
|
-
return -1;
|
|
2000
|
-
}
|
|
2001
|
-
for (let j = open + 1; j < text.length; j++) {
|
|
2002
|
-
const ch = text[j];
|
|
2003
|
-
if (ch === "\\") {
|
|
2004
|
-
j++;
|
|
2005
|
-
continue;
|
|
2006
|
-
}
|
|
2007
|
-
if (ch === "\n") return -1;
|
|
2008
|
-
if (ch === "$") {
|
|
2009
|
-
const prev = text[j - 1];
|
|
2010
|
-
if (prev === " " || prev === "\t") return -1;
|
|
2011
|
-
const next = text[j + 1];
|
|
2012
|
-
if (next !== undefined && next >= "0" && next <= "9") continue; // currency: keep scanning
|
|
2013
|
-
return text.slice(open + 1, j).trim().length > 0 ? j : -1;
|
|
2014
|
-
}
|
|
2015
|
-
}
|
|
2016
|
-
return -1;
|
|
2017
|
-
}
|
|
@@ -446,10 +446,14 @@ export function shouldEnableHyperlinksByDefault(
|
|
|
446
446
|
return true;
|
|
447
447
|
}
|
|
448
448
|
|
|
449
|
-
function getFallbackImageProtocol(
|
|
450
|
-
|
|
449
|
+
function getFallbackImageProtocol(
|
|
450
|
+
terminalId: TerminalId,
|
|
451
|
+
env: NodeJS.ProcessEnv = Bun.env,
|
|
452
|
+
isTTY: boolean = process.stdout.isTTY === true,
|
|
453
|
+
): ImageProtocol | null {
|
|
454
|
+
if (!isTTY) return null;
|
|
451
455
|
if (terminalId === "vscode" || terminalId === "alacritty") return null;
|
|
452
|
-
const term =
|
|
456
|
+
const term = env.TERM?.toLowerCase() ?? "";
|
|
453
457
|
if (term.includes("screen") || term.includes("tmux") || term.includes("ghostty")) {
|
|
454
458
|
return ImageProtocol.Kitty;
|
|
455
459
|
}
|
|
@@ -470,6 +474,51 @@ export function resolveWarpImageProtocol(
|
|
|
470
474
|
return windowsHost ? null : ImageProtocol.Kitty;
|
|
471
475
|
}
|
|
472
476
|
|
|
477
|
+
/**
|
|
478
|
+
* Paseo (getpaseo/paseo) hardcodes `TERM_PROGRAM=kitty` into every PTY
|
|
479
|
+
* (`buildTerminalEnvironment`) while its xterm.js renderer implements neither
|
|
480
|
+
* Kitty graphics nor Unicode placeholders — trusting the advertisement turns
|
|
481
|
+
* image previews into literal PUA garbage (getpaseo/paseo#3850). Paseo
|
|
482
|
+
* injects `PASEO_TERMINAL_ID` into every terminal it hosts, which makes a
|
|
483
|
+
* reliable embedder signal.
|
|
484
|
+
*/
|
|
485
|
+
export function isPaseoEmbedder(env: NodeJS.ProcessEnv = Bun.env): boolean {
|
|
486
|
+
return Boolean(env.PASEO_TERMINAL_ID);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Resolve the image protocol for a non-forced runtime: static per-terminal
|
|
491
|
+
* support (with Warp's platform carve-out), then the multiplexer fallback,
|
|
492
|
+
* then the Paseo embedder carve-out. `isTTY` is injectable because the
|
|
493
|
+
* fallback only fires on a real TTY — a piped subprocess cannot exercise
|
|
494
|
+
* that path, so regression tests call this directly.
|
|
495
|
+
*/
|
|
496
|
+
export function resolveImageProtocol(
|
|
497
|
+
terminalId: TerminalId,
|
|
498
|
+
env: NodeJS.ProcessEnv = Bun.env,
|
|
499
|
+
isTTY: boolean = process.stdout.isTTY === true,
|
|
500
|
+
): ImageProtocol | null {
|
|
501
|
+
let imageProtocol: ImageProtocol | null;
|
|
502
|
+
if (terminalId === "warp") {
|
|
503
|
+
// Warp advertises Kitty graphics on macOS/Linux only; drop it on win32.
|
|
504
|
+
imageProtocol = resolveWarpImageProtocol(process.platform, env);
|
|
505
|
+
} else {
|
|
506
|
+
imageProtocol = getTerminalInfo(terminalId).imageProtocol;
|
|
507
|
+
if (!imageProtocol) {
|
|
508
|
+
const fallbackImageProtocol = getFallbackImageProtocol(terminalId, env, isTTY);
|
|
509
|
+
if (fallbackImageProtocol) imageProtocol = fallbackImageProtocol;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
// Paseo's xterm.js renderer draws neither Kitty APC nor placeholders —
|
|
513
|
+
// applied after the multiplexer fallback so tmux/screen inside a Paseo
|
|
514
|
+
// pane cannot restore Kitty via getFallbackImageProtocol
|
|
515
|
+
// (getpaseo/paseo#3850).
|
|
516
|
+
if (imageProtocol !== null && isPaseoEmbedder(env)) {
|
|
517
|
+
return null;
|
|
518
|
+
}
|
|
519
|
+
return imageProtocol;
|
|
520
|
+
}
|
|
521
|
+
|
|
473
522
|
function getWarpTerminalInfo(platform: NodeJS.Platform, env: NodeJS.ProcessEnv = Bun.env): TerminalInfo {
|
|
474
523
|
return new TerminalInfo(
|
|
475
524
|
"warp",
|
|
@@ -572,12 +621,8 @@ export const TERMINAL: RuntimeTerminal = (() => {
|
|
|
572
621
|
const forcedImageProtocol = getForcedImageProtocol();
|
|
573
622
|
if (forcedImageProtocol !== undefined) {
|
|
574
623
|
resolved.imageProtocol = forcedImageProtocol;
|
|
575
|
-
} else
|
|
576
|
-
|
|
577
|
-
resolved.imageProtocol = resolveWarpImageProtocol();
|
|
578
|
-
} else if (!resolved.imageProtocol) {
|
|
579
|
-
const fallbackImageProtocol = getFallbackImageProtocol(resolved.id);
|
|
580
|
-
if (fallbackImageProtocol) resolved.imageProtocol = fallbackImageProtocol;
|
|
624
|
+
} else {
|
|
625
|
+
resolved.imageProtocol = resolveImageProtocol(resolved.id, Bun.env, process.stdout.isTTY === true);
|
|
581
626
|
}
|
|
582
627
|
// Hyperlink (OSC 8) capability. The static per-terminal flag lives on
|
|
583
628
|
// KNOWN_TERMINALS; shouldEnableHyperlinksByDefault folds in runtime context —
|
package/src/terminal.ts
CHANGED
|
@@ -581,6 +581,17 @@ function isPrivateModeSupported(status: string): boolean {
|
|
|
581
581
|
return status !== "0" && status !== "4";
|
|
582
582
|
}
|
|
583
583
|
|
|
584
|
+
/** Construction-time overrides for {@link ProcessTerminal}. */
|
|
585
|
+
export interface ProcessTerminalOptions {
|
|
586
|
+
/**
|
|
587
|
+
* Force ConPTY-hosted behavior on or off. Defaults to live detection via
|
|
588
|
+
* {@link isConPTYHosted}. Tests set this so the kitty-flag and write-chunking
|
|
589
|
+
* paths stay hermetic regardless of the ambient WSL env (`WSL_DISTRO_NAME` /
|
|
590
|
+
* `WSL_INTEROP`) — the suite must behave identically on WSL and on CI.
|
|
591
|
+
*/
|
|
592
|
+
conpty?: boolean;
|
|
593
|
+
}
|
|
594
|
+
|
|
584
595
|
/**
|
|
585
596
|
* Real terminal using process.stdin/stdout
|
|
586
597
|
*/
|
|
@@ -619,6 +630,11 @@ export class ProcessTerminal implements Terminal {
|
|
|
619
630
|
// terminal side effect (writes, probes, raw mode, SIGWINCH, timers) is
|
|
620
631
|
// suppressed. Defaults on under `bun test` — see isTerminalHeadless().
|
|
621
632
|
#headless = isTerminalHeadless();
|
|
633
|
+
// Captured once at construction: whether stdout flows through a ConPTY
|
|
634
|
+
// pseudo-console. Gates the kitty-flag choice (#1216) and large-write
|
|
635
|
+
// chunking (#safeWrite). Live-detected by default; tests inject a fixed
|
|
636
|
+
// value so WSL env does not change behavior. See {@link ProcessTerminalOptions}.
|
|
637
|
+
readonly #conpty: boolean;
|
|
622
638
|
#writeLogPath = $env.PI_TUI_WRITE_LOG || "";
|
|
623
639
|
#stdoutErrorCleanup?: () => void;
|
|
624
640
|
#stdoutErrorHandler = (err: Error) => {
|
|
@@ -674,6 +690,10 @@ export class ProcessTerminal implements Terminal {
|
|
|
674
690
|
#windowsTerminalAppearancePollTimer?: Timer;
|
|
675
691
|
#progressTimer?: Timer;
|
|
676
692
|
|
|
693
|
+
constructor(options?: ProcessTerminalOptions) {
|
|
694
|
+
this.#conpty = options?.conpty ?? isConPTYHosted();
|
|
695
|
+
}
|
|
696
|
+
|
|
677
697
|
get kittyProtocolActive(): boolean {
|
|
678
698
|
return this.#kittyProtocolActive;
|
|
679
699
|
}
|
|
@@ -1213,7 +1233,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
1213
1233
|
const reportedFlags = parseInt(match[1]!, 10);
|
|
1214
1234
|
this.#kittyProtocolActive = true;
|
|
1215
1235
|
setKittyProtocolActive(true);
|
|
1216
|
-
if (
|
|
1236
|
+
if (this.#conpty) {
|
|
1217
1237
|
// ConPTY (native Windows and WSL) drops Shift+letter keypresses
|
|
1218
1238
|
// entirely when flag 4 (report alternate keys) is set. Use flag 1
|
|
1219
1239
|
// (disambiguate only), preserving flag 2 if already active.
|
|
@@ -1863,7 +1883,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
1863
1883
|
// threshold. See #2034 and #2095.
|
|
1864
1884
|
const bytes = Buffer.byteLength(data, "utf8");
|
|
1865
1885
|
let accepted: boolean;
|
|
1866
|
-
if (
|
|
1886
|
+
if (this.#conpty && bytes > MAX_CONPTY_WRITE_CHUNK_BYTES) {
|
|
1867
1887
|
accepted = true;
|
|
1868
1888
|
for (const chunk of chunkForConPTY(data, MAX_CONPTY_WRITE_CHUNK_BYTES)) {
|
|
1869
1889
|
if (this.#dead) break;
|
package/src/tui.ts
CHANGED
|
@@ -901,6 +901,16 @@ export class TUI extends Container {
|
|
|
901
901
|
return this.#synchronizedOutputEnabled;
|
|
902
902
|
}
|
|
903
903
|
|
|
904
|
+
/**
|
|
905
|
+
* Cost in milliseconds of the most recently completed frame.
|
|
906
|
+
*
|
|
907
|
+
* Animation components use this to apply proportional backpressure after
|
|
908
|
+
* their render request is asynchronously composed and written.
|
|
909
|
+
*/
|
|
910
|
+
get lastFrameCostMs(): number {
|
|
911
|
+
return this.#lastFrameCostMs;
|
|
912
|
+
}
|
|
913
|
+
|
|
904
914
|
setFocus(component: Component | null): void {
|
|
905
915
|
const topVisibleOverlay = this.#getTopmostVisibleOverlay();
|
|
906
916
|
if (topVisibleOverlay && !isOverlayFocusTarget(topVisibleOverlay.component, component)) {
|
|
@@ -2218,6 +2228,14 @@ export class TUI extends Container {
|
|
|
2218
2228
|
* Append mode leaves the terminal's prior copy in place and writes a
|
|
2219
2229
|
* current-width copy below it. Rebuild mode routes through the destructive
|
|
2220
2230
|
* reset latch so ED3 removes stale history before the same replay.
|
|
2231
|
+
*
|
|
2232
|
+
* The append replay only exists to refresh width-shredded scrollback: a
|
|
2233
|
+
* width change leaves the terminal's committed history wrapped at the old
|
|
2234
|
+
* width, so the copy below it is the intended fresh current-width render. A
|
|
2235
|
+
* height-only change reflows nothing (the terminal merely pulls rows back
|
|
2236
|
+
* out of scrollback), so replaying would write an identical duplicate — the
|
|
2237
|
+
* editor/status chrome included — below the retained copy. Skip it, matching
|
|
2238
|
+
* the `widthChanged`-gated commit-ledger logic in {@link #doRender}.
|
|
2221
2239
|
*/
|
|
2222
2240
|
#prepareResizeReplay(width: number, height: number): void {
|
|
2223
2241
|
const size = `${width}x${height}`;
|
|
@@ -2240,6 +2258,7 @@ export class TUI extends Container {
|
|
|
2240
2258
|
this.#prepareForcedRender(true);
|
|
2241
2259
|
return;
|
|
2242
2260
|
}
|
|
2261
|
+
if (width === this.#previousWidth) return;
|
|
2243
2262
|
provider.beginHistoryReplay();
|
|
2244
2263
|
this.#forceViewportRepaintOnNextRender = true;
|
|
2245
2264
|
}
|
|
@@ -2352,7 +2371,16 @@ export class TUI extends Container {
|
|
|
2352
2371
|
// committed rows and blanks, never an unfinished frame.
|
|
2353
2372
|
const pushed = Math.max(0, startTop + preparedHistory.length + rows - height);
|
|
2354
2373
|
if (pushed > this.#providerViewportTop && this.#providerWindow.length > 0) {
|
|
2355
|
-
|
|
2374
|
+
const eraseTop = this.#providerViewportTop;
|
|
2375
|
+
if (eraseTop > 0) {
|
|
2376
|
+
// Below existing history: ED0 here never spans the whole screen.
|
|
2377
|
+
buffer += `\x1b[${eraseTop + 1};1H\x1b[J`;
|
|
2378
|
+
} else {
|
|
2379
|
+
// Full-screen erase makes tmux preserve the live rows (#9780);
|
|
2380
|
+
// EL2 the first row + ED0 the rest clears the same cells, no full clear.
|
|
2381
|
+
buffer += "\x1b[1;1H\x1b[2K";
|
|
2382
|
+
if (height > 1) buffer += "\x1b[2;1H\x1b[J";
|
|
2383
|
+
}
|
|
2356
2384
|
}
|
|
2357
2385
|
buffer += `\x1b[${startTop + 1};1H`;
|
|
2358
2386
|
let screenRow = startTop;
|
|
@@ -2471,10 +2499,16 @@ export class TUI extends Container {
|
|
|
2471
2499
|
this.#altActive = false;
|
|
2472
2500
|
this.#altMouseTrackingActive = false;
|
|
2473
2501
|
this.#altPreviousLines = [];
|
|
2474
|
-
// The alt
|
|
2475
|
-
//
|
|
2476
|
-
//
|
|
2502
|
+
// The alt-buffer restore put the pre-overlay normal screen back. If
|
|
2503
|
+
// that buffer resized while covered, its cursor moved with width
|
|
2504
|
+
// rewrap or a height-grow scrollback pull, while our viewport anchor
|
|
2505
|
+
// stayed frozen. Recover the restored cursor position before any
|
|
2506
|
+
// provider repaint can overwrite history at the stale row.
|
|
2477
2507
|
if (width !== this.#altEnterWidth || height !== this.#altEnterHeight) {
|
|
2508
|
+
if (this.#frameProvider !== undefined) {
|
|
2509
|
+
this.#beginResizeAnchorProbe();
|
|
2510
|
+
return;
|
|
2511
|
+
}
|
|
2478
2512
|
this.#forceViewportRepaintOnNextRender = true;
|
|
2479
2513
|
}
|
|
2480
2514
|
} else if (wantMouseTracking !== this.#altMouseTrackingActive) {
|