@prometheus-ai/tui 0.5.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +704 -0
  3. package/dist/types/autocomplete.d.ts +76 -0
  4. package/dist/types/bracketed-paste.d.ts +26 -0
  5. package/dist/types/components/box.d.ts +17 -0
  6. package/dist/types/components/cancellable-loader.d.ts +21 -0
  7. package/dist/types/components/editor.d.ts +105 -0
  8. package/dist/types/components/image.d.ts +84 -0
  9. package/dist/types/components/input.d.ts +18 -0
  10. package/dist/types/components/loader.d.ts +13 -0
  11. package/dist/types/components/markdown.d.ts +61 -0
  12. package/dist/types/components/scroll-view.d.ts +40 -0
  13. package/dist/types/components/select-list.d.ts +48 -0
  14. package/dist/types/components/settings-list.d.ts +41 -0
  15. package/dist/types/components/spacer.d.ts +11 -0
  16. package/dist/types/components/tab-bar.d.ts +56 -0
  17. package/dist/types/components/text.d.ts +13 -0
  18. package/dist/types/components/truncated-text.d.ts +10 -0
  19. package/dist/types/deccara.d.ts +49 -0
  20. package/dist/types/editor-component.d.ts +36 -0
  21. package/dist/types/fuzzy.d.ts +15 -0
  22. package/dist/types/index.d.ts +28 -0
  23. package/dist/types/keybindings.d.ts +189 -0
  24. package/dist/types/keys.d.ts +208 -0
  25. package/dist/types/kill-ring.d.ts +27 -0
  26. package/dist/types/kitty-graphics.d.ts +94 -0
  27. package/dist/types/stdin-buffer.d.ts +43 -0
  28. package/dist/types/symbols.d.ts +25 -0
  29. package/dist/types/terminal-capabilities.d.ts +196 -0
  30. package/dist/types/terminal.d.ts +103 -0
  31. package/dist/types/ttyid.d.ts +9 -0
  32. package/dist/types/tui.d.ts +275 -0
  33. package/dist/types/utils.d.ts +89 -0
  34. package/package.json +73 -0
  35. package/src/autocomplete.ts +871 -0
  36. package/src/bracketed-paste.ts +47 -0
  37. package/src/components/box.ts +156 -0
  38. package/src/components/cancellable-loader.ts +40 -0
  39. package/src/components/editor.ts +2695 -0
  40. package/src/components/image.ts +318 -0
  41. package/src/components/input.ts +459 -0
  42. package/src/components/loader.ts +86 -0
  43. package/src/components/markdown.ts +1189 -0
  44. package/src/components/scroll-view.ts +166 -0
  45. package/src/components/select-list.ts +331 -0
  46. package/src/components/settings-list.ts +212 -0
  47. package/src/components/spacer.ts +28 -0
  48. package/src/components/tab-bar.ts +175 -0
  49. package/src/components/text.ts +110 -0
  50. package/src/components/truncated-text.ts +61 -0
  51. package/src/deccara.ts +314 -0
  52. package/src/editor-component.ts +71 -0
  53. package/src/fuzzy.ts +143 -0
  54. package/src/index.ts +44 -0
  55. package/src/keybindings.ts +279 -0
  56. package/src/keys.ts +537 -0
  57. package/src/kill-ring.ts +46 -0
  58. package/src/kitty-graphics.ts +270 -0
  59. package/src/stdin-buffer.ts +423 -0
  60. package/src/symbols.ts +26 -0
  61. package/src/terminal-capabilities.ts +1009 -0
  62. package/src/terminal.ts +1114 -0
  63. package/src/ttyid.ts +70 -0
  64. package/src/tui.ts +2988 -0
  65. package/src/utils.ts +452 -0
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Kitty graphics: Unicode placeholder placement (`U=1` + U+10EEEE) and temp-file
3
+ * (`t=t`) image transmission, with runtime feature state and env overrides.
4
+ *
5
+ * Unicode placeholders let a transmitted image be displayed by writing ordinary
6
+ * text cells — the placeholder char U+10EEEE plus row/column combining
7
+ * diacritics — instead of a cursor-positioned `a=p` direct placement. The image
8
+ * then participates in the normal text grid, so it survives horizontal slicing,
9
+ * reflow and overlapping draws (each cell names its own row+column, so a sliced
10
+ * row still maps to the correct sub-region). See kitty
11
+ * `docs/graphics-protocol.rst` "Unicode placeholders for relative placements".
12
+ *
13
+ * This module is intentionally free of `./terminal-capabilities` imports so the
14
+ * dependency stays one-way (capabilities → kitty-graphics) and no import cycle
15
+ * forms. Protocol gating (`imageProtocol === Kitty`) lives in the caller.
16
+ */
17
+ import * as fs from "node:fs";
18
+ import * as os from "node:os";
19
+ import * as path from "node:path";
20
+ import { $env, logger } from "@prometheus-ai/utils";
21
+
22
+ /** Kitty Unicode placeholder base character (U+10EEEE, Plane 16 PUA). */
23
+ export const KITTY_PLACEHOLDER = "\u{10eeee}";
24
+
25
+ /**
26
+ * Row/column diacritics (Unicode combining class 230, no decomposition) used to
27
+ * name a placeholder cell's row and column. Index `i` → codepoint. Derived from
28
+ * kitty `gen/rowcolumn-diacritics.txt` (Unicode 6.0.0 NSM set). 297 entries, so
29
+ * a single image can address up to 297 rows/columns without ID-high-byte tricks.
30
+ */
31
+ const ROWCOLUMN_DIACRITICS: readonly number[] = [
32
+ 0x305, 0x30d, 0x30e, 0x310, 0x312, 0x33d, 0x33e, 0x33f, 0x346, 0x34a, 0x34b, 0x34c, 0x350, 0x351, 0x352, 0x357,
33
+ 0x35b, 0x363, 0x364, 0x365, 0x366, 0x367, 0x368, 0x369, 0x36a, 0x36b, 0x36c, 0x36d, 0x36e, 0x36f, 0x483, 0x484,
34
+ 0x485, 0x486, 0x487, 0x592, 0x593, 0x594, 0x595, 0x597, 0x598, 0x599, 0x59c, 0x59d, 0x59e, 0x59f, 0x5a0, 0x5a1,
35
+ 0x5a8, 0x5a9, 0x5ab, 0x5ac, 0x5af, 0x5c4, 0x610, 0x611, 0x612, 0x613, 0x614, 0x615, 0x616, 0x617, 0x657, 0x658,
36
+ 0x659, 0x65a, 0x65b, 0x65d, 0x65e, 0x6d6, 0x6d7, 0x6d8, 0x6d9, 0x6da, 0x6db, 0x6dc, 0x6df, 0x6e0, 0x6e1, 0x6e2,
37
+ 0x6e4, 0x6e7, 0x6e8, 0x6eb, 0x6ec, 0x730, 0x732, 0x733, 0x735, 0x736, 0x73a, 0x73d, 0x73f, 0x740, 0x741, 0x743,
38
+ 0x745, 0x747, 0x749, 0x74a, 0x7eb, 0x7ec, 0x7ed, 0x7ee, 0x7ef, 0x7f0, 0x7f1, 0x7f3, 0x816, 0x817, 0x818, 0x819,
39
+ 0x81b, 0x81c, 0x81d, 0x81e, 0x81f, 0x820, 0x821, 0x822, 0x823, 0x825, 0x826, 0x827, 0x829, 0x82a, 0x82b, 0x82c,
40
+ 0x82d, 0x951, 0x953, 0x954, 0xf82, 0xf83, 0xf86, 0xf87, 0x135d, 0x135e, 0x135f, 0x17dd, 0x193a, 0x1a17, 0x1a75,
41
+ 0x1a76, 0x1a77, 0x1a78, 0x1a79, 0x1a7a, 0x1a7b, 0x1a7c, 0x1b6b, 0x1b6d, 0x1b6e, 0x1b6f, 0x1b70, 0x1b71, 0x1b72,
42
+ 0x1b73, 0x1cd0, 0x1cd1, 0x1cd2, 0x1cda, 0x1cdb, 0x1ce0, 0x1dc0, 0x1dc1, 0x1dc3, 0x1dc4, 0x1dc5, 0x1dc6, 0x1dc7,
43
+ 0x1dc8, 0x1dc9, 0x1dcb, 0x1dcc, 0x1dd1, 0x1dd2, 0x1dd3, 0x1dd4, 0x1dd5, 0x1dd6, 0x1dd7, 0x1dd8, 0x1dd9, 0x1dda,
44
+ 0x1ddb, 0x1ddc, 0x1ddd, 0x1dde, 0x1ddf, 0x1de0, 0x1de1, 0x1de2, 0x1de3, 0x1de4, 0x1de5, 0x1de6, 0x1dfe, 0x20d0,
45
+ 0x20d1, 0x20d4, 0x20d5, 0x20d6, 0x20d7, 0x20db, 0x20dc, 0x20e1, 0x20e7, 0x20e9, 0x20f0, 0x2cef, 0x2cf0, 0x2cf1,
46
+ 0x2de0, 0x2de1, 0x2de2, 0x2de3, 0x2de4, 0x2de5, 0x2de6, 0x2de7, 0x2de8, 0x2de9, 0x2dea, 0x2deb, 0x2dec, 0x2ded,
47
+ 0x2dee, 0x2def, 0x2df0, 0x2df1, 0x2df2, 0x2df3, 0x2df4, 0x2df5, 0x2df6, 0x2df7, 0x2df8, 0x2df9, 0x2dfa, 0x2dfb,
48
+ 0x2dfc, 0x2dfd, 0x2dfe, 0x2dff, 0xa66f, 0xa67c, 0xa67d, 0xa6f0, 0xa6f1, 0xa8e0, 0xa8e1, 0xa8e2, 0xa8e3, 0xa8e4,
49
+ 0xa8e5, 0xa8e6, 0xa8e7, 0xa8e8, 0xa8e9, 0xa8ea, 0xa8eb, 0xa8ec, 0xa8ed, 0xa8ee, 0xa8ef, 0xa8f0, 0xa8f1, 0xaab0,
50
+ 0xaab2, 0xaab3, 0xaab7, 0xaab8, 0xaabe, 0xaabf, 0xaac1, 0xfe20, 0xfe21, 0xfe22, 0xfe23, 0xfe24, 0xfe25, 0xfe26,
51
+ 0x10a0f, 0x10a38, 0x1d185, 0x1d186, 0x1d187, 0x1d188, 0x1d189, 0x1d1aa, 0x1d1ab, 0x1d1ac, 0x1d1ad, 0x1d242, 0x1d243,
52
+ 0x1d244,
53
+ ];
54
+
55
+ /** Largest row/column index expressible with the diacritic table (one cell each). */
56
+ export const KITTY_PLACEHOLDER_MAX_CELLS = ROWCOLUMN_DIACRITICS.length;
57
+
58
+ /** A minimal opaque 1x1 PNG (base64) used for the temp-file support probe. */
59
+ const PROBE_PNG_BASE64 =
60
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
61
+
62
+ export type KittyTransmissionMedium = "direct" | "temp-file";
63
+
64
+ export interface KittyGraphicsFeatures {
65
+ /** Display images via Unicode placeholders instead of direct `a=p` placement. */
66
+ unicodePlaceholders: boolean;
67
+ /** How image data reaches the terminal: in-band base64 or a temp file. */
68
+ transmissionMedium: KittyTransmissionMedium;
69
+ }
70
+
71
+ /** Explicit transmission medium from `PROMETHEUS_KITTY_IMAGE_TRANSMISSION`, else "auto". */
72
+ function transmissionOverride(): KittyTransmissionMedium | "auto" {
73
+ const raw = $env.PROMETHEUS_KITTY_IMAGE_TRANSMISSION?.trim().toLowerCase();
74
+ if (raw === "temp-file") return "temp-file";
75
+ if (raw === "direct") return "direct";
76
+ return "auto";
77
+ }
78
+
79
+ /**
80
+ * Whether the detected terminal renders Kitty Unicode placeholders (`U=1` +
81
+ * U+10EEEE with row/column diacritics).
82
+ *
83
+ * Only `kitty` (the protocol's origin) and `ghostty` ship a working
84
+ * implementation; WezTerm advertises Kitty graphics but treats placeholder
85
+ * cells as literal PUA glyphs (see wezterm/wezterm#986, "placeholder support"
86
+ * still unchecked), and the tmux/screen fallback can land on any outer
87
+ * terminal. Enabling placeholders on those paths emits a `columns × rows`
88
+ * grid of U+10EEEE per image per frame; the cells render as boxed fallback
89
+ * glyphs and re-emit on every repaint, which is exactly the
90
+ * "stuck/laggy scrolling + ASCII artifact" symptom reported in #1877.
91
+ *
92
+ * `PROMETHEUS_NO_KITTY_PLACEHOLDERS=1` forces off (e.g. for tmux passthrough to a
93
+ * non-supporting outer terminal); `PROMETHEUS_KITTY_PLACEHOLDERS=1` forces on (e.g.
94
+ * for a wezterm nightly that has merged placeholder support).
95
+ */
96
+ export function detectKittyUnicodePlaceholdersSupport(terminalId: string, env: NodeJS.ProcessEnv = Bun.env): boolean {
97
+ const offRaw = env.PROMETHEUS_NO_KITTY_PLACEHOLDERS?.trim().toLowerCase();
98
+ if (offRaw === "1" || offRaw === "true" || offRaw === "on" || offRaw === "yes" || offRaw === "y") return false;
99
+ const force = env.PROMETHEUS_KITTY_PLACEHOLDERS?.trim().toLowerCase();
100
+ if (force === "1" || force === "true" || force === "on" || force === "yes" || force === "y") return true;
101
+ if (force === "0" || force === "false" || force === "off" || force === "no" || force === "n") return false;
102
+ return terminalId === "kitty" || terminalId === "ghostty";
103
+ }
104
+
105
+ let features: KittyGraphicsFeatures = {
106
+ // Off until `terminal-capabilities` seeds it from the detected terminal id —
107
+ // the default-on path corrupts wezterm and tmux-passthrough sessions.
108
+ unicodePlaceholders: false,
109
+ // Start direct; a successful probe (or explicit `temp-file` override) promotes.
110
+ transmissionMedium: transmissionOverride() === "temp-file" ? "temp-file" : "direct",
111
+ };
112
+
113
+ export function getKittyGraphics(): Readonly<KittyGraphicsFeatures> {
114
+ return features;
115
+ }
116
+
117
+ export function setKittyGraphics(partial: Partial<KittyGraphicsFeatures>): void {
118
+ features = { ...features, ...partial };
119
+ }
120
+
121
+ /**
122
+ * Whether temp-file transmission may be promoted at runtime: forced via env,
123
+ * disabled via env, otherwise auto (local sessions only — a temp file written
124
+ * locally is not readable by a terminal on the far side of an SSH link).
125
+ */
126
+ export function kittyTempFileAllowed(): boolean {
127
+ const override = transmissionOverride();
128
+ if (override === "temp-file") return true;
129
+ if (override === "direct") return false;
130
+ // Auto: local sessions only — a temp file written here is not on the SSH peer.
131
+ return !$env.SSH_CONNECTION && !$env.SSH_CLIENT && !$env.SSH_TTY;
132
+ }
133
+
134
+ /** Whether a `columns`×`rows` placeholder grid fits within the diacritic table. */
135
+ export function kittyPlaceholdersFit(columns: number, rows: number): boolean {
136
+ return columns >= 1 && rows >= 1 && columns <= KITTY_PLACEHOLDER_MAX_CELLS && rows <= KITTY_PLACEHOLDER_MAX_CELLS;
137
+ }
138
+
139
+ /** True when the base64 payload is a PNG (kitty `f=100` / temp-file path only). */
140
+ export function isPngBase64(base64Data: string): boolean {
141
+ return base64Data.startsWith("iVBORw0KGgo");
142
+ }
143
+
144
+ function diacritic(index: number): string {
145
+ const cp = ROWCOLUMN_DIACRITICS[index];
146
+ return cp === undefined ? "" : String.fromCodePoint(cp);
147
+ }
148
+
149
+ /**
150
+ * Virtual placement APC (`a=p,U=1`): tells the terminal that placeholder cells
151
+ * carrying image id `i` should display the transmitted image, scaled to fit the
152
+ * `c`×`r` cell box. Re-emitting with a stable `placementId` replaces in place.
153
+ */
154
+ export function encodeKittyVirtualPlacement(opts: {
155
+ imageId: number;
156
+ placementId?: number;
157
+ columns: number;
158
+ rows: number;
159
+ }): string {
160
+ const params = ["a=p", "U=1", "q=2", `i=${opts.imageId}`];
161
+ if (opts.placementId) params.push(`p=${opts.placementId}`);
162
+ params.push(`c=${opts.columns}`, `r=${opts.rows}`);
163
+ return `\x1b_G${params.join(",")}\x1b\\`;
164
+ }
165
+
166
+ /**
167
+ * Build the placeholder cell grid as one string per row. The image id is carried
168
+ * in each row's foreground color and the placement id (if any) in its underline
169
+ * color; every cell names its explicit row+column diacritic (robust to slicing,
170
+ * unlike left-inheritance). Returns exactly `rows` strings.
171
+ */
172
+ export function encodeKittyPlaceholderGrid(opts: {
173
+ imageId: number;
174
+ placementId?: number;
175
+ columns: number;
176
+ rows: number;
177
+ }): string[] {
178
+ const fg = `\x1b[38;2;${(opts.imageId >> 16) & 0xff};${(opts.imageId >> 8) & 0xff};${opts.imageId & 0xff}m`;
179
+ const underline = opts.placementId
180
+ ? `\x1b[58:2::${(opts.placementId >> 16) & 0xff}:${(opts.placementId >> 8) & 0xff}:${opts.placementId & 0xff}m`
181
+ : "";
182
+ const reset = "\x1b[39;59m";
183
+ const lead = fg + underline;
184
+ const out: string[] = [];
185
+ for (let r = 0; r < opts.rows; r++) {
186
+ const rowDiacritic = diacritic(r);
187
+ let row = lead;
188
+ for (let c = 0; c < opts.columns; c++) {
189
+ row += KITTY_PLACEHOLDER + rowDiacritic + diacritic(c);
190
+ }
191
+ out.push(row + reset);
192
+ }
193
+ return out;
194
+ }
195
+
196
+ /**
197
+ * Full placeholder render: the virtual-placement APC prefixes line 0, and every
198
+ * line carries placeholder cells. Returns exactly `rows` lines (no cursor moves).
199
+ */
200
+ export function renderKittyPlaceholderLines(opts: {
201
+ imageId: number;
202
+ placementId?: number;
203
+ columns: number;
204
+ rows: number;
205
+ }): string[] {
206
+ const grid = encodeKittyPlaceholderGrid(opts);
207
+ if (grid.length > 0) {
208
+ grid[0] = encodeKittyVirtualPlacement(opts) + grid[0];
209
+ }
210
+ return grid;
211
+ }
212
+
213
+ /**
214
+ * Path for a temp-file transmission. Kitty deletes a `t=t` file after reading it
215
+ * **only** when the path contains the substring `tty-graphics-protocol`, so it is
216
+ * embedded in the filename to keep the temp dir self-cleaning.
217
+ */
218
+ function tempGraphicsPath(tag: string): string {
219
+ return path.join(os.tmpdir(), `tty-graphics-protocol-${tag}-${process.pid}-${Date.now()}.png`);
220
+ }
221
+
222
+ /**
223
+ * Transmit a PNG via a temp file (`t=t`): decode the base64 to bytes once, write
224
+ * them to a temp file, and send the base64-encoded file path as payload. Returns
225
+ * the APC string, or `null` on any failure (caller falls back to direct base64).
226
+ *
227
+ * Synchronous filesystem writes are mandated by the synchronous render pipeline
228
+ * (`Image.render` → `renderImage` are sync); there is no async seam here.
229
+ */
230
+ export function encodeKittyTempFileTransmit(base64Png: string, imageId: number): string | null {
231
+ try {
232
+ const bytes = Buffer.from(base64Png, "base64");
233
+ if (bytes.length === 0) return null;
234
+ const file = tempGraphicsPath(`i${imageId}`);
235
+ fs.writeFileSync(file, bytes);
236
+ const encodedPath = Buffer.from(file, "utf8").toString("base64");
237
+ return `\x1b_Ga=t,f=100,t=t,S=${bytes.length},q=2,i=${imageId};${encodedPath}\x1b\\`;
238
+ } catch (err) {
239
+ logger.debug("Kitty temp-file transmit failed; using direct base64", { err: String(err) });
240
+ return null;
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Encode a temp-file support probe: write a tiny PNG to a temp file and ask the
246
+ * terminal to query it (`a=q,t=t`). A conforming terminal replies
247
+ * `ESC _ G i=<probeId>;OK ESC \`. Returns the query APC plus a `cleanup` that
248
+ * removes the probe file (best-effort; kitty self-deletes the magic-named file).
249
+ * Returns `null` if the temp file cannot be written.
250
+ */
251
+ export function encodeKittyTempFileProbe(probeId: number): { sequence: string; cleanup: () => void } | null {
252
+ try {
253
+ const bytes = Buffer.from(PROBE_PNG_BASE64, "base64");
254
+ const file = tempGraphicsPath(`probe${probeId}`);
255
+ fs.writeFileSync(file, bytes);
256
+ const encodedPath = Buffer.from(file, "utf8").toString("base64");
257
+ const sequence = `\x1b_Ga=q,t=t,f=100,S=${bytes.length},q=2,i=${probeId};${encodedPath}\x1b\\`;
258
+ const cleanup = () => {
259
+ try {
260
+ fs.rmSync(file, { force: true });
261
+ } catch {
262
+ // Best effort; kitty deletes tty-graphics-protocol files itself.
263
+ }
264
+ };
265
+ return { sequence, cleanup };
266
+ } catch (err) {
267
+ logger.debug("Kitty temp-file probe setup failed", { err: String(err) });
268
+ return null;
269
+ }
270
+ }
@@ -0,0 +1,423 @@
1
+ /**
2
+ * StdinBuffer buffers input and emits complete sequences.
3
+ *
4
+ * This is necessary because stdin data events can arrive in partial chunks,
5
+ * especially for escape sequences like mouse events. Without buffering,
6
+ * partial sequences can be misinterpreted as regular keypresses.
7
+ *
8
+ * For example, the mouse SGR sequence `\x1b[<35;20;5m` might arrive as:
9
+ * - Event 1: `\x1b`
10
+ * - Event 2: `[<35`
11
+ * - Event 3: `;20;5m`
12
+ *
13
+ * The buffer accumulates these until a complete sequence is detected.
14
+ * Call the `process()` method to feed input data.
15
+ *
16
+ * Based on code from OpenTUI (https://github.com/anomalyco/opentui)
17
+ * MIT License - Copyright (c) 2025 opentui
18
+ */
19
+ import { EventEmitter } from "events";
20
+
21
+ const ESC = "\x1b";
22
+ const BRACKETED_PASTE_START = "\x1b[200~";
23
+ const BRACKETED_PASTE_END = "\x1b[201~";
24
+
25
+ /**
26
+ * Check if a string is a complete escape sequence or needs more data
27
+ */
28
+ function isCompleteSequence(data: string): "complete" | "incomplete" | "not-escape" {
29
+ if (!data.startsWith(ESC)) {
30
+ return "not-escape";
31
+ }
32
+
33
+ if (data.length === 1) {
34
+ return "incomplete";
35
+ }
36
+
37
+ const afterEsc = data.slice(1);
38
+
39
+ // CSI sequences: ESC [
40
+ if (afterEsc.startsWith("[")) {
41
+ // Check for old-style mouse sequence: ESC[M + 3 bytes
42
+ if (afterEsc.startsWith("[M")) {
43
+ // Old-style mouse needs ESC[M + 3 bytes = 6 total
44
+ return data.length >= 6 ? "complete" : "incomplete";
45
+ }
46
+ return isCompleteCsiSequence(data);
47
+ }
48
+
49
+ // OSC sequences: ESC ]
50
+ if (afterEsc.startsWith("]")) {
51
+ return isCompleteOscSequence(data);
52
+ }
53
+
54
+ // DCS sequences: ESC P ... ESC \ (includes XTVersion responses)
55
+ if (afterEsc.startsWith("P")) {
56
+ return isCompleteDcsSequence(data);
57
+ }
58
+
59
+ // APC sequences: ESC _ ... ESC \ (includes Kitty graphics responses)
60
+ if (afterEsc.startsWith("_")) {
61
+ return isCompleteApcSequence(data);
62
+ }
63
+
64
+ // SS3 sequences: ESC O
65
+ if (afterEsc.startsWith("O")) {
66
+ // ESC O followed by a single character
67
+ return afterEsc.length >= 2 ? "complete" : "incomplete";
68
+ }
69
+
70
+ // ESC-prefixed sequences (terminals with metaSendsEscape):
71
+ // Only when the inner ESC starts a CSI ('[') or SS3 ('O') sequence.
72
+ // Bare double-ESC (e.g. \x1b\x1bX) remains complete to avoid 10ms timeout lag.
73
+ if (afterEsc.startsWith(ESC)) {
74
+ const inner = data.slice(1);
75
+ const third = inner.charCodeAt(1);
76
+ if (third === 0x5b || third === 0x4f) {
77
+ return isCompleteSequence(inner);
78
+ }
79
+ return "complete";
80
+ }
81
+
82
+ // Meta key sequences: ESC followed by a single character
83
+ if (afterEsc.length === 1) {
84
+ return "complete";
85
+ }
86
+
87
+ // Unknown escape sequence - treat as complete
88
+ return "complete";
89
+ }
90
+
91
+ /**
92
+ * Check if CSI sequence is complete
93
+ * CSI sequences: ESC [ ... followed by a final byte (0x40-0x7E)
94
+ */
95
+ function isCompleteCsiSequence(data: string): "complete" | "incomplete" {
96
+ if (!data.startsWith(`${ESC}[`)) {
97
+ return "complete";
98
+ }
99
+
100
+ // Need at least ESC [ and one more character
101
+ if (data.length < 3) {
102
+ return "incomplete";
103
+ }
104
+
105
+ const payload = data.slice(2);
106
+
107
+ // CSI sequences end with a byte in the range 0x40-0x7E (@-~)
108
+ // This includes all letters and several special characters
109
+ const lastChar = payload[payload.length - 1];
110
+ const lastCharCode = lastChar.charCodeAt(0);
111
+
112
+ if (lastCharCode >= 0x40 && lastCharCode <= 0x7e) {
113
+ // Special handling for SGR mouse sequences
114
+ // Format: ESC[<B;X;Ym or ESC[<B;X;YM
115
+ if (payload.startsWith("<")) {
116
+ // Must have format: <digits;digits;digits[Mm]
117
+ const mouseMatch = /^<\d+;\d+;\d+[Mm]$/.test(payload);
118
+ if (mouseMatch) {
119
+ return "complete";
120
+ }
121
+ // If it ends with M or m but doesn't match the pattern, still incomplete
122
+ if (lastChar === "M" || lastChar === "m") {
123
+ // Check if we have the right structure
124
+ const parts = payload.slice(1, -1).split(";");
125
+ if (parts.length === 3 && parts.every(p => /^\d+$/.test(p))) {
126
+ return "complete";
127
+ }
128
+ }
129
+
130
+ return "incomplete";
131
+ }
132
+
133
+ return "complete";
134
+ }
135
+
136
+ return "incomplete";
137
+ }
138
+
139
+ /**
140
+ * Check if OSC sequence is complete
141
+ * OSC sequences: ESC ] ... ST (where ST is ESC \ or BEL)
142
+ */
143
+ function isCompleteOscSequence(data: string): "complete" | "incomplete" {
144
+ if (!data.startsWith(`${ESC}]`)) {
145
+ return "complete";
146
+ }
147
+
148
+ // OSC sequences end with ST (ESC \) or BEL (\x07)
149
+ if (data.endsWith(`${ESC}\\`) || data.endsWith("\x07")) {
150
+ return "complete";
151
+ }
152
+
153
+ return "incomplete";
154
+ }
155
+
156
+ /**
157
+ * Check if DCS (Device Control String) sequence is complete
158
+ * DCS sequences: ESC P ... ST (where ST is ESC \)
159
+ * Used for XTVersion responses like ESC P >| ... ESC \
160
+ */
161
+ function isCompleteDcsSequence(data: string): "complete" | "incomplete" {
162
+ if (!data.startsWith(`${ESC}P`)) {
163
+ return "complete";
164
+ }
165
+
166
+ // DCS sequences end with ST (ESC \)
167
+ if (data.endsWith(`${ESC}\\`)) {
168
+ return "complete";
169
+ }
170
+
171
+ return "incomplete";
172
+ }
173
+
174
+ /**
175
+ * Check if APC (Application Program Command) sequence is complete
176
+ * APC sequences: ESC _ ... ST (where ST is ESC \)
177
+ * Used for Kitty graphics responses like ESC _ G ... ESC \
178
+ */
179
+ function isCompleteApcSequence(data: string): "complete" | "incomplete" {
180
+ if (!data.startsWith(`${ESC}_`)) {
181
+ return "complete";
182
+ }
183
+
184
+ // APC sequences end with ST (ESC \)
185
+ if (data.endsWith(`${ESC}\\`)) {
186
+ return "complete";
187
+ }
188
+
189
+ return "incomplete";
190
+ }
191
+
192
+ /**
193
+ * Split accumulated buffer into complete sequences
194
+ */
195
+ function parseUnmodifiedKittyPrintableCodepoint(sequence: string): number | undefined {
196
+ const match = sequence.match(/^\x1b\[(\d+)(?::\d*)?(?::\d+)?u$/);
197
+ if (!match) return undefined;
198
+
199
+ const codepoint = parseInt(match[1]!, 10);
200
+ return codepoint >= 32 ? codepoint : undefined;
201
+ }
202
+
203
+ function extractCompleteSequences(buffer: string): { sequences: string[]; remainder: string } {
204
+ const sequences: string[] = [];
205
+ let pos = 0;
206
+
207
+ while (pos < buffer.length) {
208
+ const remaining = buffer.slice(pos);
209
+
210
+ // Try to extract a sequence starting at this position
211
+ if (remaining.startsWith(ESC)) {
212
+ // Find the end of this escape sequence
213
+ let seqEnd = 1;
214
+ while (seqEnd <= remaining.length) {
215
+ const candidate = remaining.slice(0, seqEnd);
216
+ const status = isCompleteSequence(candidate);
217
+
218
+ if (status === "complete") {
219
+ sequences.push(candidate);
220
+ pos += seqEnd;
221
+ break;
222
+ } else if (status === "incomplete") {
223
+ seqEnd++;
224
+ } else {
225
+ // Should not happen when starting with ESC
226
+ sequences.push(candidate);
227
+ pos += seqEnd;
228
+ break;
229
+ }
230
+ }
231
+
232
+ if (seqEnd > remaining.length) {
233
+ return { sequences, remainder: remaining };
234
+ }
235
+ } else {
236
+ // Not an escape sequence - take one Unicode scalar, not a UTF-16 code unit.
237
+ const char = Array.from(remaining)[0] ?? "";
238
+ sequences.push(char);
239
+ pos += char.length;
240
+ }
241
+ }
242
+
243
+ return { sequences, remainder: "" };
244
+ }
245
+
246
+ export type StdinBufferOptions = {
247
+ /**
248
+ * Maximum time to wait for sequence completion (default: 75ms).
249
+ * After this time, a genuinely incomplete escape is flushed.
250
+ */
251
+ timeout?: number;
252
+ };
253
+
254
+ export type StdinBufferEventMap = {
255
+ data: [string];
256
+ paste: [string];
257
+ };
258
+
259
+ /**
260
+ * Buffers stdin input and emits complete sequences via the 'data' event.
261
+ * Handles partial escape sequences that arrive across multiple chunks.
262
+ */
263
+ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
264
+ #buffer: string = "";
265
+ #timeout?: NodeJS.Timeout;
266
+ readonly #timeoutMs: number;
267
+ #pasteMode: boolean = false;
268
+ #pasteBuffer: string = "";
269
+ #pendingKittyPrintableCodepoint: number | undefined;
270
+
271
+ constructor(options: StdinBufferOptions = {}) {
272
+ super();
273
+ this.#timeoutMs = options.timeout ?? 75;
274
+ }
275
+
276
+ process(data: string | Buffer): void {
277
+ // Clear any pending timeout
278
+ if (this.#timeout) {
279
+ clearTimeout(this.#timeout);
280
+ this.#timeout = undefined;
281
+ }
282
+
283
+ // Handle high-byte conversion (for compatibility with parseKeypress)
284
+ // If buffer has single byte > 127, convert to ESC + (byte - 128)
285
+ let str: string;
286
+ if (Buffer.isBuffer(data)) {
287
+ if (data.length === 1 && data[0]! > 127) {
288
+ const byte = data[0]! - 128;
289
+ str = `\x1b${String.fromCharCode(byte)}`;
290
+ } else {
291
+ str = data.toString();
292
+ }
293
+ } else {
294
+ str = data;
295
+ }
296
+
297
+ if (str.length === 0 && this.#buffer.length === 0) {
298
+ this.#emitDataSequence("");
299
+ return;
300
+ }
301
+
302
+ this.#buffer += str;
303
+
304
+ if (this.#pasteMode) {
305
+ this.#pasteBuffer += this.#buffer;
306
+ this.#buffer = "";
307
+
308
+ const endIndex = this.#pasteBuffer.indexOf(BRACKETED_PASTE_END);
309
+ if (endIndex !== -1) {
310
+ const pastedContent = this.#pasteBuffer.slice(0, endIndex);
311
+ const remaining = this.#pasteBuffer.slice(endIndex + BRACKETED_PASTE_END.length);
312
+
313
+ this.#pasteMode = false;
314
+ this.#pasteBuffer = "";
315
+ this.#pendingKittyPrintableCodepoint = undefined;
316
+
317
+ this.emit("paste", pastedContent);
318
+
319
+ if (remaining.length > 0) {
320
+ this.process(remaining);
321
+ }
322
+ }
323
+ return;
324
+ }
325
+
326
+ const startIndex = this.#buffer.indexOf(BRACKETED_PASTE_START);
327
+ if (startIndex !== -1) {
328
+ if (startIndex > 0) {
329
+ const beforePaste = this.#buffer.slice(0, startIndex);
330
+ const result = extractCompleteSequences(beforePaste);
331
+ for (const sequence of result.sequences) {
332
+ this.#emitDataSequence(sequence);
333
+ }
334
+ }
335
+
336
+ this.#pendingKittyPrintableCodepoint = undefined;
337
+ this.#buffer = this.#buffer.slice(startIndex + BRACKETED_PASTE_START.length);
338
+ this.#pasteMode = true;
339
+ this.#pasteBuffer = this.#buffer;
340
+ this.#buffer = "";
341
+
342
+ const endIndex = this.#pasteBuffer.indexOf(BRACKETED_PASTE_END);
343
+ if (endIndex !== -1) {
344
+ const pastedContent = this.#pasteBuffer.slice(0, endIndex);
345
+ const remaining = this.#pasteBuffer.slice(endIndex + BRACKETED_PASTE_END.length);
346
+
347
+ this.#pasteMode = false;
348
+ this.#pasteBuffer = "";
349
+ this.#pendingKittyPrintableCodepoint = undefined;
350
+
351
+ this.emit("paste", pastedContent);
352
+
353
+ if (remaining.length > 0) {
354
+ this.process(remaining);
355
+ }
356
+ }
357
+ return;
358
+ }
359
+
360
+ const result = extractCompleteSequences(this.#buffer);
361
+ this.#buffer = result.remainder;
362
+
363
+ for (const sequence of result.sequences) {
364
+ this.#emitDataSequence(sequence);
365
+ }
366
+
367
+ if (this.#buffer.length > 0) {
368
+ this.#timeout = setTimeout(() => {
369
+ const flushed = this.flush();
370
+
371
+ for (const sequence of flushed) {
372
+ this.#emitDataSequence(sequence);
373
+ }
374
+ }, this.#timeoutMs);
375
+ }
376
+ }
377
+
378
+ #emitDataSequence(sequence: string): void {
379
+ const rawCodepoint = sequence.length === 1 ? sequence.codePointAt(0) : undefined;
380
+ if (rawCodepoint !== undefined && rawCodepoint === this.#pendingKittyPrintableCodepoint) {
381
+ this.#pendingKittyPrintableCodepoint = undefined;
382
+ return;
383
+ }
384
+
385
+ this.#pendingKittyPrintableCodepoint = parseUnmodifiedKittyPrintableCodepoint(sequence);
386
+ this.emit("data", sequence);
387
+ }
388
+
389
+ flush(): string[] {
390
+ if (this.#timeout) {
391
+ clearTimeout(this.#timeout);
392
+ this.#timeout = undefined;
393
+ }
394
+
395
+ if (this.#buffer.length === 0) {
396
+ return [];
397
+ }
398
+
399
+ const sequences = [this.#buffer];
400
+ this.#buffer = "";
401
+ this.#pendingKittyPrintableCodepoint = undefined;
402
+ return sequences;
403
+ }
404
+
405
+ clear(): void {
406
+ if (this.#timeout) {
407
+ clearTimeout(this.#timeout);
408
+ this.#timeout = undefined;
409
+ }
410
+ this.#buffer = "";
411
+ this.#pasteMode = false;
412
+ this.#pasteBuffer = "";
413
+ this.#pendingKittyPrintableCodepoint = undefined;
414
+ }
415
+
416
+ getBuffer(): string {
417
+ return this.#buffer;
418
+ }
419
+
420
+ destroy(): void {
421
+ this.clear();
422
+ }
423
+ }