@oh-my-pi/pi-tui 18.0.8 → 18.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/types/components/box.d.ts +2 -0
  3. package/dist/types/components/cancellable-loader.d.ts +2 -0
  4. package/dist/types/components/composer/band.d.ts +2 -0
  5. package/dist/types/components/composer/index.d.ts +1 -0
  6. package/dist/types/components/composer/types.d.ts +3 -3
  7. package/dist/types/components/editor.d.ts +4 -0
  8. package/dist/types/components/image.d.ts +2 -0
  9. package/dist/types/components/input.d.ts +2 -0
  10. package/dist/types/components/loader.d.ts +8 -0
  11. package/dist/types/components/markdown.d.ts +2 -0
  12. package/dist/types/components/scroll-view.d.ts +2 -0
  13. package/dist/types/components/select-list.d.ts +2 -0
  14. package/dist/types/components/settings-list.d.ts +4 -0
  15. package/dist/types/components/spacer.d.ts +2 -0
  16. package/dist/types/components/tab-bar.d.ts +2 -0
  17. package/dist/types/components/text.d.ts +2 -0
  18. package/dist/types/components/truncated-text.d.ts +2 -0
  19. package/dist/types/debug-server.d.ts +27 -0
  20. package/dist/types/index.d.ts +1 -0
  21. package/dist/types/latex-to-unicode.d.ts +4 -4
  22. package/dist/types/tui.d.ts +23 -0
  23. package/package.json +3 -3
  24. package/src/components/box.ts +10 -0
  25. package/src/components/cancellable-loader.ts +7 -0
  26. package/src/components/composer/band.ts +42 -0
  27. package/src/components/composer/index.ts +1 -0
  28. package/src/components/composer/registry.ts +2 -0
  29. package/src/components/composer/types.ts +12 -3
  30. package/src/components/editor.ts +56 -10
  31. package/src/components/image.ts +11 -0
  32. package/src/components/input.ts +13 -0
  33. package/src/components/loader.ts +29 -2
  34. package/src/components/markdown.ts +39 -10
  35. package/src/components/scroll-view.ts +12 -0
  36. package/src/components/select-list.ts +13 -0
  37. package/src/components/settings-list.ts +19 -0
  38. package/src/components/spacer.ts +4 -0
  39. package/src/components/tab-bar.ts +11 -0
  40. package/src/components/text.ts +11 -0
  41. package/src/components/truncated-text.ts +15 -1
  42. package/src/debug-server.ts +422 -0
  43. package/src/index.ts +2 -0
  44. package/src/latex-to-unicode.ts +401 -172
  45. package/src/tui.ts +105 -12
  46. package/src/utils.ts +10 -1
@@ -1,9 +1,10 @@
1
1
  import type { TUI } from "../tui";
2
- import { getPaddingX, sliceByColumn, visibleWidth } from "../utils";
2
+ import { getPaddingX, padding, sliceByColumn, visibleWidth } from "../utils";
3
3
  import { Text } from "./text";
4
4
 
5
5
  const RENDER_INTERVAL_MS = 1000 / 30;
6
- const SPINNER_ADVANCE_MS = 80;
6
+ /** Milliseconds between spinner-frame advances; exported so time-derived spinners elsewhere tick at the Loader cadence. */
7
+ export const SPINNER_ADVANCE_MS = 80;
7
8
  const RENDER_BACKPRESSURE_MULTIPLIER = 9;
8
9
  const MAX_BACKPRESSURE_FRAME_COST_MS = 200;
9
10
 
@@ -28,6 +29,7 @@ export class Loader extends Text {
28
29
  #layout?: readonly { leading: string; content: string; trailing: string }[];
29
30
  #layoutFrames: readonly string[];
30
31
  #layoutFrame: string;
32
+ #trailer?: () => string | undefined;
31
33
 
32
34
  constructor(
33
35
  ui: TUI,
@@ -54,6 +56,16 @@ export class Loader extends Text {
54
56
  this.#layoutFrame = this.#layoutFrames[0];
55
57
  this.start();
56
58
  }
59
+ /** Return the current message and animation state for debug inspection. */
60
+ override debugState(): Record<string, unknown> {
61
+ const message = this.#resolveMessage();
62
+ return {
63
+ message: message.slice(0, 120),
64
+ messageLength: message.length,
65
+ running: this.#intervalId !== undefined,
66
+ frame: this.#currentFrame,
67
+ };
68
+ }
57
69
 
58
70
  override render(width: number): readonly string[] {
59
71
  const source = super.render(width);
@@ -92,6 +104,15 @@ export class Loader extends Text {
92
104
  lines.push(`${leading}${content ? this.messageColorFn(content) : ""}${trailing}`);
93
105
  }
94
106
  }
107
+ if (this.#trailer && lines.length > 1) {
108
+ const trailer = this.#trailer();
109
+ if (trailer) {
110
+ // Text pads rows to full width; drop that pad before docking right.
111
+ const body = lines[1].trimEnd();
112
+ const gap = width - visibleWidth(body) - visibleWidth(trailer);
113
+ if (gap >= 2) lines[1] = body + padding(gap) + trailer;
114
+ }
115
+ }
95
116
  return lines;
96
117
  }
97
118
 
@@ -114,6 +135,12 @@ export class Loader extends Text {
114
135
  dispose() {
115
136
  this.stop();
116
137
  }
138
+ /** Install a lazy right-docked suffix for the spinner row (e.g. a styled
139
+ * session title). Re-evaluated every paint; dropped when the row leaves
140
+ * less than a two-cell gap. */
141
+ setTrailer(trailer: (() => string | undefined) | undefined): void {
142
+ this.#trailer = trailer;
143
+ }
117
144
 
118
145
  setMessage(message: string) {
119
146
  if (message === this.message) {
@@ -1539,12 +1539,28 @@ function classifyHexColor(hex: string, strict: boolean): boolean {
1539
1539
  return true;
1540
1540
  }
1541
1541
 
1542
- /** ANSI-painted `glyph` for `#${hex}`, or "" when the color can't be encoded. */
1543
- function colorSwatch(hex: string, glyph: string): string {
1544
- const ansi = Bun.color(`#${hex}`, TERMINAL.trueColor ? "ansi-16m" : "ansi-256");
1545
- // Reset only the foreground (\x1b[39m) so an enclosing background/decoration
1546
- // applied later by the line renderer survives across the swatch.
1547
- return ansi ? `${ansi}${glyph}\x1b[39m ` : "";
1542
+ /** Black-or-white foreground legible on `#${hex}` used as a fill. VS Code's
1543
+ * rule (Color.isLighter): YIQ brightness (r·299 + g·587 + b·114)/1000 ≥ 128
1544
+ * → dark text, else light. */
1545
+ function swatchContrastFg(hex: string): string {
1546
+ const rgba = Bun.color(`#${hex}`, "{rgba}");
1547
+ if (!rgba) return "";
1548
+ const light = (rgba.r * 299 + rgba.g * 587 + rgba.b * 114) / 1000 >= 128;
1549
+ if (TERMINAL.trueColor) return light ? "\x1b[38;2;0;0;0m" : "\x1b[38;2;255;255;255m";
1550
+ return light ? "\x1b[38;5;16m" : "\x1b[38;5;231m";
1551
+ }
1552
+
1553
+ /** Chip glyph + `text` (the `#hex` mention) painted onto the color itself:
1554
+ * a fg-painted chip, then the token with the color as background and a
1555
+ * YIQ-contrast foreground (VS Code's color-picker rule). Returns "" when the
1556
+ * color can't be encoded. Foreground closes with \x1b[39m and background with
1557
+ * a standalone \x1b[49m so line-level backgrounds re-open themselves via
1558
+ * applyBackgroundToLine / Theme.bgFill. */
1559
+ function colorSwatch(hex: string, glyph: string, text: string): string {
1560
+ const fg = Bun.color(`#${hex}`, TERMINAL.trueColor ? "ansi-16m" : "ansi-256");
1561
+ if (!fg) return "";
1562
+ const bg = fg.replace("[38;", "[48;");
1563
+ return `${fg}${glyph}\x1b[39m ${bg}${swatchContrastFg(hex)}${text}\x1b[39m\x1b[49m`;
1548
1564
  }
1549
1565
 
1550
1566
  /**
@@ -1560,10 +1576,10 @@ function renderTextWithSwatches(text: string, applySegment: (t: string) => strin
1560
1576
  const match = HEX_COLOR_REGEX.exec(text);
1561
1577
  if (match === null) break;
1562
1578
  if (!classifyHexColor(match[1], true)) continue;
1563
- const swatch = colorSwatch(match[1], glyph);
1579
+ const swatch = colorSwatch(match[1], glyph, match[0]);
1564
1580
  if (!swatch) continue;
1565
1581
  if (match.index > last) result += applySegment(text.slice(last, match.index));
1566
- result += swatch + applySegment(match[0]);
1582
+ result += swatch;
1567
1583
  last = match.index + match[0].length;
1568
1584
  }
1569
1585
  if (last === 0) return applySegment(text);
@@ -1575,7 +1591,7 @@ function renderTextWithSwatches(text: string, applySegment: (t: string) => strin
1575
1591
  function codespanSwatch(code: string, glyph: string): string {
1576
1592
  const match = HEX_COLOR_EXACT_REGEX.exec(code.trim());
1577
1593
  if (!match || !classifyHexColor(match[1], false)) return "";
1578
- return colorSwatch(match[1], glyph);
1594
+ return colorSwatch(match[1], glyph, match[0]);
1579
1595
  }
1580
1596
 
1581
1597
  interface RenderSignature {
@@ -1757,6 +1773,18 @@ export class Markdown implements Component {
1757
1773
  this.#defaultTextStyle = defaultTextStyle;
1758
1774
  this.#codeBlockIndent = Math.max(0, Math.floor(codeBlockIndent));
1759
1775
  }
1776
+ /** Return bounded source text and layout state for debug inspection. */
1777
+ debugState(): Record<string, unknown> {
1778
+ return {
1779
+ textPreview: this.#text.slice(0, 120),
1780
+ textLength: this.#text.length,
1781
+ previewTruncated: this.#text.length > 120,
1782
+ paddingX: this.#paddingX,
1783
+ paddingY: this.#paddingY,
1784
+ codeBlockIndent: this.#codeBlockIndent,
1785
+ ignoreTight: this.#ignoreTight,
1786
+ };
1787
+ }
1760
1788
 
1761
1789
  setText(text: string): boolean {
1762
1790
  // Identical re-emit (throttled tick): fully normalized already.
@@ -3118,7 +3146,8 @@ export class Markdown implements Component {
3118
3146
 
3119
3147
  case "codespan": {
3120
3148
  markHtmlItemWhenContent(token.text);
3121
- result += codespanSwatch(token.text, swatchGlyph) + this.#theme.code(token.text) + stylePrefix;
3149
+ const painted = codespanSwatch(token.text, swatchGlyph);
3150
+ result += (painted || this.#theme.code(token.text)) + stylePrefix;
3122
3151
  break;
3123
3152
  }
3124
3153
 
@@ -79,6 +79,18 @@ export class ScrollView implements Component {
79
79
  this.#fastScrollLines = Math.max(1, Math.trunc(options.fastScrollLines ?? 5));
80
80
  this.#clampScrollOffset();
81
81
  }
82
+ /** Return viewport, content, and scroll position state for debug inspection. */
83
+ debugState(): Record<string, unknown> {
84
+ const rowCount = this.#totalRows ?? this.#lines.length;
85
+ return {
86
+ scrollOffset: this.#scrollOffset,
87
+ maxScrollOffset: this.getMaxScrollOffset(),
88
+ height: this.#height,
89
+ rowCount,
90
+ bufferedLineCount: this.#lines.length,
91
+ scrollbar: this.#scrollbar,
92
+ };
93
+ }
82
94
 
83
95
  setLines(lines: readonly string[]): void {
84
96
  this.#lines = [...lines];
@@ -116,6 +116,19 @@ export class SelectList implements Component, MouseRoutable {
116
116
  this.#maxVisible = Math.max(1, Math.trunc(maxVisible));
117
117
  this.#filteredItems = items;
118
118
  }
119
+ /** Return item, selection, and filter state for debug inspection. */
120
+ debugState(): Record<string, unknown> {
121
+ const selected = this.#filteredItems[this.#selectedIndex];
122
+ return {
123
+ itemCount: this.items.length,
124
+ filteredItemCount: this.#filteredItems.length,
125
+ selectedIndex: this.#filteredItems.length > 0 ? this.#selectedIndex : -1,
126
+ selectedItemId: selected?.value ?? null,
127
+ selectedItemLabel: selected?.label ?? null,
128
+ filterText: this.#filterQuery,
129
+ maxVisible: this.#maxVisible,
130
+ };
131
+ }
119
132
 
120
133
  /** Refit the visible row budget (hosts clamp the list to available height). */
121
134
  setMaxVisible(rows: number): void {
@@ -142,6 +142,25 @@ export class SettingsList implements Component {
142
142
  this.#selectedIndex = this.#firstSelectableIndex();
143
143
  this.#lastNotifiedSelectionId = this.getSelectedItem()?.id;
144
144
  }
145
+ /** Return item, selection, filter, and submenu state for debug inspection. */
146
+ debugState(): Record<string, unknown> {
147
+ const selected = this.getSelectedItem();
148
+ return {
149
+ itemCount: this.#items.length,
150
+ filteredItemCount: this.#filteredItems.length,
151
+ selectedIndex: this.#filteredItems.length > 0 ? this.#selectedIndex : -1,
152
+ selectedItemId: selected?.id ?? null,
153
+ selectedItemLabel: selected?.label ?? null,
154
+ filterText: this.#filterQuery,
155
+ sectionFocused: this.#sectionFocus,
156
+ submenuOpen: this.#submenuComponent !== null,
157
+ };
158
+ }
159
+
160
+ /** Expose the active submenu to the debug tree walker. */
161
+ get debugChildren(): readonly Component[] {
162
+ return this.#submenuComponent ? [this.#submenuComponent] : [];
163
+ }
145
164
 
146
165
  /** The currently selected item, or undefined when empty or on a heading. */
147
166
  getSelectedItem(): SettingItem | undefined {
@@ -10,6 +10,10 @@ export class Spacer implements Component {
10
10
  constructor(lines: number = 1) {
11
11
  this.#lines = lines;
12
12
  }
13
+ /** Return the spacer height for debug inspection. */
14
+ debugState(): Record<string, unknown> {
15
+ return { height: this.#lines };
16
+ }
13
17
 
14
18
  setLines(lines: number): void {
15
19
  if (lines === this.#lines) return;
@@ -74,6 +74,17 @@ export class TabBar implements Component {
74
74
  this.#theme = theme;
75
75
  this.#activeIndex = initialIndex;
76
76
  }
77
+ /** Return tab identities and active-tab state for debug inspection. */
78
+ debugState(): Record<string, unknown> {
79
+ const active = this.#tabs[this.#activeIndex];
80
+ return {
81
+ label: this.#label,
82
+ tabs: this.#tabs.map(tab => ({ id: tab.id, label: tab.label })),
83
+ activeIndex: this.#tabs.length > 0 ? this.#activeIndex : -1,
84
+ activeTabId: active?.id ?? null,
85
+ activeTabLabel: active?.label ?? null,
86
+ };
87
+ }
77
88
 
78
89
  /** Get the currently active tab */
79
90
  getActiveTab(): Tab {
@@ -46,6 +46,17 @@ export class Text implements Component {
46
46
  this.#paddingY = paddingY;
47
47
  this.#customBgFn = customBgFn;
48
48
  }
49
+ /** Return bounded text and layout state for debug inspection. */
50
+ debugState(): Record<string, unknown> {
51
+ return {
52
+ textPreview: this.#text.slice(0, 120),
53
+ textLength: this.#text.length,
54
+ previewTruncated: this.#text.length > 120,
55
+ paddingX: this.#paddingX,
56
+ paddingY: this.#paddingY,
57
+ ignoreTight: this.#ignoreTight,
58
+ };
59
+ }
49
60
 
50
61
  getText(): string {
51
62
  return this.#text;
@@ -1,5 +1,5 @@
1
1
  import type { Component } from "../tui";
2
- import { padding, truncateToWidth } from "../utils";
2
+ import { padding, truncateToWidth, visibleWidth } from "../utils";
3
3
 
4
4
  /**
5
5
  * Text component that truncates to fit viewport width
@@ -16,6 +16,20 @@ export class TruncatedText implements Component {
16
16
  this.#paddingX = paddingX;
17
17
  this.#paddingY = paddingY;
18
18
  }
19
+ /** Return bounded source text and the last-known truncation state. */
20
+ debugState(): Record<string, unknown> {
21
+ const newlineIndex = this.#text.indexOf("\n");
22
+ const firstLine = newlineIndex === -1 ? this.#text : this.#text.slice(0, newlineIndex);
23
+ const availableWidth = Math.max(1, this.#cachedWidth - this.#paddingX * 2);
24
+ return {
25
+ textPreview: this.#text.slice(0, 120),
26
+ textLength: this.#text.length,
27
+ previewTruncated: this.#text.length > 120,
28
+ truncated: newlineIndex !== -1 || (this.#cachedWidth >= 0 && visibleWidth(firstLine) > availableWidth),
29
+ paddingX: this.#paddingX,
30
+ paddingY: this.#paddingY,
31
+ };
32
+ }
19
33
 
20
34
  invalidate(): void {
21
35
  this.#cachedWidth = -1;