@oh-my-pi/pi-tui 8.0.16 → 8.1.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-tui",
3
- "version": "8.0.16",
3
+ "version": "8.1.0",
4
4
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -10,8 +10,7 @@
10
10
  },
11
11
  "files": [
12
12
  "src/**/*",
13
- "README.md",
14
- "tsconfig.json"
13
+ "README.md"
15
14
  ],
16
15
  "keywords": [
17
16
  "tui",
@@ -43,12 +42,5 @@
43
42
  "devDependencies": {
44
43
  "@xterm/headless": "^5.5.0",
45
44
  "@xterm/xterm": "^5.5.0"
46
- },
47
- "exports": {
48
- ".": {
49
- "types": "./src/index.ts",
50
- "import": "./src/index.ts"
51
- },
52
- "./*": "./src/*"
53
45
  }
54
46
  }
@@ -2,7 +2,13 @@ import type { AutocompleteProvider, CombinedAutocompleteProvider } from "@oh-my-
2
2
  import { matchesKey } from "@oh-my-pi/pi-tui/keys";
3
3
  import type { SymbolTheme } from "@oh-my-pi/pi-tui/symbols";
4
4
  import { type Component, CURSOR_MARKER, type Focusable } from "@oh-my-pi/pi-tui/tui";
5
- import { getSegmenter, isPunctuationChar, isWhitespaceChar, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui/utils";
5
+ import {
6
+ getSegmenter,
7
+ isPunctuationChar,
8
+ isWhitespaceChar,
9
+ truncateToWidth,
10
+ visibleWidth,
11
+ } from "@oh-my-pi/pi-tui/utils";
6
12
  import { SelectList, type SelectListTheme } from "./select-list";
7
13
 
8
14
  const segmenter = getSegmenter();
@@ -1,7 +1,9 @@
1
- import { marked, type Token } from "marked";
1
+ import type { MermaidImage } from "@oh-my-pi/pi-tui/mermaid";
2
2
  import type { SymbolTheme } from "@oh-my-pi/pi-tui/symbols";
3
+ import { encodeITerm2, encodeKitty, getCapabilities, getCellDimensions } from "@oh-my-pi/pi-tui/terminal-image";
3
4
  import type { Component } from "@oh-my-pi/pi-tui/tui";
4
5
  import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui/utils";
6
+ import { marked, type Token } from "marked";
5
7
 
6
8
  /**
7
9
  * Default text styling for markdown content.
@@ -42,6 +44,12 @@ export interface MarkdownTheme {
42
44
  strikethrough: (text: string) => string;
43
45
  underline: (text: string) => string;
44
46
  highlightCode?: (code: string, lang?: string) => string[];
47
+ /**
48
+ * Lookup a pre-rendered mermaid image by source hash.
49
+ * Hash is computed as `Bun.hash(source.trim()).toString(16)`.
50
+ * Return null to fall back to text rendering.
51
+ */
52
+ getMermaidImage?: (sourceHash: string) => MermaidImage | null;
45
53
  symbols: SymbolTheme;
46
54
  }
47
55
 
@@ -125,7 +133,12 @@ export class Markdown implements Component {
125
133
  // Wrap lines (NO padding, NO background yet)
126
134
  const wrappedLines: string[] = [];
127
135
  for (const line of renderedLines) {
128
- wrappedLines.push(...wrapTextWithAnsi(line, contentWidth));
136
+ // Skip wrapping for image protocol lines (would corrupt escape sequences)
137
+ if (this.containsImage(line)) {
138
+ wrappedLines.push(line);
139
+ } else {
140
+ wrappedLines.push(...wrapTextWithAnsi(line, contentWidth));
141
+ }
129
142
  }
130
143
 
131
144
  // Add margins and background to each wrapped line
@@ -135,6 +148,12 @@ export class Markdown implements Component {
135
148
  const contentLines: string[] = [];
136
149
 
137
150
  for (const line of wrappedLines) {
151
+ // Image lines must be output raw - no margins or background
152
+ if (this.containsImage(line)) {
153
+ contentLines.push(line);
154
+ continue;
155
+ }
156
+
138
157
  const lineWithMargins = leftMargin + line + rightMargin;
139
158
 
140
159
  if (bgFn) {
@@ -267,6 +286,24 @@ export class Markdown implements Component {
267
286
  }
268
287
 
269
288
  case "code": {
289
+ // Handle mermaid diagrams with image rendering when available
290
+ if (token.lang === "mermaid" && this.theme.getMermaidImage) {
291
+ const hash = Bun.hash(token.text.trim()).toString(16);
292
+ const image = this.theme.getMermaidImage(hash);
293
+ const caps = getCapabilities();
294
+
295
+ if (image && caps.images) {
296
+ const imageLines = this.renderMermaidImage(image, width);
297
+ if (imageLines) {
298
+ lines.push(...imageLines);
299
+ if (nextTokenType !== "space") {
300
+ lines.push("");
301
+ }
302
+ break;
303
+ }
304
+ }
305
+ }
306
+
270
307
  const codeIndent = " ".repeat(this.codeBlockIndent);
271
308
  lines.push(this.theme.codeBlockBorder(`\`\`\`${token.lang || ""}`));
272
309
  if (this.theme.highlightCode) {
@@ -658,4 +695,63 @@ export class Markdown implements Component {
658
695
  lines.push(""); // Add spacing after table
659
696
  return lines;
660
697
  }
698
+
699
+ private containsImage(line: string): boolean {
700
+ return line.includes("\x1b_G") || line.includes("\x1b]1337;File=");
701
+ }
702
+
703
+ /**
704
+ * Render a mermaid image using terminal graphics protocol.
705
+ * Returns array of lines (image placeholder rows) or null if rendering fails.
706
+ */
707
+ private renderMermaidImage(image: MermaidImage, availableWidth: number): string[] | null {
708
+ const caps = getCapabilities();
709
+ if (!caps.images) return null;
710
+
711
+ const cellDims = getCellDimensions();
712
+ const scale = 0.5; // Render at 50% of natural size
713
+
714
+ // Calculate natural size in cells (don't scale up, only down if needed)
715
+ const naturalColumns = Math.ceil((image.widthPx * scale) / cellDims.widthPx);
716
+ const naturalRows = Math.ceil((image.heightPx * scale) / cellDims.heightPx);
717
+
718
+ // Use natural size, but cap to available width
719
+ const columns = Math.min(naturalColumns, availableWidth);
720
+
721
+ // If we had to shrink width, calculate proportional height
722
+ let rows: number;
723
+ if (columns < naturalColumns) {
724
+ // Scaled down - recalculate height
725
+ const scale = columns / naturalColumns;
726
+ rows = Math.max(1, Math.ceil(naturalRows * scale));
727
+ } else {
728
+ // Natural size
729
+ rows = naturalRows;
730
+ }
731
+
732
+ let sequence: string;
733
+ if (caps.images === "kitty") {
734
+ sequence = encodeKitty(image.base64, { columns, rows });
735
+ } else if (caps.images === "iterm2") {
736
+ sequence = encodeITerm2(image.base64, {
737
+ width: columns,
738
+ height: "auto",
739
+ preserveAspectRatio: true,
740
+ });
741
+ } else {
742
+ return null;
743
+ }
744
+
745
+ // Reserve space with empty lines, then output image with cursor-up
746
+ // This ensures TUI accounts for image height in layout
747
+ const lines: string[] = [];
748
+ for (let i = 0; i < rows - 1; i++) {
749
+ lines.push("");
750
+ }
751
+ // Move cursor up to first row, then output image
752
+ const moveUp = rows > 1 ? `\x1b[${rows - 1}A` : "";
753
+ lines.push(moveUp + sequence);
754
+
755
+ return lines;
756
+ }
661
757
  }
@@ -131,7 +131,7 @@ export class SettingsList implements Component {
131
131
 
132
132
  // Add hint
133
133
  lines.push("");
134
- lines.push(this.theme.hint(" Enter/Space to change · Esc to cancel"));
134
+ lines.push(this.theme.hint("Enter/Space to change · Esc to cancel"));
135
135
 
136
136
  return lines;
137
137
  }
package/src/index.ts CHANGED
@@ -47,6 +47,14 @@ export {
47
47
  parseKittySequence,
48
48
  setKittyProtocolActive,
49
49
  } from "./keys";
50
+ // Mermaid diagram support
51
+ export {
52
+ extractMermaidBlocks,
53
+ type MermaidImage,
54
+ type MermaidRenderOptions,
55
+ prerenderMermaidBlocks,
56
+ renderMermaidToPng,
57
+ } from "./mermaid";
50
58
  // Input buffering for batch splitting
51
59
  export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from "./stdin-buffer";
52
60
  export type { BoxSymbols, SymbolTheme } from "./symbols";
package/src/mermaid.ts ADDED
@@ -0,0 +1,140 @@
1
+ import { mkdir, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { $ } from "bun";
5
+
6
+ export interface MermaidImage {
7
+ base64: string;
8
+ widthPx: number;
9
+ heightPx: number;
10
+ }
11
+
12
+ export interface MermaidRenderOptions {
13
+ theme?: "default" | "dark" | "forest" | "neutral";
14
+ backgroundColor?: string;
15
+ width?: number;
16
+ scale?: number;
17
+ }
18
+
19
+ /**
20
+ * Render mermaid diagram source to PNG.
21
+ *
22
+ * Uses `mmdc` (mermaid-cli) which must be installed and in PATH.
23
+ * Returns null if rendering fails or mmdc is unavailable.
24
+ */
25
+ export async function renderMermaidToPng(
26
+ source: string,
27
+ options: MermaidRenderOptions = {},
28
+ ): Promise<MermaidImage | null> {
29
+ const mmdc = Bun.which("mmdc");
30
+ if (!mmdc) {
31
+ return null;
32
+ }
33
+
34
+ const tmpDir = join(tmpdir(), `mermaid-${Date.now()}-${Math.random().toString(36).slice(2)}`);
35
+ const inputPath = join(tmpDir, "input.mmd");
36
+ const outputPath = join(tmpDir, "output.png");
37
+
38
+ try {
39
+ await mkdir(tmpDir, { recursive: true });
40
+ await Bun.write(inputPath, source);
41
+
42
+ const args: string[] = ["-i", inputPath, "-o", outputPath, "-q"];
43
+
44
+ if (options.theme) {
45
+ args.push("-t", options.theme);
46
+ }
47
+ if (options.backgroundColor) {
48
+ args.push("-b", options.backgroundColor);
49
+ }
50
+ if (options.width) {
51
+ args.push("-w", String(options.width));
52
+ }
53
+ if (options.scale) {
54
+ args.push("-s", String(options.scale));
55
+ }
56
+
57
+ const result = await $`${mmdc} ${args}`.quiet().nothrow();
58
+ if (result.exitCode !== 0) {
59
+ return null;
60
+ }
61
+
62
+ const outputFile = Bun.file(outputPath);
63
+ if (!(await outputFile.exists())) {
64
+ return null;
65
+ }
66
+
67
+ const buffer = Buffer.from(await outputFile.arrayBuffer());
68
+ const base64 = buffer.toString("base64");
69
+
70
+ const dims = parsePngDimensions(buffer);
71
+ if (!dims) {
72
+ return null;
73
+ }
74
+
75
+ return {
76
+ base64,
77
+ widthPx: dims.width,
78
+ heightPx: dims.height,
79
+ };
80
+ } catch {
81
+ return null;
82
+ } finally {
83
+ await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
84
+ }
85
+ }
86
+
87
+ function parsePngDimensions(buffer: Buffer): { width: number; height: number } | null {
88
+ if (buffer.length < 24) return null;
89
+ if (buffer[0] !== 0x89 || buffer[1] !== 0x50 || buffer[2] !== 0x4e || buffer[3] !== 0x47) {
90
+ return null;
91
+ }
92
+ return {
93
+ width: buffer.readUInt32BE(16),
94
+ height: buffer.readUInt32BE(20),
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Extract mermaid code blocks from markdown text.
100
+ * Returns array of { source, startIndex, endIndex } for each block.
101
+ */
102
+ export function extractMermaidBlocks(markdown: string): { source: string; hash: string }[] {
103
+ const blocks: { source: string; hash: string }[] = [];
104
+ const regex = /```mermaid\s*\n([\s\S]*?)```/g;
105
+
106
+ for (let match = regex.exec(markdown); match !== null; match = regex.exec(markdown)) {
107
+ const source = match[1].trim();
108
+ const hash = Bun.hash(source).toString(16);
109
+ blocks.push({ source, hash });
110
+ }
111
+
112
+ return blocks;
113
+ }
114
+
115
+ /**
116
+ * Pre-render all mermaid blocks in markdown text.
117
+ * Returns a cache map: hash → MermaidImage.
118
+ */
119
+ export async function prerenderMermaidBlocks(
120
+ markdown: string,
121
+ options: MermaidRenderOptions = {},
122
+ ): Promise<Map<string, MermaidImage>> {
123
+ const blocks = extractMermaidBlocks(markdown);
124
+ const cache = new Map<string, MermaidImage>();
125
+
126
+ const results = await Promise.all(
127
+ blocks.map(async ({ source, hash }) => {
128
+ const image = await renderMermaidToPng(source, options);
129
+ return { hash, image };
130
+ }),
131
+ );
132
+
133
+ for (const { hash, image } of results) {
134
+ if (image) {
135
+ cache.set(hash, image);
136
+ }
137
+ }
138
+
139
+ return cache;
140
+ }
package/src/terminal.ts CHANGED
@@ -13,20 +13,24 @@ let activeTerminal: ProcessTerminal | null = null;
13
13
  * Resets terminal state without requiring access to the ProcessTerminal instance
14
14
  */
15
15
  export function emergencyTerminalRestore(): void {
16
- const terminal = activeTerminal;
17
- if (terminal) {
18
- terminal.stop();
19
- terminal.showCursor();
20
- } else {
21
- // Blind restore if no instance tracked - covers edge cases
22
- process.stdout.write(
23
- "\x1b[?2004l" + // Disable bracketed paste
24
- "\x1b[<u" + // Pop kitty keyboard protocol
25
- "\x1b[?25h", // Show cursor
26
- );
27
- if (process.stdin.setRawMode) {
28
- process.stdin.setRawMode(false);
16
+ try {
17
+ const terminal = activeTerminal;
18
+ if (terminal) {
19
+ terminal.stop();
20
+ terminal.showCursor();
21
+ } else {
22
+ // Blind restore if no instance tracked - covers edge cases
23
+ process.stdout.write(
24
+ "\x1b[?2004l" + // Disable bracketed paste
25
+ "\x1b[<u" + // Pop kitty keyboard protocol
26
+ "\x1b[?25h", // Show cursor
27
+ );
28
+ if (process.stdin.setRawMode) {
29
+ process.stdin.setRawMode(false);
30
+ }
29
31
  }
32
+ } catch {
33
+ // Terminal may already be dead during crash cleanup - ignore errors
30
34
  }
31
35
  }
32
36
  export interface Terminal {
@@ -93,7 +97,7 @@ export class ProcessTerminal implements Terminal {
93
97
  process.stdin.resume();
94
98
 
95
99
  // Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~
96
- process.stdout.write("\x1b[?2004h");
100
+ this.safeWrite("\x1b[?2004h");
97
101
 
98
102
  // Set up resize handler immediately
99
103
  process.stdout.on("resize", this.resizeHandler);
@@ -137,7 +141,7 @@ export class ProcessTerminal implements Terminal {
137
141
  // Flag 1 = disambiguate escape codes
138
142
  // Flag 2 = report event types (press/repeat/release)
139
143
  // Flag 4 = report alternate keys
140
- process.stdout.write("\x1b[>7u");
144
+ this.safeWrite("\x1b[>7u");
141
145
  return; // Don't forward protocol response to TUI
142
146
  }
143
147
  }
@@ -172,7 +176,7 @@ export class ProcessTerminal implements Terminal {
172
176
  private queryAndEnableKittyProtocol(): void {
173
177
  this.setupStdinBuffer();
174
178
  process.stdin.on("data", this.stdinDataHandler!);
175
- process.stdout.write("\x1b[?u");
179
+ this.safeWrite("\x1b[?u");
176
180
  }
177
181
 
178
182
  stop(): void {
@@ -182,11 +186,11 @@ export class ProcessTerminal implements Terminal {
182
186
  }
183
187
 
184
188
  // Disable bracketed paste mode
185
- process.stdout.write("\x1b[?2004l");
189
+ this.safeWrite("\x1b[?2004l");
186
190
 
187
191
  // Disable Kitty keyboard protocol (pop the flags we pushed) - only if we enabled it
188
192
  if (this._kittyProtocolActive) {
189
- process.stdout.write("\x1b[<u");
193
+ this.safeWrite("\x1b[<u");
190
194
  this._kittyProtocolActive = false;
191
195
  setKittyProtocolActive(false);
192
196
  }
@@ -215,7 +219,19 @@ export class ProcessTerminal implements Terminal {
215
219
  }
216
220
 
217
221
  write(data: string): void {
218
- process.stdout.write(data);
222
+ this.safeWrite(data);
223
+ }
224
+
225
+ private safeWrite(data: string): void {
226
+ try {
227
+ process.stdout.write(data);
228
+ } catch (err) {
229
+ // EIO means terminal is dead - exit gracefully instead of crashing
230
+ if (err && typeof err === "object" && (err as { code?: string }).code === "EIO") {
231
+ process.exit(1);
232
+ }
233
+ throw err;
234
+ }
219
235
  }
220
236
 
221
237
  get columns(): number {
@@ -229,36 +245,36 @@ export class ProcessTerminal implements Terminal {
229
245
  moveBy(lines: number): void {
230
246
  if (lines > 0) {
231
247
  // Move down
232
- process.stdout.write(`\x1b[${lines}B`);
248
+ this.safeWrite(`\x1b[${lines}B`);
233
249
  } else if (lines < 0) {
234
250
  // Move up
235
- process.stdout.write(`\x1b[${-lines}A`);
251
+ this.safeWrite(`\x1b[${-lines}A`);
236
252
  }
237
253
  // lines === 0: no movement
238
254
  }
239
255
 
240
256
  hideCursor(): void {
241
- process.stdout.write("\x1b[?25l");
257
+ this.safeWrite("\x1b[?25l");
242
258
  }
243
259
 
244
260
  showCursor(): void {
245
- process.stdout.write("\x1b[?25h");
261
+ this.safeWrite("\x1b[?25h");
246
262
  }
247
263
 
248
264
  clearLine(): void {
249
- process.stdout.write("\x1b[K");
265
+ this.safeWrite("\x1b[K");
250
266
  }
251
267
 
252
268
  clearFromCursor(): void {
253
- process.stdout.write("\x1b[J");
269
+ this.safeWrite("\x1b[J");
254
270
  }
255
271
 
256
272
  clearScreen(): void {
257
- process.stdout.write("\x1b[2J\x1b[H"); // Clear screen and move to home (1,1)
273
+ this.safeWrite("\x1b[2J\x1b[H"); // Clear screen and move to home (1,1)
258
274
  }
259
275
 
260
276
  setTitle(title: string): void {
261
277
  // OSC 0;title BEL - set terminal window title
262
- process.stdout.write(`\x1b]0;${title}\x07`);
278
+ this.safeWrite(`\x1b]0;${title}\x07`);
263
279
  }
264
280
  }
package/tsconfig.json DELETED
@@ -1,42 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2024",
4
- "module": "ESNext",
5
- "lib": [
6
- "ES2024"
7
- ],
8
- "strict": true,
9
- "esModuleInterop": true,
10
- "skipLibCheck": true,
11
- "forceConsistentCasingInFileNames": true,
12
- "moduleResolution": "Bundler",
13
- "resolveJsonModule": true,
14
- "allowImportingTsExtensions": true,
15
- "experimentalDecorators": true,
16
- "emitDecoratorMetadata": true,
17
- "useDefineForClassFields": false,
18
- "types": [
19
- "bun",
20
- "node"
21
- ],
22
- "noEmit": true,
23
- "baseUrl": ".",
24
- "paths": {
25
- "@oh-my-pi/pi-tui": [
26
- "./src/index.ts"
27
- ],
28
- "@oh-my-pi/pi-tui/*": [
29
- "./src/*"
30
- ]
31
- }
32
- },
33
- "include": [
34
- "src/**/*.ts"
35
- ],
36
- "exclude": [
37
- "node_modules",
38
- "dist",
39
- "**/*.test.ts",
40
- "test/**"
41
- ]
42
- }