@oh-my-pi/pi-tui 8.0.20 → 8.2.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 +24 -18
- package/src/autocomplete.ts +86 -60
- package/src/components/box.ts +2 -2
- package/src/components/cancellable-loader.ts +1 -1
- package/src/components/editor.ts +57 -28
- package/src/components/image.ts +2 -2
- package/src/components/input.ts +4 -4
- package/src/components/loader.ts +1 -1
- package/src/components/markdown.ts +106 -10
- package/src/components/select-list.ts +5 -5
- package/src/components/settings-list.ts +6 -6
- package/src/components/spacer.ts +1 -1
- package/src/components/tab-bar.ts +3 -4
- package/src/components/text.ts +2 -2
- package/src/components/truncated-text.ts +2 -2
- package/src/fuzzy.ts +2 -2
- package/src/index.ts +8 -0
- package/src/keybindings.ts +2 -2
- package/src/mermaid.ts +139 -0
- package/src/stdin-buffer.ts +1 -2
- package/src/terminal.ts +43 -27
- package/src/tui.ts +5 -6
- package/src/utils.ts +1 -1
- package/tsconfig.json +0 -42
package/src/components/input.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { getEditorKeybindings } from "
|
|
2
|
-
import { type Component, CURSOR_MARKER, type Focusable } from "
|
|
3
|
-
import { getSegmenter, isPunctuationChar, isWhitespaceChar, visibleWidth } from "
|
|
1
|
+
import { getEditorKeybindings } from "../keybindings";
|
|
2
|
+
import { type Component, CURSOR_MARKER, type Focusable } from "../tui";
|
|
3
|
+
import { getSegmenter, isPunctuationChar, isWhitespaceChar, visibleWidth } from "../utils";
|
|
4
4
|
|
|
5
5
|
const segmenter = getSegmenter();
|
|
6
6
|
|
|
@@ -186,7 +186,7 @@ export class Input implements Component, Focusable {
|
|
|
186
186
|
|
|
187
187
|
// Regular character input - accept printable characters including Unicode,
|
|
188
188
|
// but reject control characters (C0: 0x00-0x1F, DEL: 0x7F, C1: 0x80-0x9F)
|
|
189
|
-
const hasControlChars = [...data].some(
|
|
189
|
+
const hasControlChars = [...data].some(ch => {
|
|
190
190
|
const code = ch.charCodeAt(0);
|
|
191
191
|
return code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f);
|
|
192
192
|
});
|
package/src/components/loader.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import type { SymbolTheme } from "@oh-my-pi/pi-tui/symbols";
|
|
2
|
-
import type { Component } from "@oh-my-pi/pi-tui/tui";
|
|
3
|
-
import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui/utils";
|
|
4
1
|
import { marked, type Token } from "marked";
|
|
2
|
+
import type { MermaidImage } from "../mermaid";
|
|
3
|
+
import type { SymbolTheme } from "../symbols";
|
|
4
|
+
import { encodeITerm2, encodeKitty, getCapabilities, getCellDimensions } from "../terminal-image";
|
|
5
|
+
import type { Component } from "../tui";
|
|
6
|
+
import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils";
|
|
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
|
-
|
|
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) {
|
|
@@ -591,7 +628,7 @@ export class Markdown implements Component {
|
|
|
591
628
|
} else {
|
|
592
629
|
// Distribute space proportionally based on natural widths
|
|
593
630
|
const totalNatural = naturalWidths.reduce((a, b) => a + b, 0);
|
|
594
|
-
columnWidths = naturalWidths.map(
|
|
631
|
+
columnWidths = naturalWidths.map(w => {
|
|
595
632
|
const proportion = w / totalNatural;
|
|
596
633
|
return Math.max(1, Math.floor(proportion * availableForCells));
|
|
597
634
|
});
|
|
@@ -611,7 +648,7 @@ export class Markdown implements Component {
|
|
|
611
648
|
const v = t.vertical;
|
|
612
649
|
|
|
613
650
|
// Render top border
|
|
614
|
-
const topBorderCells = columnWidths.map(
|
|
651
|
+
const topBorderCells = columnWidths.map(w => h.repeat(w));
|
|
615
652
|
lines.push(`${t.topLeft}${h}${topBorderCells.join(`${h}${t.teeDown}${h}`)}${h}${t.topRight}`);
|
|
616
653
|
|
|
617
654
|
// Render header with wrapping
|
|
@@ -619,7 +656,7 @@ export class Markdown implements Component {
|
|
|
619
656
|
const text = this.renderInlineTokens(cell.tokens || []);
|
|
620
657
|
return this.wrapCellText(text, columnWidths[i]);
|
|
621
658
|
});
|
|
622
|
-
const headerLineCount = Math.max(...headerCellLines.map(
|
|
659
|
+
const headerLineCount = Math.max(...headerCellLines.map(c => c.length));
|
|
623
660
|
|
|
624
661
|
for (let lineIdx = 0; lineIdx < headerLineCount; lineIdx++) {
|
|
625
662
|
const rowParts = headerCellLines.map((cellLines, colIdx) => {
|
|
@@ -631,7 +668,7 @@ export class Markdown implements Component {
|
|
|
631
668
|
}
|
|
632
669
|
|
|
633
670
|
// Render separator
|
|
634
|
-
const separatorCells = columnWidths.map(
|
|
671
|
+
const separatorCells = columnWidths.map(w => h.repeat(w));
|
|
635
672
|
lines.push(`${t.teeRight}${h}${separatorCells.join(`${h}${t.cross}${h}`)}${h}${t.teeLeft}`);
|
|
636
673
|
|
|
637
674
|
// Render rows with wrapping
|
|
@@ -640,7 +677,7 @@ export class Markdown implements Component {
|
|
|
640
677
|
const text = this.renderInlineTokens(cell.tokens || []);
|
|
641
678
|
return this.wrapCellText(text, columnWidths[i]);
|
|
642
679
|
});
|
|
643
|
-
const rowLineCount = Math.max(...rowCellLines.map(
|
|
680
|
+
const rowLineCount = Math.max(...rowCellLines.map(c => c.length));
|
|
644
681
|
|
|
645
682
|
for (let lineIdx = 0; lineIdx < rowLineCount; lineIdx++) {
|
|
646
683
|
const rowParts = rowCellLines.map((cellLines, colIdx) => {
|
|
@@ -652,10 +689,69 @@ export class Markdown implements Component {
|
|
|
652
689
|
}
|
|
653
690
|
|
|
654
691
|
// Render bottom border
|
|
655
|
-
const bottomBorderCells = columnWidths.map(
|
|
692
|
+
const bottomBorderCells = columnWidths.map(w => h.repeat(w));
|
|
656
693
|
lines.push(`${t.bottomLeft}${h}${bottomBorderCells.join(`${h}${t.teeUp}${h}`)}${h}${t.bottomRight}`);
|
|
657
694
|
|
|
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
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { matchesKey } from "
|
|
2
|
-
import type { SymbolTheme } from "
|
|
3
|
-
import type { Component } from "
|
|
4
|
-
import { truncateToWidth, visibleWidth } from "
|
|
1
|
+
import { matchesKey } from "../keys";
|
|
2
|
+
import type { SymbolTheme } from "../symbols";
|
|
3
|
+
import type { Component } from "../tui";
|
|
4
|
+
import { truncateToWidth, visibleWidth } from "../utils";
|
|
5
5
|
|
|
6
6
|
export interface SelectItem {
|
|
7
7
|
value: string;
|
|
@@ -37,7 +37,7 @@ export class SelectList implements Component {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
setFilter(filter: string): void {
|
|
40
|
-
this.filteredItems = this.items.filter(
|
|
40
|
+
this.filteredItems = this.items.filter(item => item.value.toLowerCase().startsWith(filter.toLowerCase()));
|
|
41
41
|
// Reset selection when filter changes
|
|
42
42
|
this.selectedIndex = 0;
|
|
43
43
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { matchesKey } from "
|
|
2
|
-
import type { Component } from "
|
|
3
|
-
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "
|
|
1
|
+
import { matchesKey } from "../keys";
|
|
2
|
+
import type { Component } from "../tui";
|
|
3
|
+
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils";
|
|
4
4
|
|
|
5
5
|
export interface SettingItem {
|
|
6
6
|
/** Unique identifier for this setting */
|
|
@@ -53,7 +53,7 @@ export class SettingsList implements Component {
|
|
|
53
53
|
|
|
54
54
|
/** Update an item's currentValue */
|
|
55
55
|
updateValue(id: string, newValue: string): void {
|
|
56
|
-
const item = this.items.find(
|
|
56
|
+
const item = this.items.find(i => i.id === id);
|
|
57
57
|
if (item) {
|
|
58
58
|
item.currentValue = newValue;
|
|
59
59
|
}
|
|
@@ -88,7 +88,7 @@ export class SettingsList implements Component {
|
|
|
88
88
|
const endIndex = Math.min(startIndex + this.maxVisible, this.items.length);
|
|
89
89
|
|
|
90
90
|
// Calculate max label width for alignment
|
|
91
|
-
const maxLabelWidth = Math.min(30, Math.max(...this.items.map(
|
|
91
|
+
const maxLabelWidth = Math.min(30, Math.max(...this.items.map(item => visibleWidth(item.label))));
|
|
92
92
|
|
|
93
93
|
// Render visible items
|
|
94
94
|
for (let i = startIndex; i < endIndex; i++) {
|
|
@@ -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("
|
|
134
|
+
lines.push(this.theme.hint("Enter/Space to change · Esc to cancel"));
|
|
135
135
|
|
|
136
136
|
return lines;
|
|
137
137
|
}
|
package/src/components/spacer.ts
CHANGED
|
@@ -8,10 +8,9 @@
|
|
|
8
8
|
* - Tab / Arrow Right: Next tab (wraps around)
|
|
9
9
|
* - Shift+Tab / Arrow Left: Previous tab (wraps around)
|
|
10
10
|
*/
|
|
11
|
-
|
|
12
|
-
import {
|
|
13
|
-
import
|
|
14
|
-
import { wrapTextWithAnsi } from "@oh-my-pi/pi-tui/utils";
|
|
11
|
+
import { matchesKey } from "../keys";
|
|
12
|
+
import type { Component } from "../tui";
|
|
13
|
+
import { wrapTextWithAnsi } from "../utils";
|
|
15
14
|
|
|
16
15
|
/** Tab definition */
|
|
17
16
|
export interface Tab {
|
package/src/components/text.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { Component } from "
|
|
2
|
-
import { applyBackgroundToLine, wrapTextWithAnsi } from "
|
|
1
|
+
import type { Component } from "../tui";
|
|
2
|
+
import { applyBackgroundToLine, wrapTextWithAnsi } from "../utils";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Text component - displays multi-line text with word wrapping
|
package/src/fuzzy.ts
CHANGED
|
@@ -110,7 +110,7 @@ export function fuzzyFilter<T>(items: T[], query: string, getText: (item: T) =>
|
|
|
110
110
|
const tokens = query
|
|
111
111
|
.trim()
|
|
112
112
|
.split(/\s+/)
|
|
113
|
-
.filter(
|
|
113
|
+
.filter(t => t.length > 0);
|
|
114
114
|
|
|
115
115
|
if (tokens.length === 0) {
|
|
116
116
|
return items;
|
|
@@ -139,5 +139,5 @@ export function fuzzyFilter<T>(items: T[], query: string, getText: (item: T) =>
|
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
results.sort((a, b) => a.totalScore - b.totalScore);
|
|
142
|
-
return results.map(
|
|
142
|
+
return results.map(r => r.item);
|
|
143
143
|
}
|
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/keybindings.ts
CHANGED
|
@@ -123,7 +123,7 @@ export class EditorKeybindingsManager {
|
|
|
123
123
|
const keyArray = Array.isArray(keys) ? keys : [keys];
|
|
124
124
|
this.actionToKeys.set(
|
|
125
125
|
action as EditorAction,
|
|
126
|
-
keyArray.map(
|
|
126
|
+
keyArray.map(key => normalizeKeyId(key as KeyId)),
|
|
127
127
|
);
|
|
128
128
|
}
|
|
129
129
|
|
|
@@ -133,7 +133,7 @@ export class EditorKeybindingsManager {
|
|
|
133
133
|
const keyArray = Array.isArray(keys) ? keys : [keys];
|
|
134
134
|
this.actionToKeys.set(
|
|
135
135
|
action as EditorAction,
|
|
136
|
-
keyArray.map(
|
|
136
|
+
keyArray.map(key => normalizeKeyId(key as KeyId)),
|
|
137
137
|
);
|
|
138
138
|
}
|
|
139
139
|
}
|
package/src/mermaid.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path 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 = path.join(os.tmpdir(), `mermaid-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
35
|
+
const inputPath = path.join(tmpDir, "input.mmd");
|
|
36
|
+
const outputPath = path.join(tmpDir, "output.png");
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
await Bun.write(inputPath, source);
|
|
40
|
+
|
|
41
|
+
const args: string[] = ["-i", inputPath, "-o", outputPath, "-q"];
|
|
42
|
+
|
|
43
|
+
if (options.theme) {
|
|
44
|
+
args.push("-t", options.theme);
|
|
45
|
+
}
|
|
46
|
+
if (options.backgroundColor) {
|
|
47
|
+
args.push("-b", options.backgroundColor);
|
|
48
|
+
}
|
|
49
|
+
if (options.width) {
|
|
50
|
+
args.push("-w", String(options.width));
|
|
51
|
+
}
|
|
52
|
+
if (options.scale) {
|
|
53
|
+
args.push("-s", String(options.scale));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const result = await $`${mmdc} ${args}`.quiet().nothrow();
|
|
57
|
+
if (result.exitCode !== 0) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const outputFile = Bun.file(outputPath);
|
|
62
|
+
if (!(await outputFile.exists())) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const buffer = Buffer.from(await outputFile.bytes());
|
|
67
|
+
const base64 = buffer.toString("base64");
|
|
68
|
+
|
|
69
|
+
const dims = parsePngDimensions(buffer);
|
|
70
|
+
if (!dims) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
base64,
|
|
76
|
+
widthPx: dims.width,
|
|
77
|
+
heightPx: dims.height,
|
|
78
|
+
};
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
} finally {
|
|
82
|
+
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parsePngDimensions(buffer: Buffer): { width: number; height: number } | null {
|
|
87
|
+
if (buffer.length < 24) return null;
|
|
88
|
+
if (buffer[0] !== 0x89 || buffer[1] !== 0x50 || buffer[2] !== 0x4e || buffer[3] !== 0x47) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
width: buffer.readUInt32BE(16),
|
|
93
|
+
height: buffer.readUInt32BE(20),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Extract mermaid code blocks from markdown text.
|
|
99
|
+
* Returns array of { source, startIndex, endIndex } for each block.
|
|
100
|
+
*/
|
|
101
|
+
export function extractMermaidBlocks(markdown: string): { source: string; hash: string }[] {
|
|
102
|
+
const blocks: { source: string; hash: string }[] = [];
|
|
103
|
+
const regex = /```mermaid\s*\n([\s\S]*?)```/g;
|
|
104
|
+
|
|
105
|
+
for (let match = regex.exec(markdown); match !== null; match = regex.exec(markdown)) {
|
|
106
|
+
const source = match[1].trim();
|
|
107
|
+
const hash = Bun.hash(source).toString(16);
|
|
108
|
+
blocks.push({ source, hash });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return blocks;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Pre-render all mermaid blocks in markdown text.
|
|
116
|
+
* Returns a cache map: hash → MermaidImage.
|
|
117
|
+
*/
|
|
118
|
+
export async function prerenderMermaidBlocks(
|
|
119
|
+
markdown: string,
|
|
120
|
+
options: MermaidRenderOptions = {},
|
|
121
|
+
): Promise<Map<string, MermaidImage>> {
|
|
122
|
+
const blocks = extractMermaidBlocks(markdown);
|
|
123
|
+
const cache = new Map<string, MermaidImage>();
|
|
124
|
+
|
|
125
|
+
const results = await Promise.all(
|
|
126
|
+
blocks.map(async ({ source, hash }) => {
|
|
127
|
+
const image = await renderMermaidToPng(source, options);
|
|
128
|
+
return { hash, image };
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
for (const { hash, image } of results) {
|
|
133
|
+
if (image) {
|
|
134
|
+
cache.set(hash, image);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return cache;
|
|
139
|
+
}
|
package/src/stdin-buffer.ts
CHANGED
|
@@ -16,7 +16,6 @@
|
|
|
16
16
|
* Based on code from OpenTUI (https://github.com/anomalyco/opentui)
|
|
17
17
|
* MIT License - Copyright (c) 2025 opentui
|
|
18
18
|
*/
|
|
19
|
-
|
|
20
19
|
import { EventEmitter } from "events";
|
|
21
20
|
|
|
22
21
|
const ESC = "\x1b";
|
|
@@ -111,7 +110,7 @@ function isCompleteCsiSequence(data: string): "complete" | "incomplete" {
|
|
|
111
110
|
if (lastChar === "M" || lastChar === "m") {
|
|
112
111
|
// Check if we have the right structure
|
|
113
112
|
const parts = payload.slice(1, -1).split(";");
|
|
114
|
-
if (parts.length === 3 && parts.every(
|
|
113
|
+
if (parts.length === 3 && parts.every(p => /^\d+$/.test(p))) {
|
|
115
114
|
return "complete";
|
|
116
115
|
}
|
|
117
116
|
}
|
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
|
-
|
|
17
|
-
|
|
18
|
-
terminal
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
"\x1b[
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
process.stdin.setRawMode
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
248
|
+
this.safeWrite(`\x1b[${lines}B`);
|
|
233
249
|
} else if (lines < 0) {
|
|
234
250
|
// Move up
|
|
235
|
-
|
|
251
|
+
this.safeWrite(`\x1b[${-lines}A`);
|
|
236
252
|
}
|
|
237
253
|
// lines === 0: no movement
|
|
238
254
|
}
|
|
239
255
|
|
|
240
256
|
hideCursor(): void {
|
|
241
|
-
|
|
257
|
+
this.safeWrite("\x1b[?25l");
|
|
242
258
|
}
|
|
243
259
|
|
|
244
260
|
showCursor(): void {
|
|
245
|
-
|
|
261
|
+
this.safeWrite("\x1b[?25h");
|
|
246
262
|
}
|
|
247
263
|
|
|
248
264
|
clearLine(): void {
|
|
249
|
-
|
|
265
|
+
this.safeWrite("\x1b[K");
|
|
250
266
|
}
|
|
251
267
|
|
|
252
268
|
clearFromCursor(): void {
|
|
253
|
-
|
|
269
|
+
this.safeWrite("\x1b[J");
|
|
254
270
|
}
|
|
255
271
|
|
|
256
272
|
clearScreen(): void {
|
|
257
|
-
|
|
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
|
-
|
|
278
|
+
this.safeWrite(`\x1b]0;${title}\x07`);
|
|
263
279
|
}
|
|
264
280
|
}
|