@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.
@@ -1,6 +1,6 @@
1
- import { getEditorKeybindings } from "@oh-my-pi/pi-tui/keybindings";
2
- import { type Component, CURSOR_MARKER, type Focusable } from "@oh-my-pi/pi-tui/tui";
3
- import { getSegmenter, isPunctuationChar, isWhitespaceChar, visibleWidth } from "@oh-my-pi/pi-tui/utils";
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((ch) => {
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
  });
@@ -1,4 +1,4 @@
1
- import type { TUI } from "@oh-my-pi/pi-tui/tui";
1
+ import type { TUI } from "../tui";
2
2
  import { Text } from "./text";
3
3
 
4
4
  /**
@@ -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
- 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) {
@@ -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((w) => {
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((w) => h.repeat(w));
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((c) => c.length));
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((w) => h.repeat(w));
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((c) => c.length));
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((w) => h.repeat(w));
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 "@oh-my-pi/pi-tui/keys";
2
- import type { SymbolTheme } from "@oh-my-pi/pi-tui/symbols";
3
- import type { Component } from "@oh-my-pi/pi-tui/tui";
4
- import { truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui/utils";
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((item) => item.value.toLowerCase().startsWith(filter.toLowerCase()));
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 "@oh-my-pi/pi-tui/keys";
2
- import type { Component } from "@oh-my-pi/pi-tui/tui";
3
- import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui/utils";
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((i) => i.id === id);
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((item) => visibleWidth(item.label))));
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(" 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
  }
@@ -1,4 +1,4 @@
1
- import type { Component } from "@oh-my-pi/pi-tui/tui";
1
+ import type { Component } from "../tui";
2
2
 
3
3
  /**
4
4
  * Spacer component that renders empty lines
@@ -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 { matchesKey } from "@oh-my-pi/pi-tui/keys";
13
- import type { Component } from "@oh-my-pi/pi-tui/tui";
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 {
@@ -1,5 +1,5 @@
1
- import type { Component } from "@oh-my-pi/pi-tui/tui";
2
- import { applyBackgroundToLine, wrapTextWithAnsi } from "@oh-my-pi/pi-tui/utils";
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
@@ -1,5 +1,5 @@
1
- import type { Component } from "@oh-my-pi/pi-tui/tui";
2
- import { truncateToWidth } from "@oh-my-pi/pi-tui/utils";
1
+ import type { Component } from "../tui";
2
+ import { truncateToWidth } from "../utils";
3
3
 
4
4
  /**
5
5
  * Text component that truncates to fit viewport width
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((t) => t.length > 0);
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((r) => r.item);
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";
@@ -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((key) => normalizeKeyId(key as KeyId)),
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((key) => normalizeKeyId(key as KeyId)),
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
+ }
@@ -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((p) => /^\d+$/.test(p))) {
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
- 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
  }