@oh-my-pi/pi-tui 16.0.9 → 16.0.11
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/utils.d.ts +3 -7
- package/package.json +3 -3
- package/src/components/loader.ts +5 -2
- package/src/components/markdown.ts +211 -11
- package/src/tui.ts +19 -12
- package/src/utils.ts +20 -30
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.0.11] - 2026-06-19
|
|
6
|
+
|
|
7
|
+
### Breaking Changes
|
|
8
|
+
|
|
9
|
+
- Removed `getIndentation` and `getIndentationNoescape` exported utilities
|
|
10
|
+
- Tab-related operations no longer respect per-file or globally configured indentation settings
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Standardized tab expansion to use a fixed display width instead of configurable settings
|
|
15
|
+
- Removed support for custom tab width configuration in text rendering and input handling
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- Corrected logic in string truncation to prevent improper truncation of short strings
|
|
20
|
+
- Fixed a one-frame transcript flash during a non-multiplexer resize drag: while the drag borrowed the alternate screen and painted only the viewport, any ordinary (non-forced) render from a still-animating block — a tool spinner tick, a streamed token, a cursor blink — fell through to the deferred geometry-rebuild full paint, which left the alternate screen to repaint the whole transcript on the normal screen for a single frame before the next SIGWINCH re-entered the viewport fast path, so a live tool block flashed in and vanished. Ordinary renders mid-drag now stay on the viewport fast path; only forced renders (tool finalization, reset, image reconciliation) still preempt it.
|
|
21
|
+
|
|
22
|
+
## [16.0.10] - 2026-06-18
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- Fixed Markdown renderer rendering raw HTML tags (like `<br>`, `<li>`, `<ul>`, `<ol>`, and `<p>`) literally in the terminal by parsing and converting them to appropriate terminal formatting, preserving repeated HTML line breaks, nested HTML list indentation, ordered list numbering, paragraph-wrapped list item markers, paragraph separation, and table sizing after HTML line breaks.
|
|
27
|
+
- Fixed animated working-message loader frames repainting at 30fps on terminals without synchronized-output support, which could cause visible flicker during normal prompt rendering ([#2771](https://github.com/can1357/oh-my-pi/issues/2771)).
|
|
28
|
+
|
|
5
29
|
## [16.0.9] - 2026-06-18
|
|
6
30
|
|
|
7
31
|
### Fixed
|
package/dist/types/utils.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Ellipsis, type ExtractSegmentsResult, type SliceResult } from "@oh-my-pi/pi-natives";
|
|
2
2
|
export { Ellipsis } from "@oh-my-pi/pi-natives";
|
|
3
|
-
export {
|
|
3
|
+
export { DEFAULT_TAB_WIDTH } from "@oh-my-pi/pi-utils";
|
|
4
4
|
export type TextSizingScale = 1 | 2 | 3;
|
|
5
5
|
export type TextSizingVerticalAlign = "top" | "bottom" | "center";
|
|
6
6
|
export type TextSizingHorizontalAlign = "left" | "right" | "center";
|
|
@@ -17,14 +17,10 @@ export interface TextSizingOptions {
|
|
|
17
17
|
*/
|
|
18
18
|
export declare function encodeTextSized(text: string, options?: TextSizingOptions): string;
|
|
19
19
|
export declare function sliceWithWidth(line: string, startCol: number, length: number, strict?: boolean | null): SliceResult;
|
|
20
|
-
export declare function truncateToWidth(text: string, maxWidth: number, ellipsisKind?: Ellipsis | null, pad?: boolean | null): string;
|
|
20
|
+
export declare function truncateToWidth(text: string, maxWidth: number, ellipsisKind?: Ellipsis | null | "", pad?: boolean | null): string;
|
|
21
21
|
export declare function wrapTextWithAnsi(text: string, width: number): string[];
|
|
22
22
|
export declare function extractSegments(line: string, beforeEnd: number, afterStart: number, afterLen: number, strictAfter: boolean): ExtractSegmentsResult;
|
|
23
|
-
|
|
24
|
-
* Tab width in columns for `file`, using `process.cwd()` as the project root for relative paths.
|
|
25
|
-
*/
|
|
26
|
-
export declare function getIndentationNoescape(file?: string): number;
|
|
27
|
-
export declare function replaceTabs(text: string, file?: string): string;
|
|
23
|
+
export declare function replaceTabs(text: string): string;
|
|
28
24
|
/**
|
|
29
25
|
* Returns a string of n spaces. Uses a pre-allocated buffer for efficiency.
|
|
30
26
|
*/
|
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.0.
|
|
4
|
+
"version": "16.0.11",
|
|
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.0.
|
|
41
|
-
"@oh-my-pi/pi-utils": "16.0.
|
|
40
|
+
"@oh-my-pi/pi-natives": "16.0.11",
|
|
41
|
+
"@oh-my-pi/pi-utils": "16.0.11",
|
|
42
42
|
"lru-cache": "11.5.1",
|
|
43
43
|
"marked": "^18.0.5"
|
|
44
44
|
},
|
package/src/components/loader.ts
CHANGED
|
@@ -57,12 +57,15 @@ export class Loader extends Text {
|
|
|
57
57
|
this.#intervalId = setInterval(() => {
|
|
58
58
|
const now = performance.now();
|
|
59
59
|
const elapsed = now - this.#lastSpinnerTick;
|
|
60
|
-
|
|
60
|
+
const shouldAdvanceSpinner = elapsed >= SPINNER_ADVANCE_MS;
|
|
61
|
+
if (shouldAdvanceSpinner) {
|
|
61
62
|
const steps = Math.floor(elapsed / SPINNER_ADVANCE_MS);
|
|
62
63
|
this.#currentFrame = (this.#currentFrame + steps) % this.#frames.length;
|
|
63
64
|
this.#lastSpinnerTick += steps * SPINNER_ADVANCE_MS;
|
|
64
65
|
}
|
|
65
|
-
this.#
|
|
66
|
+
if (shouldAdvanceSpinner || this.#ui?.synchronizedOutput === true) {
|
|
67
|
+
this.#updateDisplay();
|
|
68
|
+
}
|
|
66
69
|
}, intervalMs);
|
|
67
70
|
}
|
|
68
71
|
|
|
@@ -31,6 +31,167 @@ function isOsc66Line(line: string): boolean {
|
|
|
31
31
|
return line.includes(OSC66_LINE_PREFIX);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
function normalizeHtmlEntitiesForTerminal(raw: string): string {
|
|
35
|
+
return raw.replace(/ /gi, " ");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface HtmlListState {
|
|
39
|
+
type: "ol" | "ul";
|
|
40
|
+
next: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface HtmlNormalizationState {
|
|
44
|
+
lists: HtmlListState[];
|
|
45
|
+
openItems: boolean[];
|
|
46
|
+
itemHasContent: boolean[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function createHtmlNormalizationState(): HtmlNormalizationState {
|
|
50
|
+
return { lists: [], openItems: [], itemHasContent: [] };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const HTML_TAG_REGEX = /<\/?(?:br|p|ol|ul|li)\b(?:\s[^>]*)?\s*\/?>/gi;
|
|
54
|
+
|
|
55
|
+
function htmlTagName(tag: string): string {
|
|
56
|
+
const match = /^<\/?\s*([A-Za-z][A-Za-z0-9:-]*)/.exec(tag);
|
|
57
|
+
return match ? match[1].toLowerCase() : "";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function htmlOlStart(tag: string): number {
|
|
61
|
+
const match = /\bstart\s*=\s*(?:"(\d+)"|'(\d+)'|(\d+))/i.exec(tag);
|
|
62
|
+
if (!match) return 1;
|
|
63
|
+
return Number(match[1] ?? match[2] ?? match[3]);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function appendHtmlLineBreak(output: string, force: boolean = false): string {
|
|
67
|
+
const trimmed = output.replace(/[ \t]+$/u, "");
|
|
68
|
+
return !force && trimmed.endsWith("\n") ? trimmed : `${trimmed}\n`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function htmlListIndent(state: HtmlNormalizationState): string {
|
|
72
|
+
return " ".repeat(Math.max(0, state.lists.length - 1));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function appendHtmlListBreak(output: string, state: HtmlNormalizationState): string {
|
|
76
|
+
const indent = htmlListIndent(state);
|
|
77
|
+
return output.endsWith(`${indent}\n`) ? output : appendHtmlLineBreak(output);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function markCurrentHtmlItemContent(state: HtmlNormalizationState, text: string): void {
|
|
81
|
+
if (text.trim() !== "" && state.itemHasContent.length > 0) {
|
|
82
|
+
state.itemHasContent[state.itemHasContent.length - 1] = true;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isAtEmptyHtmlListItem(state: HtmlNormalizationState): boolean {
|
|
87
|
+
const itemIndex = state.itemHasContent.length - 1;
|
|
88
|
+
return state.openItems[itemIndex] === true && state.itemHasContent[itemIndex] !== true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function normalizeHtmlForTerminal(raw: string, state: HtmlNormalizationState = createHtmlNormalizationState()): string {
|
|
92
|
+
let output = "";
|
|
93
|
+
let lastIndex = 0;
|
|
94
|
+
|
|
95
|
+
for (const match of raw.matchAll(HTML_TAG_REGEX)) {
|
|
96
|
+
const tag = match[0];
|
|
97
|
+
const index = match.index ?? 0;
|
|
98
|
+
const textBeforeTag = normalizeHtmlEntitiesForTerminal(raw.slice(lastIndex, index));
|
|
99
|
+
// HTML formatting whitespace between block/list tags (e.g. the newlines and
|
|
100
|
+
// indentation in pretty-printed `<ul>\n <li>…`) is not rendered content;
|
|
101
|
+
// appending it literally would leak source indentation before bullets and
|
|
102
|
+
// blank rows between items. Every tag handled here is block-level, so a
|
|
103
|
+
// whitespace-only slice is always insignificant formatting and is dropped.
|
|
104
|
+
if (textBeforeTag.trim() !== "") {
|
|
105
|
+
output += textBeforeTag;
|
|
106
|
+
markCurrentHtmlItemContent(state, textBeforeTag);
|
|
107
|
+
}
|
|
108
|
+
lastIndex = index + tag.length;
|
|
109
|
+
|
|
110
|
+
const name = htmlTagName(tag);
|
|
111
|
+
const isClosing = /^<\//.test(tag);
|
|
112
|
+
const isSelfClosing = /\/\s*>$/.test(tag);
|
|
113
|
+
|
|
114
|
+
switch (name) {
|
|
115
|
+
case "br":
|
|
116
|
+
output = appendHtmlLineBreak(output, true);
|
|
117
|
+
break;
|
|
118
|
+
case "p":
|
|
119
|
+
if (isClosing) {
|
|
120
|
+
output = appendHtmlLineBreak(output);
|
|
121
|
+
} else if (output.trim() !== "" && !output.endsWith("\n") && !isAtEmptyHtmlListItem(state)) {
|
|
122
|
+
output = appendHtmlLineBreak(output);
|
|
123
|
+
}
|
|
124
|
+
break;
|
|
125
|
+
case "ol":
|
|
126
|
+
if (isClosing) {
|
|
127
|
+
state.lists.pop();
|
|
128
|
+
state.openItems.pop();
|
|
129
|
+
state.itemHasContent.pop();
|
|
130
|
+
} else if (!isSelfClosing) {
|
|
131
|
+
if (state.openItems.length > 0 && state.openItems[state.openItems.length - 1]) {
|
|
132
|
+
output = appendHtmlListBreak(output, state);
|
|
133
|
+
}
|
|
134
|
+
state.lists.push({ type: "ol", next: htmlOlStart(tag) });
|
|
135
|
+
state.openItems.push(false);
|
|
136
|
+
state.itemHasContent.push(false);
|
|
137
|
+
}
|
|
138
|
+
break;
|
|
139
|
+
case "ul":
|
|
140
|
+
if (isClosing) {
|
|
141
|
+
state.lists.pop();
|
|
142
|
+
state.openItems.pop();
|
|
143
|
+
state.itemHasContent.pop();
|
|
144
|
+
} else if (!isSelfClosing) {
|
|
145
|
+
if (state.openItems.length > 0 && state.openItems[state.openItems.length - 1]) {
|
|
146
|
+
output = appendHtmlListBreak(output, state);
|
|
147
|
+
}
|
|
148
|
+
state.lists.push({ type: "ul", next: 1 });
|
|
149
|
+
state.openItems.push(false);
|
|
150
|
+
state.itemHasContent.push(false);
|
|
151
|
+
}
|
|
152
|
+
break;
|
|
153
|
+
case "li": {
|
|
154
|
+
if (isClosing) {
|
|
155
|
+
output = appendHtmlLineBreak(output);
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
if (state.openItems.length > 0) {
|
|
159
|
+
const itemOpenIndex = state.openItems.length - 1;
|
|
160
|
+
if (state.openItems[itemOpenIndex]) output = appendHtmlListBreak(output, state);
|
|
161
|
+
state.openItems[itemOpenIndex] = true;
|
|
162
|
+
state.itemHasContent[itemOpenIndex] = false;
|
|
163
|
+
} else if (output.trim() !== "" && !output.endsWith("\n")) {
|
|
164
|
+
output = appendHtmlLineBreak(output);
|
|
165
|
+
}
|
|
166
|
+
const list = state.lists[state.lists.length - 1];
|
|
167
|
+
const indent = htmlListIndent(state);
|
|
168
|
+
if (list?.type === "ol") {
|
|
169
|
+
output += `${indent}${list.next}. `;
|
|
170
|
+
list.next++;
|
|
171
|
+
} else {
|
|
172
|
+
output += `${indent}• `;
|
|
173
|
+
}
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
default:
|
|
177
|
+
output += tag;
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const remainingText = normalizeHtmlEntitiesForTerminal(raw.slice(lastIndex));
|
|
183
|
+
markCurrentHtmlItemContent(state, remainingText);
|
|
184
|
+
return output + remainingText;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function splitTerminalLines(text: string): string[] {
|
|
188
|
+
const lines = text.split("\n");
|
|
189
|
+
while (lines.length > 1 && lines[lines.length - 1] === "") {
|
|
190
|
+
lines.pop();
|
|
191
|
+
}
|
|
192
|
+
return lines;
|
|
193
|
+
}
|
|
194
|
+
|
|
34
195
|
class StrictStrikethroughTokenizer extends Tokenizer {
|
|
35
196
|
override del(src: string): Tokens.Del | undefined {
|
|
36
197
|
const match = STRICT_STRIKETHROUGH_REGEX.exec(src);
|
|
@@ -997,9 +1158,13 @@ export class Markdown implements Component {
|
|
|
997
1158
|
break;
|
|
998
1159
|
|
|
999
1160
|
case "html":
|
|
1000
|
-
// Render HTML as plain text (escaped for terminal)
|
|
1001
1161
|
if ("raw" in token && typeof token.raw === "string") {
|
|
1002
|
-
|
|
1162
|
+
const cleaned = normalizeHtmlForTerminal(token.raw);
|
|
1163
|
+
const blockLines = splitTerminalLines(cleaned);
|
|
1164
|
+
for (const line of blockLines) {
|
|
1165
|
+
const trimmed = line.trimEnd();
|
|
1166
|
+
lines.push(trimmed.trim() === "" ? "" : this.#applyDefaultStyle(trimmed));
|
|
1167
|
+
}
|
|
1003
1168
|
}
|
|
1004
1169
|
break;
|
|
1005
1170
|
|
|
@@ -1024,31 +1189,45 @@ export class Markdown implements Component {
|
|
|
1024
1189
|
const { applyText, stylePrefix } = resolvedStyleContext;
|
|
1025
1190
|
const applyTextWithNewlines = (text: string): string => {
|
|
1026
1191
|
const segments: string[] = text.split("\n");
|
|
1027
|
-
return segments.map((segment: string) => applyText(segment)).join("\n");
|
|
1192
|
+
return segments.map((segment: string) => (segment === "" ? "" : applyText(segment))).join("\n");
|
|
1028
1193
|
};
|
|
1029
1194
|
const swatchGlyph = this.#theme.symbols.colorSwatch || DEFAULT_COLOR_SWATCH_GLYPH;
|
|
1195
|
+
let trimLeadingWhitespace = false;
|
|
1196
|
+
const htmlState = createHtmlNormalizationState();
|
|
1197
|
+
const markHtmlItemWhenContent = (text: string): void => {
|
|
1198
|
+
markCurrentHtmlItemContent(htmlState, text);
|
|
1199
|
+
};
|
|
1030
1200
|
|
|
1031
1201
|
for (const token of tokens) {
|
|
1032
1202
|
if (isMathToken(token)) {
|
|
1203
|
+
markHtmlItemWhenContent(token.text);
|
|
1033
1204
|
result += applyTextWithNewlines(renderMathToken(token.text));
|
|
1034
1205
|
continue;
|
|
1035
1206
|
}
|
|
1036
1207
|
switch (token.type) {
|
|
1037
|
-
case "text":
|
|
1208
|
+
case "text": {
|
|
1209
|
+
const rawText = trimLeadingWhitespace ? token.text.replace(/^\s+/, "") : token.text;
|
|
1210
|
+
const text = normalizeHtmlEntitiesForTerminal(rawText);
|
|
1211
|
+
trimLeadingWhitespace = false;
|
|
1212
|
+
markHtmlItemWhenContent(text);
|
|
1213
|
+
if (token.tokens) markHtmlItemWhenContent(plainInlineTokens(token.tokens));
|
|
1038
1214
|
// Text tokens in list items can have nested tokens for inline formatting
|
|
1039
1215
|
if (token.tokens && token.tokens.length > 0) {
|
|
1040
1216
|
result += this.#renderInlineTokens(token.tokens, resolvedStyleContext);
|
|
1041
1217
|
} else {
|
|
1042
|
-
result += renderTextWithSwatches(
|
|
1218
|
+
result += renderTextWithSwatches(text, applyTextWithNewlines, swatchGlyph);
|
|
1043
1219
|
}
|
|
1044
1220
|
break;
|
|
1221
|
+
}
|
|
1045
1222
|
|
|
1046
1223
|
case "paragraph":
|
|
1047
1224
|
// Paragraph tokens contain nested inline tokens
|
|
1225
|
+
markHtmlItemWhenContent(plainInlineTokens(token.tokens || []));
|
|
1048
1226
|
result += this.#renderInlineTokens(token.tokens || [], resolvedStyleContext);
|
|
1049
1227
|
break;
|
|
1050
1228
|
|
|
1051
1229
|
case "strong": {
|
|
1230
|
+
markHtmlItemWhenContent(plainInlineTokens(token.tokens || []));
|
|
1052
1231
|
const boldContent = this.#renderInlineTokens(token.tokens || [], resolvedStyleContext);
|
|
1053
1232
|
result += this.#theme.bold(boldContent) + stylePrefix;
|
|
1054
1233
|
break;
|
|
@@ -1056,16 +1235,19 @@ export class Markdown implements Component {
|
|
|
1056
1235
|
|
|
1057
1236
|
case "em": {
|
|
1058
1237
|
const italicContent = this.#renderInlineTokens(token.tokens || [], resolvedStyleContext);
|
|
1238
|
+
markHtmlItemWhenContent(plainInlineTokens(token.tokens || []));
|
|
1059
1239
|
result += this.#theme.italic(italicContent) + stylePrefix;
|
|
1060
1240
|
break;
|
|
1061
1241
|
}
|
|
1062
1242
|
|
|
1063
1243
|
case "codespan": {
|
|
1244
|
+
markHtmlItemWhenContent(token.text);
|
|
1064
1245
|
result += codespanSwatch(token.text, swatchGlyph) + this.#theme.code(token.text) + stylePrefix;
|
|
1065
1246
|
break;
|
|
1066
1247
|
}
|
|
1067
1248
|
|
|
1068
1249
|
case "link": {
|
|
1250
|
+
markHtmlItemWhenContent(token.text);
|
|
1069
1251
|
const linkText = this.#renderInlineTokens(token.tokens || [], resolvedStyleContext);
|
|
1070
1252
|
const styledLinkText = this.#theme.link(this.#theme.underline(linkText));
|
|
1071
1253
|
const clickableLinkText = formatHyperlink(styledLinkText, token.href);
|
|
@@ -1085,25 +1267,36 @@ export class Markdown implements Component {
|
|
|
1085
1267
|
|
|
1086
1268
|
case "br":
|
|
1087
1269
|
result += "\n";
|
|
1270
|
+
trimLeadingWhitespace = true;
|
|
1088
1271
|
break;
|
|
1089
1272
|
|
|
1090
1273
|
case "del": {
|
|
1091
1274
|
const delContent = this.#renderInlineTokens(token.tokens || [], resolvedStyleContext);
|
|
1275
|
+
markHtmlItemWhenContent(plainInlineTokens(token.tokens || []));
|
|
1092
1276
|
result += this.#theme.strikethrough(delContent) + stylePrefix;
|
|
1093
1277
|
break;
|
|
1094
1278
|
}
|
|
1095
1279
|
|
|
1096
1280
|
case "html":
|
|
1097
|
-
// Render inline HTML as plain text
|
|
1098
1281
|
if ("raw" in token && typeof token.raw === "string") {
|
|
1099
|
-
|
|
1282
|
+
const cleaned = normalizeHtmlForTerminal(token.raw, htmlState);
|
|
1283
|
+
result += applyTextWithNewlines(cleaned);
|
|
1284
|
+
if (cleaned.endsWith("\n")) {
|
|
1285
|
+
trimLeadingWhitespace = true;
|
|
1286
|
+
} else if (cleaned.length > 0) {
|
|
1287
|
+
trimLeadingWhitespace = false;
|
|
1288
|
+
}
|
|
1100
1289
|
}
|
|
1101
1290
|
break;
|
|
1102
1291
|
|
|
1103
1292
|
default:
|
|
1104
1293
|
// Handle any other inline token types as plain text
|
|
1105
1294
|
if ("text" in token && typeof token.text === "string") {
|
|
1106
|
-
|
|
1295
|
+
const rawText = trimLeadingWhitespace ? token.text.replace(/^\s+/, "") : token.text;
|
|
1296
|
+
const text = normalizeHtmlEntitiesForTerminal(rawText);
|
|
1297
|
+
trimLeadingWhitespace = false;
|
|
1298
|
+
markHtmlItemWhenContent(text);
|
|
1299
|
+
result += applyTextWithNewlines(text);
|
|
1107
1300
|
}
|
|
1108
1301
|
}
|
|
1109
1302
|
}
|
|
@@ -1260,6 +1453,10 @@ export class Markdown implements Component {
|
|
|
1260
1453
|
return Math.min(longest, maxWidth);
|
|
1261
1454
|
}
|
|
1262
1455
|
|
|
1456
|
+
#terminalLineWidths(text: string): number[] {
|
|
1457
|
+
return splitTerminalLines(text).map(line => visibleWidth(line));
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1263
1460
|
/**
|
|
1264
1461
|
* Wrap a table cell to fit into a column.
|
|
1265
1462
|
*
|
|
@@ -1267,7 +1464,8 @@ export class Markdown implements Component {
|
|
|
1267
1464
|
* consistently with the rest of the renderer.
|
|
1268
1465
|
*/
|
|
1269
1466
|
#wrapCellText(text: string, maxWidth: number): string[] {
|
|
1270
|
-
|
|
1467
|
+
const cellWidth = Math.max(1, maxWidth);
|
|
1468
|
+
return splitTerminalLines(text).flatMap(line => wrapTextWithAnsi(line, cellWidth));
|
|
1271
1469
|
}
|
|
1272
1470
|
|
|
1273
1471
|
/**
|
|
@@ -1307,13 +1505,15 @@ export class Markdown implements Component {
|
|
|
1307
1505
|
const minWordWidths: number[] = [];
|
|
1308
1506
|
for (let i = 0; i < numCols; i++) {
|
|
1309
1507
|
const headerText = this.#renderInlineTokens(token.header[i].tokens || [], styleContext);
|
|
1310
|
-
|
|
1508
|
+
const headerLineWidths = this.#terminalLineWidths(headerText);
|
|
1509
|
+
naturalWidths[i] = Math.max(...headerLineWidths, 0);
|
|
1311
1510
|
minWordWidths[i] = Math.max(1, this.#getLongestWordWidth(headerText, maxUnbrokenWordWidth));
|
|
1312
1511
|
}
|
|
1313
1512
|
for (const row of token.rows) {
|
|
1314
1513
|
for (let i = 0; i < row.length; i++) {
|
|
1315
1514
|
const cellText = this.#renderInlineTokens(row[i].tokens || [], styleContext);
|
|
1316
|
-
|
|
1515
|
+
const cellLineWidths = this.#terminalLineWidths(cellText);
|
|
1516
|
+
naturalWidths[i] = Math.max(naturalWidths[i] || 0, ...cellLineWidths);
|
|
1317
1517
|
minWordWidths[i] = Math.max(
|
|
1318
1518
|
minWordWidths[i] || 1,
|
|
1319
1519
|
this.#getLongestWordWidth(cellText, maxUnbrokenWordWidth),
|
package/src/tui.ts
CHANGED
|
@@ -1005,11 +1005,6 @@ export class TUI extends Container {
|
|
|
1005
1005
|
// fast path (`#renderResizeViewport`) instead of an authoritative full
|
|
1006
1006
|
// paint, and no commit/window/diff state is advanced.
|
|
1007
1007
|
#resizeViewportActive = false;
|
|
1008
|
-
// Set only by the resize callback's cheap-paint request. A concurrent
|
|
1009
|
-
// caller-forced render (tool finalization, reset, image reconciliation) must
|
|
1010
|
-
// not be downgraded to the throwaway viewport path just because a resize
|
|
1011
|
-
// settle window is active.
|
|
1012
|
-
#resizeViewportPaintPending = false;
|
|
1013
1008
|
// Quiet-window timer that ends the drag: its callback clears the flag and
|
|
1014
1009
|
// drives the one authoritative full paint. Reset on every resize event so it
|
|
1015
1010
|
// only fires once the drag stops. Cancelled on stop().
|
|
@@ -1792,7 +1787,6 @@ export class TUI extends Container {
|
|
|
1792
1787
|
// Any non-component-scoped request makes the pending frame a full one.
|
|
1793
1788
|
this.#pendingRenderComponentsOnly = false;
|
|
1794
1789
|
if (force) {
|
|
1795
|
-
this.#resizeViewportPaintPending = false;
|
|
1796
1790
|
// Forced repaints landing inside the multiplexer resize debounce
|
|
1797
1791
|
// (e.g. `#finishSixelProbe`, image-budget eviction, a programmatic
|
|
1798
1792
|
// `requestRender(true)`) would paint into a still-reflowing pane
|
|
@@ -2471,16 +2465,30 @@ export class TUI extends Container {
|
|
|
2471
2465
|
// Strictly state-isolated: it never consumes #resizeEventPending nor
|
|
2472
2466
|
// advances any commit/window/diff field, so the authoritative full paint
|
|
2473
2467
|
// the settle timer queues reconciles as if these throwaway frames never
|
|
2474
|
-
// ran.
|
|
2475
|
-
//
|
|
2476
|
-
//
|
|
2468
|
+
// ran. Two render sources reach here mid-drag and BOTH must stay on this
|
|
2469
|
+
// path:
|
|
2470
|
+
// - the resize callback's own cheap paint after each SIGWINCH;
|
|
2471
|
+
// - an ordinary (non-forced) render from a live block that keeps
|
|
2472
|
+
// animating through the drag — a spinner tick, a streamed token, a
|
|
2473
|
+
// cursor blink — firing requestRender(false)/requestComponentRender.
|
|
2474
|
+
// #resizeEventPending is still set (the fast path never consumed it),
|
|
2475
|
+
// so without this branch the ordinary render falls through to the
|
|
2476
|
+
// geometry-rebuild full paint below, which LEAVES the borrowed
|
|
2477
|
+
// alternate screen to repaint the whole transcript on the normal
|
|
2478
|
+
// screen — then the next SIGWINCH re-enters the alt screen and paints
|
|
2479
|
+
// only the tail, so the block flashes in for one frame and vanishes.
|
|
2480
|
+
// A forced render (tool finalization, reset, image reconciliation) must
|
|
2481
|
+
// still preempt: it set #forceViewportRepaintOnNextRender via
|
|
2482
|
+
// #prepareForcedRender and owns the next authoritative paint, so it falls
|
|
2483
|
+
// through. A visible overlay composites over the transcript and needs the
|
|
2484
|
+
// whole window, so it also falls through (overlay resizes are not on the
|
|
2485
|
+
// drag-cost hot path).
|
|
2477
2486
|
if (
|
|
2478
|
-
this.#resizeViewportPaintPending &&
|
|
2479
2487
|
this.#resizeViewportActive &&
|
|
2488
|
+
!this.#forceViewportRepaintOnNextRender &&
|
|
2480
2489
|
this.#hasEverRendered &&
|
|
2481
2490
|
this.#getTopmostVisibleOverlay() === undefined
|
|
2482
2491
|
) {
|
|
2483
|
-
this.#resizeViewportPaintPending = false;
|
|
2484
2492
|
this.#componentRenderTargets.clear();
|
|
2485
2493
|
this.#renderResizeViewport(width, height);
|
|
2486
2494
|
return;
|
|
@@ -3145,7 +3153,6 @@ export class TUI extends Container {
|
|
|
3145
3153
|
|
|
3146
3154
|
#requestResizeViewportPaint(): void {
|
|
3147
3155
|
if (this.#stopped) return;
|
|
3148
|
-
this.#resizeViewportPaintPending = true;
|
|
3149
3156
|
this.#renderRequested = false;
|
|
3150
3157
|
this.#lastRenderAt = this.#renderScheduler.now();
|
|
3151
3158
|
this.#doRender();
|
package/src/utils.ts
CHANGED
|
@@ -7,11 +7,11 @@ import {
|
|
|
7
7
|
wrapTextWithAnsi as nativeWrapTextWithAnsi,
|
|
8
8
|
type SliceResult,
|
|
9
9
|
} from "@oh-my-pi/pi-natives";
|
|
10
|
-
import {
|
|
10
|
+
import { DEFAULT_TAB_WIDTH } from "@oh-my-pi/pi-utils";
|
|
11
11
|
|
|
12
12
|
export { Ellipsis } from "@oh-my-pi/pi-natives";
|
|
13
13
|
|
|
14
|
-
export {
|
|
14
|
+
export { DEFAULT_TAB_WIDTH } from "@oh-my-pi/pi-utils";
|
|
15
15
|
|
|
16
16
|
export type TextSizingScale = 1 | 2 | 3;
|
|
17
17
|
export type TextSizingVerticalAlign = "top" | "bottom" | "center";
|
|
@@ -74,35 +74,32 @@ export function encodeTextSized(text: string, options: TextSizingOptions = {}):
|
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
export function sliceWithWidth(line: string, startCol: number, length: number, strict?: boolean | null): SliceResult {
|
|
77
|
-
return nativeSliceWithWidth(line, startCol, length, strict ?? null,
|
|
77
|
+
return nativeSliceWithWidth(line, startCol, length, strict ?? null, DEFAULT_TAB_WIDTH);
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
export function truncateToWidth(
|
|
81
81
|
text: string,
|
|
82
82
|
maxWidth: number,
|
|
83
|
-
ellipsisKind?: Ellipsis | null,
|
|
83
|
+
ellipsisKind?: Ellipsis | null | "",
|
|
84
84
|
pad?: boolean | null,
|
|
85
85
|
): string {
|
|
86
|
-
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
let resolvedEllipsis: Ellipsis | null | undefined | string = ellipsisKind;
|
|
92
|
-
if (typeof resolvedEllipsis === "string") {
|
|
93
|
-
resolvedEllipsis = resolvedEllipsis === "" ? Ellipsis.Omit : Ellipsis.Unicode;
|
|
86
|
+
maxWidth = Math.max(0, maxWidth | 0);
|
|
87
|
+
// Fast path: every UTF-16 unit is at most 3 cells wide, so a string whose
|
|
88
|
+
// `length * 3` already fits within `safeWidth` cannot need truncation.
|
|
89
|
+
if (!pad && text.length * 3 <= maxWidth) {
|
|
90
|
+
return text;
|
|
94
91
|
}
|
|
95
92
|
return nativeTruncateToWidth(
|
|
96
93
|
text,
|
|
97
|
-
|
|
98
|
-
|
|
94
|
+
maxWidth,
|
|
95
|
+
(ellipsisKind === "" ? Ellipsis.Omit : ellipsisKind) ?? Ellipsis.Unicode,
|
|
99
96
|
pad ?? false,
|
|
100
|
-
|
|
97
|
+
DEFAULT_TAB_WIDTH,
|
|
101
98
|
);
|
|
102
99
|
}
|
|
103
100
|
|
|
104
101
|
export function wrapTextWithAnsi(text: string, width: number): string[] {
|
|
105
|
-
return nativeWrapTextWithAnsi(text, width,
|
|
102
|
+
return nativeWrapTextWithAnsi(text, width, DEFAULT_TAB_WIDTH);
|
|
106
103
|
}
|
|
107
104
|
|
|
108
105
|
export function extractSegments(
|
|
@@ -112,24 +109,17 @@ export function extractSegments(
|
|
|
112
109
|
afterLen: number,
|
|
113
110
|
strictAfter: boolean,
|
|
114
111
|
): ExtractSegmentsResult {
|
|
115
|
-
return nativeExtractSegments(line, beforeEnd, afterStart, afterLen, strictAfter,
|
|
112
|
+
return nativeExtractSegments(line, beforeEnd, afterStart, afterLen, strictAfter, DEFAULT_TAB_WIDTH);
|
|
116
113
|
}
|
|
117
114
|
|
|
118
115
|
// Pre-allocated space buffer for padding
|
|
119
116
|
const SPACE_BUFFER = " ".repeat(512);
|
|
120
117
|
|
|
121
|
-
/**
|
|
122
|
-
* Tab width in columns for `file`, using `process.cwd()` as the project root for relative paths.
|
|
123
|
-
*/
|
|
124
|
-
export function getIndentationNoescape(file?: string): number {
|
|
125
|
-
return getIndentation(file, process.cwd());
|
|
126
|
-
}
|
|
127
|
-
|
|
128
118
|
/*
|
|
129
|
-
* Replace tabs with
|
|
119
|
+
* Replace tabs with the fixed display tab width for consistent rendering.
|
|
130
120
|
*/
|
|
131
|
-
export function replaceTabs(text: string
|
|
132
|
-
return text.replaceAll("\t", " ".repeat(
|
|
121
|
+
export function replaceTabs(text: string): string {
|
|
122
|
+
return text.replaceAll("\t", " ".repeat(DEFAULT_TAB_WIDTH));
|
|
133
123
|
}
|
|
134
124
|
|
|
135
125
|
/**
|
|
@@ -186,7 +176,7 @@ export function visibleWidth(str: string): number {
|
|
|
186
176
|
for (let tabIndex = str.indexOf(TAB); tabIndex !== -1; tabIndex = str.indexOf(TAB, tabIndex + 1)) {
|
|
187
177
|
tabCount++;
|
|
188
178
|
}
|
|
189
|
-
if (tabCount > 0) width += tabCount *
|
|
179
|
+
if (tabCount > 0) width += tabCount * DEFAULT_TAB_WIDTH;
|
|
190
180
|
return width;
|
|
191
181
|
}
|
|
192
182
|
|
|
@@ -203,7 +193,7 @@ export function visibleWidth(str: string): number {
|
|
|
203
193
|
}
|
|
204
194
|
}
|
|
205
195
|
if (i === str.length) {
|
|
206
|
-
return tabCount === 0 ? str.length : str.length + tabCount * (
|
|
196
|
+
return tabCount === 0 ? str.length : str.length + tabCount * (DEFAULT_TAB_WIDTH - 1);
|
|
207
197
|
}
|
|
208
198
|
|
|
209
199
|
if (tabCount === 0) {
|
|
@@ -224,7 +214,7 @@ export function visibleWidth(str: string): number {
|
|
|
224
214
|
// the native scanner that traps under Bun 1.3.x GC/N-API load). It strips
|
|
225
215
|
// CSI/OSC to zero cells and shares the native engine's UAX#11 width tables.
|
|
226
216
|
let width = Bun.stringWidth(str, STRING_WIDTH_OPTS);
|
|
227
|
-
if (tabCount > 0) width += tabCount *
|
|
217
|
+
if (tabCount > 0) width += tabCount * DEFAULT_TAB_WIDTH;
|
|
228
218
|
|
|
229
219
|
// OSC 66: add back each stripped span as `scale * (explicit w ?? payload
|
|
230
220
|
// width)`. Matched rather than replaced to avoid reallocating the string.
|