@oh-my-pi/pi-tui 16.0.8 → 16.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.0.10] - 2026-06-18
6
+
7
+ ### Fixed
8
+
9
+ - 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.
10
+ - 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)).
11
+
12
+ ## [16.0.9] - 2026-06-18
13
+
14
+ ### Fixed
15
+
16
+ - Fixed bottom-anchored fullscreen overlays keeping their body rows but clipping off footer actions when terminal-height clamping is applied, restoring plan-mode approval options on short or stale-size terminals ([#2957](https://github.com/can1357/oh-my-pi/issues/2957)).
17
+
5
18
  ## [16.0.8] - 2026-06-18
6
19
 
7
20
  ### Fixed
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.8",
4
+ "version": "16.0.10",
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.8",
41
- "@oh-my-pi/pi-utils": "16.0.8",
40
+ "@oh-my-pi/pi-natives": "16.0.10",
41
+ "@oh-my-pi/pi-utils": "16.0.10",
42
42
  "lru-cache": "11.5.1",
43
43
  "marked": "^18.0.5"
44
44
  },
@@ -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
- if (elapsed >= SPINNER_ADVANCE_MS) {
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.#updateDisplay();
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(/&nbsp;/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
- lines.push(this.#applyDefaultStyle(token.raw.trim()));
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(token.text, applyTextWithNewlines, swatchGlyph);
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
- result += applyTextWithNewlines(token.raw);
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
- result += applyTextWithNewlines(token.text);
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
- return wrapTextWithAnsi(text, Math.max(1, maxWidth));
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
- naturalWidths[i] = visibleWidth(headerText);
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
- naturalWidths[i] = Math.max(naturalWidths[i] || 0, visibleWidth(cellText));
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
@@ -2309,7 +2309,11 @@ export class TUI extends Container {
2309
2309
  const { width, maxHeight } = this.#resolveOverlayLayout(options, 0, termWidth, termHeight);
2310
2310
  let overlayLines = component.render(width);
2311
2311
  if (overlayLines.length > maxHeight) {
2312
- overlayLines = overlayLines.slice(0, maxHeight);
2312
+ const anchor = options?.anchor ?? "center";
2313
+ overlayLines =
2314
+ anchor === "bottom-left" || anchor === "bottom-center" || anchor === "bottom-right"
2315
+ ? overlayLines.slice(overlayLines.length - maxHeight)
2316
+ : overlayLines.slice(0, maxHeight);
2313
2317
  }
2314
2318
  const { row, col } = this.#resolveOverlayLayout(options, overlayLines.length, termWidth, termHeight);
2315
2319
  for (let i = 0; i < overlayLines.length; i++) {