@earendil-works/pi-tui 1.4.0 → 1.5.1

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.
Files changed (63) hide show
  1. package/README.md +2 -0
  2. package/dist/components/editor.d.ts +2 -0
  3. package/dist/components/editor.d.ts.map +1 -1
  4. package/dist/components/editor.js +9 -2
  5. package/dist/components/editor.js.map +1 -1
  6. package/dist/components/markdown.d.ts +6 -0
  7. package/dist/components/markdown.d.ts.map +1 -1
  8. package/dist/components/markdown.js +152 -54
  9. package/dist/components/markdown.js.map +1 -1
  10. package/dist/components/select-list.d.ts +1 -0
  11. package/dist/components/select-list.d.ts.map +1 -1
  12. package/dist/components/select-list.js +10 -1
  13. package/dist/components/select-list.js.map +1 -1
  14. package/dist/components/settings-list.d.ts +1 -0
  15. package/dist/components/settings-list.d.ts.map +1 -1
  16. package/dist/components/settings-list.js +8 -1
  17. package/dist/components/settings-list.js.map +1 -1
  18. package/dist/components/text.d.ts.map +1 -1
  19. package/dist/components/text.js +3 -0
  20. package/dist/components/text.js.map +1 -1
  21. package/dist/fuzzy.d.ts.map +1 -1
  22. package/dist/fuzzy.js +59 -44
  23. package/dist/fuzzy.js.map +1 -1
  24. package/dist/index.d.ts +2 -2
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +2 -2
  27. package/dist/index.js.map +1 -1
  28. package/dist/keybindings.d.ts +6 -0
  29. package/dist/keybindings.d.ts.map +1 -1
  30. package/dist/keybindings.js +15 -0
  31. package/dist/keybindings.js.map +1 -1
  32. package/dist/keys.d.ts.map +1 -1
  33. package/dist/keys.js +60 -6
  34. package/dist/keys.js.map +1 -1
  35. package/dist/layout.d.ts.map +1 -1
  36. package/dist/layout.js +18 -13
  37. package/dist/layout.js.map +1 -1
  38. package/dist/program-status.d.ts.map +1 -1
  39. package/dist/program-status.js +2 -2
  40. package/dist/program-status.js.map +1 -1
  41. package/dist/terminal-image.d.ts +2 -0
  42. package/dist/terminal-image.d.ts.map +1 -1
  43. package/dist/terminal-image.js +18 -3
  44. package/dist/terminal-image.js.map +1 -1
  45. package/dist/terminal.d.ts.map +1 -1
  46. package/dist/terminal.js +11 -4
  47. package/dist/terminal.js.map +1 -1
  48. package/dist/tui-alt-screen.d.ts +2 -1
  49. package/dist/tui-alt-screen.d.ts.map +1 -1
  50. package/dist/tui-alt-screen.js +13 -10
  51. package/dist/tui-alt-screen.js.map +1 -1
  52. package/dist/tui-main-screen.d.ts.map +1 -1
  53. package/dist/tui-main-screen.js +4 -1
  54. package/dist/tui-main-screen.js.map +1 -1
  55. package/dist/tui.d.ts +5 -0
  56. package/dist/tui.d.ts.map +1 -1
  57. package/dist/tui.js +5 -3
  58. package/dist/tui.js.map +1 -1
  59. package/dist/utils.d.ts +2 -0
  60. package/dist/utils.d.ts.map +1 -1
  61. package/dist/utils.js +74 -7
  62. package/dist/utils.js.map +1 -1
  63. package/package.json +2 -2
@@ -36,6 +36,7 @@ export interface MarkdownTheme {
36
36
  italic: (text: string) => string;
37
37
  strikethrough: (text: string) => string;
38
38
  underline: (text: string) => string;
39
+ /** Highlighted lines of a code block. A Markdown component reuses the result for the same code until invalidate(). */
39
40
  highlightCode?: (code: string, lang?: string) => string[];
40
41
  /** Prefix applied to each rendered code block line (default: " ") */
41
42
  codeBlockIndent?: string;
@@ -62,10 +63,15 @@ export declare class Markdown implements Component {
62
63
  private cachedWidth?;
63
64
  private cachedLines?;
64
65
  private cachedTokens?;
66
+ private blocks?;
67
+ private blockHighlightedCode?;
65
68
  constructor(text: string, paddingX: number, paddingY: number, theme: MarkdownTheme, defaultTextStyle?: DefaultTextStyle, options?: MarkdownOptions);
66
69
  setText(text: string): void;
67
70
  invalidate(): void;
71
+ private clearRenderCache;
68
72
  render(width: number): string[];
73
+ /** Render one top-level block to output lines: wrapped, with margins, and padded or backgrounded to the width. */
74
+ private renderBlock;
69
75
  /**
70
76
  * Apply default text style to a string.
71
77
  * This is the base styling applied to all text content.
@@ -1 +1 @@
1
- {"version":3,"file":"markdown.d.ts","sourceRoot":"","sources":["../../src/components/markdown.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"markdown.d.ts","sourceRoot":"","sources":["../../src/components/markdown.ts"],"names":[],"mappings":"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"}
@@ -2,9 +2,18 @@ import { Marked, Tokenizer } from "marked";
2
2
  import markedCjkFriendly from "marked-cjk-friendly";
3
3
  import { renderLatex } from "../latex.js";
4
4
  import { getCapabilities, hyperlink, isImageLine } from "../terminal-image.js";
5
- import { applyBackgroundToLine, flattenLines, visibleWidth, wrapTextWithAnsi } from "../utils.js";
5
+ import { applyBackgroundToLine, cjkPunctuationRegex, flattenLines, visibleWidth, wrapTextWithAnsi } from "../utils.js";
6
6
  const STRICT_STRIKETHROUGH_REGEX = /^(~~)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/;
7
- class StrictStrikethroughTokenizer extends Tokenizer {
7
+ class MarkdownTokenizer extends Tokenizer {
8
+ url(src) {
9
+ const token = super.url(src);
10
+ if (!token)
11
+ return undefined;
12
+ // Bare URLs stop at CJK punctuation; explicit links and angle autolinks
13
+ // use separate tokenizers and can still contain these characters.
14
+ const punctuationIndex = token.raw.search(cjkPunctuationRegex);
15
+ return punctuationIndex < 0 ? token : super.url(token.raw.slice(0, punctuationIndex));
16
+ }
8
17
  del(src) {
9
18
  const match = STRICT_STRIKETHROUGH_REGEX.exec(src);
10
19
  if (!match) {
@@ -159,9 +168,40 @@ function trimPartialClosingFences(tokens) {
159
168
  }
160
169
  token.text = token.text.slice(0, -lastLine.length).replace(/\n$/, "");
161
170
  }
171
+ // An unclosed comment runs to the end of an HTML block, as in CommonMark, so streamed comments stay hidden.
172
+ const HTML_COMMENT_REGEX = /<!--[\s\S]*?(?:-->|$)/g;
173
+ function stripHtmlComments(html) {
174
+ return html.replace(HTML_COMMENT_REGEX, "");
175
+ }
176
+ /**
177
+ * Remove block HTML tokens that hold only comments, so they render no line. A removed comment between two blank
178
+ * lines, or at the start or end, also drops a blank line, so it leaves no extra gap.
179
+ * See https://github.com/earendil-works/pi/issues/11710.
180
+ */
181
+ function removeHtmlCommentBlocks(tokens) {
182
+ for (let i = tokens.length - 1; i >= 0; i--) {
183
+ const token = tokens[i];
184
+ if (token.type === "list") {
185
+ for (const item of token.items)
186
+ removeHtmlCommentBlocks(item.tokens);
187
+ }
188
+ else if (token.type === "blockquote") {
189
+ removeHtmlCommentBlocks(token.tokens ?? []);
190
+ }
191
+ else if (token.type === "html" && stripHtmlComments(token.raw).trim() === "") {
192
+ tokens.splice(i, 1);
193
+ if (tokens[i]?.type === "space" && (i === 0 || tokens[i - 1]?.type === "space")) {
194
+ tokens.splice(i, 1);
195
+ }
196
+ else if (i === tokens.length && tokens[i - 1]?.type === "space") {
197
+ tokens.splice(--i, 1);
198
+ }
199
+ }
200
+ }
201
+ }
162
202
  const markdownParser = new Marked();
163
203
  markdownParser.setOptions({
164
- tokenizer: new StrictStrikethroughTokenizer(),
204
+ tokenizer: new MarkdownTokenizer(),
165
205
  });
166
206
  markdownParser.use({
167
207
  tokenizer: { emStrong: markedCjkFriendly().tokenizer?.emStrong },
@@ -183,6 +223,13 @@ export class Markdown {
183
223
  // about ten times the size of its source, and every message of a long transcript keeps a Markdown component. The
184
224
  // tokens survive a burst of re-renders, such as a theme preview, and are collected afterwards.
185
225
  cachedTokens;
226
+ // The last render's top-level blocks. While a message streams only its last block changes, so a render styles and
227
+ // wraps only that block. Each render keeps only the blocks it used, which drops a streamed block's earlier versions.
228
+ // Held weakly like the tokens: the blocks repeat the source in their keys and the code in their highlighting, and
229
+ // only a burst of renders, such as streaming or a resize, needs them.
230
+ blocks;
231
+ // Highlighted code of the block being rendered, only set while renderBlock runs.
232
+ blockHighlightedCode;
186
233
  constructor(text, paddingX, paddingY, theme, defaultTextStyle, options) {
187
234
  this.text = text;
188
235
  this.paddingX = paddingX;
@@ -193,9 +240,15 @@ export class Markdown {
193
240
  }
194
241
  setText(text) {
195
242
  this.text = text;
196
- this.invalidate();
243
+ this.clearRenderCache();
197
244
  }
198
245
  invalidate() {
246
+ this.clearRenderCache();
247
+ // The theme may have changed, which changes every styled line and the default style's prefix.
248
+ this.blocks = undefined;
249
+ this.defaultStylePrefix = undefined;
250
+ }
251
+ clearRenderCache() {
199
252
  this.cachedText = undefined;
200
253
  this.cachedWidth = undefined;
201
254
  this.cachedLines = undefined;
@@ -210,6 +263,7 @@ export class Markdown {
210
263
  const text = this.options.transform?.(this.text, contentWidth) ?? this.text;
211
264
  // Don't render anything if there's no actual text
212
265
  if (!text || text.trim() === "") {
266
+ this.blocks = undefined;
213
267
  const result = [];
214
268
  // Update cache
215
269
  this.cachedText = this.text;
@@ -225,67 +279,77 @@ export class Markdown {
225
279
  if (!tokens) {
226
280
  tokens = markdownParser.lexer(normalizedText);
227
281
  trimPartialClosingFences(tokens);
282
+ removeHtmlCommentBlocks(tokens);
228
283
  this.cachedTokens = new WeakRef({ source: normalizedText, tokens });
229
284
  }
230
- // Convert tokens to styled terminal output
231
- const renderedLines = [];
285
+ // A reference link resolves through a definition anywhere in the text, so blocks with the same source can render
286
+ // differently; such texts are rare in practice and render without reusing blocks.
287
+ const reuseBlocks = Object.keys(tokens.links).length === 0;
288
+ const previousBlocks = this.blocks?.deref();
289
+ const blocks = new Map();
290
+ this.blocks = new WeakRef(blocks);
291
+ const contentLines = [];
232
292
  for (let i = 0; i < tokens.length; i++) {
233
293
  const token = tokens[i];
234
- const nextToken = tokens[i + 1];
235
- const tokenLines = this.renderToken(token, contentWidth, nextToken?.type);
236
- for (const tokenLine of tokenLines) {
237
- renderedLines.push(tokenLine);
238
- }
239
- }
240
- // Wrap lines (NO padding, NO background yet)
241
- const wrappedLines = [];
242
- for (const line of renderedLines) {
243
- if (isImageLine(line)) {
244
- wrappedLines.push(line);
294
+ const nextTokenType = tokens[i + 1]?.type;
295
+ const reused = reuseBlocks ? (blocks.get(token.raw) ?? previousBlocks?.get(token.raw)) : undefined;
296
+ const block = reused ?? { width: -1, nextTokenType, lines: [], highlightedCode: new Map() };
297
+ if (block.width !== width || block.nextTokenType !== nextTokenType) {
298
+ this.renderBlock(block, token, nextTokenType, width, contentWidth);
245
299
  }
246
- else {
247
- for (const wrappedLine of wrapTextWithAnsi(line, contentWidth)) {
248
- wrappedLines.push(wrappedLine);
249
- }
250
- }
251
- }
252
- // Add margins and background to each wrapped line
253
- const leftMargin = " ".repeat(this.paddingX);
254
- const rightMargin = " ".repeat(this.paddingX);
255
- const bgFn = this.defaultTextStyle?.bgColor;
256
- const contentLines = [];
257
- for (const line of wrappedLines) {
258
- if (isImageLine(line)) {
300
+ if (reuseBlocks)
301
+ blocks.set(token.raw, block);
302
+ for (const line of block.lines) {
259
303
  contentLines.push(line);
260
- continue;
261
- }
262
- const lineWithMargins = leftMargin + line + rightMargin;
263
- if (bgFn) {
264
- contentLines.push(applyBackgroundToLine(lineWithMargins, width, bgFn));
265
- }
266
- else {
267
- // No background - just pad to width
268
- const visibleLen = visibleWidth(lineWithMargins);
269
- const paddingNeeded = Math.max(0, width - visibleLen);
270
- contentLines.push(lineWithMargins + " ".repeat(paddingNeeded));
271
304
  }
272
305
  }
273
306
  // Add top/bottom padding (empty lines)
307
+ const bgFn = this.defaultTextStyle?.bgColor;
274
308
  const emptyLine = " ".repeat(width);
275
309
  const emptyLines = [];
276
310
  for (let i = 0; i < this.paddingY; i++) {
277
311
  const line = bgFn ? applyBackgroundToLine(emptyLine, width, bgFn) : emptyLine;
278
312
  emptyLines.push(line);
279
313
  }
314
+ flattenLines(emptyLines);
280
315
  // Combine top padding, content, and bottom padding
281
316
  const result = emptyLines.concat(contentLines, emptyLines);
282
- flattenLines(result);
283
317
  // Update cache
284
318
  this.cachedText = this.text;
285
319
  this.cachedWidth = width;
286
320
  this.cachedLines = result;
287
321
  return result.length > 0 ? result : [""];
288
322
  }
323
+ /** Render one top-level block to output lines: wrapped, with margins, and padded or backgrounded to the width. */
324
+ renderBlock(block, token, nextTokenType, width, contentWidth) {
325
+ this.blockHighlightedCode = block.highlightedCode;
326
+ const leftMargin = " ".repeat(this.paddingX);
327
+ const rightMargin = " ".repeat(this.paddingX);
328
+ const bgFn = this.defaultTextStyle?.bgColor;
329
+ const lines = [];
330
+ for (const line of this.renderToken(token, contentWidth, nextTokenType)) {
331
+ if (isImageLine(line)) {
332
+ lines.push(line);
333
+ continue;
334
+ }
335
+ for (const wrappedLine of wrapTextWithAnsi(line, contentWidth)) {
336
+ const lineWithMargins = leftMargin + wrappedLine + rightMargin;
337
+ if (bgFn) {
338
+ lines.push(applyBackgroundToLine(lineWithMargins, width, bgFn));
339
+ }
340
+ else {
341
+ // No background - just pad to width
342
+ const paddingNeeded = Math.max(0, width - visibleWidth(lineWithMargins));
343
+ lines.push(lineWithMargins + " ".repeat(paddingNeeded));
344
+ }
345
+ }
346
+ }
347
+ this.blockHighlightedCode = undefined;
348
+ flattenLines(lines);
349
+ block.lines = lines;
350
+ block.width = width;
351
+ block.nextTokenType = nextTokenType;
352
+ }
289
353
  /**
290
354
  * Apply default text style to a string.
291
355
  * This is the base styling applied to all text content.
@@ -398,9 +462,13 @@ export class Markdown {
398
462
  break;
399
463
  case "latexBlock": {
400
464
  const latexToken = token;
401
- const rendered = !latexToken.pending && this.options.renderLatex !== false
402
- ? (renderLatex(latexToken.text, { display: true }) ?? latexToken.raw.trim())
403
- : latexToken.raw.trim();
465
+ let rendered = !latexToken.pending && this.options.renderLatex !== false
466
+ ? renderLatex(latexToken.text, { display: true })
467
+ : undefined;
468
+ // A 2D layout cannot be wrapped row by row; fall back to the raw source when it does not fit.
469
+ if (rendered === undefined || rendered.split("\n").some((line) => visibleWidth(line) > width)) {
470
+ rendered = latexToken.raw.trim();
471
+ }
404
472
  for (const line of rendered.split("\n")) {
405
473
  lines.push(this.applyDefaultStyle(line));
406
474
  }
@@ -413,8 +481,13 @@ export class Markdown {
413
481
  const indent = this.theme.codeBlockIndent ?? " ";
414
482
  lines.push(this.theme.codeBlockBorder(`\`\`\`${token.lang || ""}`));
415
483
  if (this.theme.highlightCode) {
416
- const highlightedLines = this.theme.highlightCode(token.text, token.lang);
417
- for (const hlLine of highlightedLines) {
484
+ const key = `${token.lang ?? ""}\0${token.text}`;
485
+ let highlighted = this.blockHighlightedCode?.get(key);
486
+ if (!highlighted) {
487
+ highlighted = this.theme.highlightCode(token.text, token.lang);
488
+ this.blockHighlightedCode?.set(key, highlighted);
489
+ }
490
+ for (const hlLine of highlighted) {
418
491
  lines.push(`${indent}${hlLine}`);
419
492
  }
420
493
  }
@@ -453,8 +526,20 @@ export class Markdown {
453
526
  const lineWithReappliedStyle = line.replace(/\x1b\[0m/g, `\x1b[0m${quoteStylePrefix}`);
454
527
  return quoteStyle(lineWithReappliedStyle);
455
528
  };
529
+ // With no room for another border ("│ " plus at least one content column), render the
530
+ // nested content without one. Otherwise each extra level re-wraps every child line at
531
+ // width 1 and the output grows exponentially with nesting depth.
532
+ if (width < 3) {
533
+ const quoteTokens = token.tokens || [];
534
+ for (let i = 0; i < quoteTokens.length; i++) {
535
+ for (const line of this.renderToken(quoteTokens[i], width, quoteTokens[i + 1]?.type, styleContext)) {
536
+ lines.push(line);
537
+ }
538
+ }
539
+ break;
540
+ }
456
541
  // Calculate available width for quote content (subtract border "│ " = 2 chars)
457
- const quoteContentWidth = Math.max(1, width - 2);
542
+ const quoteContentWidth = width - 2;
458
543
  // Blockquotes contain block-level tokens (paragraph, list, code, etc.), so render
459
544
  // children with renderToken() instead of renderInlineTokens().
460
545
  // Default message style should not apply inside blockquotes.
@@ -467,7 +552,9 @@ export class Markdown {
467
552
  for (let i = 0; i < quoteTokens.length; i++) {
468
553
  const quoteToken = quoteTokens[i];
469
554
  const nextQuoteToken = quoteTokens[i + 1];
470
- renderedQuoteLines.push(...this.renderToken(quoteToken, quoteContentWidth, nextQuoteToken?.type, quoteInlineStyleContext));
555
+ for (const line of this.renderToken(quoteToken, quoteContentWidth, nextQuoteToken?.type, quoteInlineStyleContext)) {
556
+ renderedQuoteLines.push(line);
557
+ }
471
558
  }
472
559
  // Avoid rendering an extra empty quote line before the outer blockquote spacing.
473
560
  while (renderedQuoteLines.length > 0 && renderedQuoteLines[renderedQuoteLines.length - 1] === "") {
@@ -492,9 +579,9 @@ export class Markdown {
492
579
  }
493
580
  break;
494
581
  case "html":
495
- // Render HTML as plain text (escaped for terminal)
582
+ // Render HTML as plain text (escaped for terminal), without its comments
496
583
  if ("raw" in token && typeof token.raw === "string") {
497
- lines.push(this.applyDefaultStyle(token.raw.trim()));
584
+ lines.push(this.applyDefaultStyle(stripHtmlComments(token.raw).trim()));
498
585
  }
499
586
  break;
500
587
  case "space":
@@ -517,7 +604,15 @@ export class Markdown {
517
604
  const segments = text.split("\n");
518
605
  return segments.map((segment) => applyText(segment)).join("\n");
519
606
  };
520
- for (const token of tokens) {
607
+ // After a skipped HTML comment, drop the next text's leading space if the text before ended with one,
608
+ // so "a <!-- c --> b" renders as "a b".
609
+ let dropLeadingSpace = false;
610
+ for (let i = 0; i < tokens.length; i++) {
611
+ let token = tokens[i];
612
+ if (dropLeadingSpace && token.type === "text" && !token.tokens?.length && /^[ \t]/.test(token.text)) {
613
+ token = { ...token, text: token.text.replace(/^[ \t]+/, "") };
614
+ }
615
+ dropLeadingSpace = false;
521
616
  switch (token.type) {
522
617
  case "latex": {
523
618
  const latexToken = token;
@@ -588,8 +683,11 @@ export class Markdown {
588
683
  break;
589
684
  }
590
685
  case "html":
591
- // Render inline HTML as plain text
592
- if ("raw" in token && typeof token.raw === "string") {
686
+ // Render inline HTML as plain text; comments are hidden
687
+ if (token.raw.startsWith("<!--")) {
688
+ dropLeadingSpace = i === 0 || /[ \t]$/.test(tokens[i - 1].raw);
689
+ }
690
+ else {
593
691
  result += applyTextWithNewlines(token.raw);
594
692
  }
595
693
  break;