@oh-my-pi/pi-tui 17.2.11 → 17.2.12

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,13 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.2.12] - 2026-08-08
6
+
7
+ ### Fixed
8
+
9
+ - Fixed slow Loader paints exceeding their cost-aware CPU duty cycle on WSL/ConPTY when a 200 ms backpressure cap was shorter than the proportional delay ([#8012](https://github.com/can1357/oh-my-pi/issues/8012)).
10
+ - Fixed display-math (`$$…$$`) fractions rendering as fragmented text when the numerator and denominator are written on separate source lines: `latexToBlock` treated the top-level newline between `\frac{num}` and `{den}` as a row break, severing `\frac` from its denominator. Such argument-continuation newlines are now preserved so the fraction stays stacked ([#7996](https://github.com/can1357/oh-my-pi/issues/7996)).
11
+
5
12
  ## [17.2.11] - 2026-08-07
6
13
 
7
14
  ### 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": "17.2.11",
4
+ "version": "17.2.12",
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": "17.2.11",
41
- "@oh-my-pi/pi-utils": "17.2.11"
40
+ "@oh-my-pi/pi-natives": "17.2.12",
41
+ "@oh-my-pi/pi-utils": "17.2.12"
42
42
  },
43
43
  "devDependencies": {
44
44
  "ghostty-web": "^0.4.0"
@@ -4,7 +4,6 @@ import { Text } from "./text";
4
4
 
5
5
  const RENDER_INTERVAL_MS = 1000 / 30;
6
6
  const SPINNER_ADVANCE_MS = 80;
7
- const MAX_RENDER_BACKPRESSURE_MS = 200;
8
7
  const RENDER_BACKPRESSURE_MULTIPLIER = 9;
9
8
 
10
9
  type ColorFn = (str: string) => string;
@@ -144,8 +143,8 @@ export class Loader extends Text {
144
143
  if (this.#intervalId !== timer) return;
145
144
  const cadenceDelayMs = Math.max(0, intervalMs - frameCostMs);
146
145
  // Idle for nine times the paint cost to keep animation at or below
147
- // 10% CPU, while cheap frames retain their original cadence.
148
- const backpressureDelayMs = Math.min(MAX_RENDER_BACKPRESSURE_MS, frameCostMs * RENDER_BACKPRESSURE_MULTIPLIER);
146
+ // 10% CPU, even when a slow ConPTY write exceeds the normal cadence.
147
+ const backpressureDelayMs = frameCostMs * RENDER_BACKPRESSURE_MULTIPLIER;
149
148
  this.#scheduleTick(intervalMs, Math.max(cadenceDelayMs, backpressureDelayMs));
150
149
  }, delayMs);
151
150
  this.#intervalId = timer;
@@ -146,6 +146,16 @@ const HBRACE_COMMANDS: Record<string, HBraceSpec> = {
146
146
  underparen: { left: "╰", mid: "─", center: "─", right: "╯", over: false },
147
147
  };
148
148
 
149
+ /**
150
+ * Number of required arguments each display command consumes. Shared by
151
+ * {@link readArg} and {@link splitLines} so nested command atoms consume exactly
152
+ * their own arguments while preserving any outer command's pending arity.
153
+ */
154
+ const COMMAND_ARITY: Record<string, number> = { overset: 2, underset: 2, stackrel: 2, sqrt: 1 };
155
+ for (const name in FRAC_COMMANDS) COMMAND_ARITY[name] = 2;
156
+ for (const name in BINOM_COMMANDS) COMMAND_ARITY[name] = 2;
157
+ for (const name in HBRACE_COMMANDS) COMMAND_ARITY[name] = 1;
158
+
149
159
  // Vertical delimiter piece characters: `only` for single-line content, then
150
160
  // top/mid/bot columns for stretched forms; `axis` replaces `mid` at the
151
161
  // baseline row (the brace point).
@@ -516,11 +526,13 @@ function readBraceGroup(src: string, i: number): Span {
516
526
 
517
527
  /**
518
528
  * Read one command argument: a `{…}` group, a single char, or a `\command`
519
- * together with its attached `[…]`/`{…}` arguments (or whole `\begin…\end`
520
- * block), so e.g. `\frac\sqrt{a}{b}` reads `\sqrt{a}` as the numerator.
529
+ * together with its arguments (or whole `\begin…\end` block). Commands whose
530
+ * arity is known consume exactly that many arguments, including across source
531
+ * whitespace, so `\frac\sqrt {a} {b}` reads `\sqrt {a}` as the numerator and
532
+ * leaves `{b}` for the denominator.
521
533
  */
522
534
  function readArg(src: string, i: number): Span {
523
- while (src[i] === " ") i++;
535
+ while (src[i] === " " || src[i] === "\t" || src[i] === "\n") i++;
524
536
  if (i >= src.length) return { text: "", end: i };
525
537
  if (src[i] === "{") return readBraceGroup(src, i);
526
538
  if (src[i] !== "\\") return { text: src[i], end: i + 1 };
@@ -535,6 +547,22 @@ function readArg(src: string, i: number): Span {
535
547
  if (env) return env;
536
548
  }
537
549
  if (!name) return { text: src.slice(i, i + 2), end: i + 2 }; // non-letter command (\,, \{, …)
550
+
551
+ const arity = COMMAND_ARITY[name];
552
+ if (arity !== undefined) {
553
+ let end = j;
554
+ // Optional command arguments (e.g. the degree in `\sqrt[3]{x}`) do not
555
+ // consume a required-argument slot.
556
+ for (;;) {
557
+ while (src[end] === " " || src[end] === "\t" || src[end] === "\n") end++;
558
+ if (src[end] !== "[") break;
559
+ const close = src.indexOf("]", end);
560
+ end = close === -1 ? src.length : close + 1;
561
+ }
562
+ for (let arg = 0; arg < arity; arg++) end = readArg(src, end).end;
563
+ return { text: src.slice(i, end), end };
564
+ }
565
+
538
566
  let end = j;
539
567
  while (src[end] === "[" || src[end] === "{") {
540
568
  if (src[end] === "{") end = readBraceGroup(src, end).end;
@@ -1271,6 +1299,80 @@ function parseExpr(src: string, ctx: Ctx = ROOT_CTX): Box {
1271
1299
  return hconcat(boxes);
1272
1300
  }
1273
1301
 
1302
+ /**
1303
+ * Count the command arguments still owed at the end of `seg` — non-zero when
1304
+ * the row ends mid-construct (`\frac{a}` awaiting its denominator, or
1305
+ * `\frac`/`x^` awaiting any argument). Pending arities form a stack: an
1306
+ * unbraced nested command consumes one outer argument, then retains its own
1307
+ * pending arguments without discarding the outer command's remaining arity.
1308
+ * Used to keep a command joined to an argument written on the next source line
1309
+ * while still treating an ordinary next row (`a\n{b+c}`) as a real row break.
1310
+ */
1311
+ function bracesOwed(seg: string): number {
1312
+ const pending: number[] = [];
1313
+ const consumeArg = (): void => {
1314
+ const top = pending.length - 1;
1315
+ if (top < 0) return;
1316
+ if (pending[top] === 1) pending.pop();
1317
+ else pending[top]--;
1318
+ };
1319
+
1320
+ let i = 0;
1321
+ while (i < seg.length) {
1322
+ const c = seg[i];
1323
+ if (c === "\\") {
1324
+ let j = i + 1;
1325
+ let name = "";
1326
+ while (j < seg.length && /[A-Za-z]/.test(seg[j])) name += seg[j++];
1327
+ // A command plus its immediately attached `[…]`/`{…}` groups is one
1328
+ // atom for an enclosing argument, matching readArg. Consume that outer
1329
+ // argument first, then retain only the command's own missing arguments
1330
+ // in a nested frame. Attached groups beyond the known arity still stay
1331
+ // part of the atom and cannot consume another outer argument.
1332
+ consumeArg();
1333
+ const arity = name ? (COMMAND_ARITY[name] ?? 0) : 0;
1334
+ let attached = 0;
1335
+ if (name) {
1336
+ while (seg[j] === "[" || seg[j] === "{") {
1337
+ if (seg[j] === "{") {
1338
+ j = readBraceGroup(seg, j).end;
1339
+ if (attached < arity) attached++;
1340
+ } else {
1341
+ const close = seg.indexOf("]", j);
1342
+ j = close === -1 ? seg.length : close + 1;
1343
+ }
1344
+ }
1345
+ } else {
1346
+ j = i + 2; // non-letter command (`\,`, `\{`, …)
1347
+ }
1348
+ const missing = arity - attached;
1349
+ if (missing > 0) pending.push(missing);
1350
+ i = j;
1351
+ continue;
1352
+ }
1353
+ if (c === "{") {
1354
+ i = readBraceGroup(seg, i).end;
1355
+ consumeArg();
1356
+ continue;
1357
+ }
1358
+ if (c === "^" || c === "_") {
1359
+ pending.push(1);
1360
+ i++;
1361
+ continue;
1362
+ }
1363
+ if (c === " " || c === "\t" || c === "\n") {
1364
+ i++;
1365
+ continue;
1366
+ }
1367
+ consumeArg(); // a bare atom satisfies one pending argument
1368
+ i++;
1369
+ }
1370
+
1371
+ let owed = 0;
1372
+ for (const remaining of pending) owed += remaining;
1373
+ return owed;
1374
+ }
1375
+
1274
1376
  /** Split on top-level `\n` and `\\` row separators (outside braces and environments). */
1275
1377
  function splitLines(src: string): string[] {
1276
1378
  const lines: string[] = [];
@@ -1308,8 +1410,16 @@ function splitLines(src: string): string[] {
1308
1410
  if (c === "{") braceDepth++;
1309
1411
  else if (c === "}") braceDepth--;
1310
1412
  else if (c === "\n" && braceDepth === 0 && envDepth === 0) {
1311
- lines.push(src.slice(last, i));
1312
- last = i + 1;
1413
+ // A top-level newline is a row break UNLESS the current row ends with a
1414
+ // command still awaiting an argument (e.g. `\frac{num}\n{den}`,
1415
+ // `\frac{num}\n\sqrt{x}`, or `x^\n2`). Splitting there would sever the
1416
+ // command from its argument, so keep both in one segment; latexToBlock
1417
+ // collapses the interior newline to a space before parsing. A row that
1418
+ // merely opens with a braced group (`a\n{b+c}`) stays a break.
1419
+ if (bracesOwed(src.slice(last, i)) === 0) {
1420
+ lines.push(src.slice(last, i));
1421
+ last = i + 1;
1422
+ }
1313
1423
  }
1314
1424
  i++;
1315
1425
  }
@@ -1327,7 +1437,7 @@ function splitLines(src: string): string[] {
1327
1437
  export function latexToBlock(src: string): string[] {
1328
1438
  if (typeof src !== "string" || src.trim() === "") return [];
1329
1439
  const rows = splitLines(src.trim())
1330
- .map(line => line.trim())
1440
+ .map(line => line.replace(/[ \t]*\n[ \t]*/g, " ").trim())
1331
1441
  .filter(line => line !== "")
1332
1442
  .map(line => parseExpr(line));
1333
1443
  if (rows.length === 0) return [];