@linxiraos/pi-tui 1.0.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 (77) hide show
  1. package/CHANGELOG.md +2219 -0
  2. package/README.md +705 -0
  3. package/dist/types/autocomplete.d.ts +116 -0
  4. package/dist/types/bracketed-paste.d.ts +51 -0
  5. package/dist/types/components/box.d.ts +31 -0
  6. package/dist/types/components/cancellable-loader.d.ts +21 -0
  7. package/dist/types/components/editor.d.ts +162 -0
  8. package/dist/types/components/image.d.ts +112 -0
  9. package/dist/types/components/input.d.ts +25 -0
  10. package/dist/types/components/loader.d.ts +25 -0
  11. package/dist/types/components/markdown.d.ts +88 -0
  12. package/dist/types/components/scroll-view.d.ts +62 -0
  13. package/dist/types/components/select-list.d.ts +69 -0
  14. package/dist/types/components/settings-list.d.ts +123 -0
  15. package/dist/types/components/spacer.d.ts +11 -0
  16. package/dist/types/components/tab-bar.d.ts +89 -0
  17. package/dist/types/components/text.d.ts +27 -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/desktop-notify.d.ts +52 -0
  21. package/dist/types/editor-component.d.ts +38 -0
  22. package/dist/types/fuzzy.d.ts +48 -0
  23. package/dist/types/index.d.ts +32 -0
  24. package/dist/types/keybindings.d.ts +197 -0
  25. package/dist/types/keys.d.ts +210 -0
  26. package/dist/types/kill-ring.d.ts +20 -0
  27. package/dist/types/kitty-graphics.d.ts +76 -0
  28. package/dist/types/latex-block.d.ts +8 -0
  29. package/dist/types/latex-to-unicode.d.ts +50 -0
  30. package/dist/types/loop-watchdog.d.ts +44 -0
  31. package/dist/types/mouse.d.ts +67 -0
  32. package/dist/types/stdin-buffer.d.ts +60 -0
  33. package/dist/types/symbols.d.ts +25 -0
  34. package/dist/types/terminal-capabilities.d.ts +285 -0
  35. package/dist/types/terminal.d.ts +175 -0
  36. package/dist/types/tmux.d.ts +6 -0
  37. package/dist/types/ttyid.d.ts +9 -0
  38. package/dist/types/tui.d.ts +457 -0
  39. package/dist/types/utils.d.ts +100 -0
  40. package/package.json +70 -0
  41. package/src/autocomplete.ts +1079 -0
  42. package/src/bracketed-paste.ts +123 -0
  43. package/src/components/box.ts +236 -0
  44. package/src/components/cancellable-loader.ts +40 -0
  45. package/src/components/editor.ts +3301 -0
  46. package/src/components/image.ts +460 -0
  47. package/src/components/input.ts +482 -0
  48. package/src/components/loader.ts +174 -0
  49. package/src/components/markdown.ts +3119 -0
  50. package/src/components/scroll-view.ts +227 -0
  51. package/src/components/select-list.ts +539 -0
  52. package/src/components/settings-list.ts +793 -0
  53. package/src/components/spacer.ts +32 -0
  54. package/src/components/tab-bar.ts +300 -0
  55. package/src/components/text.ts +173 -0
  56. package/src/components/truncated-text.ts +69 -0
  57. package/src/deccara.ts +314 -0
  58. package/src/desktop-notify.ts +192 -0
  59. package/src/editor-component.ts +74 -0
  60. package/src/fuzzy.ts +384 -0
  61. package/src/index.ts +51 -0
  62. package/src/keybindings.ts +346 -0
  63. package/src/keys.ts +566 -0
  64. package/src/kill-ring.ts +51 -0
  65. package/src/kitty-graphics.ts +171 -0
  66. package/src/latex-block.ts +1338 -0
  67. package/src/latex-to-unicode.ts +2017 -0
  68. package/src/loop-watchdog.ts +115 -0
  69. package/src/mouse.ts +105 -0
  70. package/src/stdin-buffer.ts +781 -0
  71. package/src/symbols.ts +26 -0
  72. package/src/terminal-capabilities.ts +1211 -0
  73. package/src/terminal.ts +1854 -0
  74. package/src/tmux.ts +14 -0
  75. package/src/ttyid.ts +84 -0
  76. package/src/tui.ts +4275 -0
  77. package/src/utils.ts +619 -0
@@ -0,0 +1,1211 @@
1
+ import { encodeSixel } from "@linxiraos/pi-natives";
2
+ import { $env, isBunTestRuntime, isTerminalHeadless } from "@linxiraos/pi-utils";
3
+ import { sendDesktopNotification, shouldDeliverDesktopNotification } from "./desktop-notify";
4
+ import {
5
+ detectKittyUnicodePlaceholdersSupport,
6
+ getKittyGraphics,
7
+ KITTY_PLACEHOLDER,
8
+ kittyPlaceholdersFit,
9
+ renderKittyPlaceholderLines,
10
+ setKittyGraphics,
11
+ } from "./kitty-graphics";
12
+ import { isInsideTmux, wrapTmuxPassthrough, wrapTmuxPassthroughIfNeeded } from "./tmux";
13
+ import type { HangulCompatibilityJamoWidth } from "./utils";
14
+
15
+ export { isInsideTmux, wrapTmuxPassthrough } from "./tmux";
16
+
17
+ export enum ImageProtocol {
18
+ Kitty = "\x1b_G",
19
+ Iterm2 = "\x1b]1337;File=",
20
+ Sixel = "\x1bPq",
21
+ }
22
+
23
+ export enum NotifyProtocol {
24
+ Bell = "\x07",
25
+ Osc99 = "\x1b]99;;",
26
+ Osc9 = "\x1b]9;",
27
+ }
28
+
29
+ export type TerminalId =
30
+ | "kitty"
31
+ | "ghostty"
32
+ | "wezterm"
33
+ | "iterm2"
34
+ | "vscode"
35
+ | "alacritty"
36
+ | "warp"
37
+ | "base"
38
+ | "trueColor";
39
+
40
+ const CMUX_NOTIFICATION_TITLE = "Zeta";
41
+ const CMUX_SURFACE_ID_PATTERN = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/iu;
42
+
43
+ /**
44
+ * Route a notification through cmux when the process belongs to a concrete
45
+ * surface. Workspace/socket state alone is not enough: only the injected
46
+ * surface UUID identifies the pane that should receive the notification.
47
+ * Returns whether cmux owns delivery so the caller can preserve every existing
48
+ * terminal fallback unchanged when no valid surface is present.
49
+ */
50
+ function sendCmuxNotification(message: string | TerminalNotification, env: NodeJS.ProcessEnv = Bun.env): boolean {
51
+ const surfaceId = env.CMUX_SURFACE_ID?.trim();
52
+ if (!surfaceId || !CMUX_SURFACE_ID_PATTERN.test(surfaceId)) return false;
53
+
54
+ const title =
55
+ typeof message === "string" ? CMUX_NOTIFICATION_TITLE : message.title?.trim() || CMUX_NOTIFICATION_TITLE;
56
+ const body = typeof message === "string" ? message : (message.body ?? "");
57
+ try {
58
+ const child = Bun.spawn({
59
+ cmd: ["cmux", "notify", "--surface", surfaceId, "--title", title, "--body", body],
60
+ stdin: "ignore",
61
+ stdout: "ignore",
62
+ stderr: "ignore",
63
+ });
64
+ child.unref();
65
+ } catch {
66
+ // A missing cmux binary leaves delivery to the existing terminal fallback.
67
+ return false;
68
+ }
69
+ return true;
70
+ }
71
+
72
+ function hasNeedleBefore(line: string, needle: string, limit: number): boolean {
73
+ const index = line.indexOf(needle);
74
+ return index !== -1 && index + needle.length <= limit;
75
+ }
76
+
77
+ function hasSixelDcsStart(line: string): boolean {
78
+ const limit = Math.min(line.length, 128);
79
+ let from = 0;
80
+ for (;;) {
81
+ const start = line.indexOf("\x1bP", from);
82
+ if (start === -1 || start + 3 > limit) return false;
83
+ let i = start + 2;
84
+ while (i < limit) {
85
+ const code = line.charCodeAt(i);
86
+ if ((code >= 0x30 && code <= 0x39) || code === 0x3b) {
87
+ i++;
88
+ continue;
89
+ }
90
+ break;
91
+ }
92
+ if (i < limit && line.charCodeAt(i) === 0x71) return true;
93
+ from = start + 2;
94
+ }
95
+ }
96
+
97
+ /** Terminal capability details used for rendering and protocol selection. */
98
+ export class TerminalInfo {
99
+ constructor(
100
+ public readonly id: TerminalId,
101
+ public readonly imageProtocol: ImageProtocol | null,
102
+ public readonly trueColor: boolean,
103
+ public readonly hyperlinks: boolean,
104
+ public readonly notifyProtocol: NotifyProtocol = NotifyProtocol.Bell,
105
+ public readonly deccara: boolean = false,
106
+ readonly supportsScreenToScrollback: boolean = false,
107
+ /** Renders the Kitty OSC 66 text-sizing protocol (scaled spans). Kitty only. */
108
+ public readonly textSizing: boolean = false,
109
+ /**
110
+ * Hangul Compatibility Jamo (U+3131..=U+318E) cell width. Ghostty follows
111
+ * UAX#11 (2 cells); Warp paints 1; "platform" keeps the OS default
112
+ * (macOS narrow, otherwise UAX#11).
113
+ */
114
+ public readonly hangulJamoWidth: HangulCompatibilityJamoWidth = "platform",
115
+ ) {}
116
+
117
+ /**
118
+ * Mutable clone for the {@link TERMINAL} singleton: copies every field and
119
+ * keeps the prototype methods, so the builder and runtime setters flip
120
+ * runtime-resolved {@link RuntimeTerminal} capabilities in place instead of
121
+ * reconstructing positional constructor args.
122
+ */
123
+ clone(): RuntimeTerminal {
124
+ return Object.assign(Object.create(TerminalInfo.prototype), this) as RuntimeTerminal;
125
+ }
126
+
127
+ isImageLine(line: string): boolean {
128
+ if (!this.imageProtocol) return false;
129
+ if (this.imageProtocol === ImageProtocol.Sixel) {
130
+ return hasSixelDcsStart(line);
131
+ }
132
+ return hasNeedleBefore(line, this.imageProtocol, 64) || hasNeedleBefore(line, KITTY_PLACEHOLDER, 64);
133
+ }
134
+
135
+ formatNotification(message: string | TerminalNotification): string {
136
+ if (this.notifyProtocol === NotifyProtocol.Bell) {
137
+ return NotifyProtocol.Bell;
138
+ }
139
+ // Structured notifications use OSC 99's rich metadata only once the
140
+ // terminal confirms support; otherwise collapse to a single message line
141
+ // (basic OSC 99 / OSC 9 still work).
142
+ if (typeof message !== "string") {
143
+ if (this.notifyProtocol === NotifyProtocol.Osc99 && osc99CapabilitiesConfirmed) {
144
+ return formatOsc99Notification(message);
145
+ }
146
+ return `${this.notifyProtocol}${notificationToLine(message)}\x1b\\`;
147
+ }
148
+ return `${this.notifyProtocol}${message}\x1b\\`;
149
+ }
150
+
151
+ sendNotification(message: string | TerminalNotification): void {
152
+ if (isNotificationSuppressed() || isTerminalHeadless()) return;
153
+ if (sendCmuxNotification(message)) return;
154
+ const formatted = this.formatNotification(message);
155
+ // Under tmux, terminals whose notify protocol is OSC 9 / OSC 99 would
156
+ // otherwise lose the notification entirely: tmux does not forward bare
157
+ // OSC 9/99 to the outer terminal, and the bare sequence does not flag
158
+ // tmux's own `monitor-bell` / `monitor-activity`. Wrap the OSC in tmux's
159
+ // DCS passthrough envelope so users with `allow-passthrough on` still
160
+ // get the desktop toast, then append a BEL so `monitor-bell` flags the
161
+ // pane/window for everyone else — the only signal a backgrounded pane
162
+ // has that the agent finished or is waiting for input. `Bell` protocol
163
+ // already self-flags via tmux's bell monitoring, so leave it alone.
164
+ if (this.notifyProtocol !== NotifyProtocol.Bell && isInsideTmux()) {
165
+ process.stdout.write(`${wrapTmuxPassthrough(formatted)}\x07`);
166
+ return;
167
+ }
168
+ // Zellij drops OSC 9/99 and has no DCS passthrough envelope, but raises its
169
+ // `[!]` bell flag on a bare BEL — the same backgrounded-pane signal tmux
170
+ // users get. So follow the (Zellij-swallowed) OSC with a plain BEL.
171
+ if (this.notifyProtocol !== NotifyProtocol.Bell && isInsideZellij()) {
172
+ process.stdout.write(`${formatted}\x07`);
173
+ return;
174
+ }
175
+ process.stdout.write(formatted);
176
+ // VTE-family terminals (Ptyxis, GNOME Terminal, Tilix, …) plus Alacritty
177
+ // and bare xterm-on-Wayland have no in-band escape that surfaces an
178
+ // arbitrary desktop toast (#3685). When the chosen `notifyProtocol` is
179
+ // BEL on a Linux session bus, also fan the notification out via
180
+ // libnotify so users see the toast and the BEL still fires for tmux
181
+ // `monitor-bell` / X11 urgency hints / audible bell.
182
+ if (this.notifyProtocol === NotifyProtocol.Bell && shouldDeliverDesktopNotification(this.id, true)) {
183
+ sendDesktopNotification(message);
184
+ }
185
+ }
186
+ }
187
+
188
+ /** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */
189
+ export function isInsideTerminalMultiplexer(env: NodeJS.ProcessEnv = Bun.env): boolean {
190
+ // TMUX/STY/ZELLIJ, Herdr, and CMUX workspace/surface/remote-transport
191
+ // markers are authoritative session signals. TERM can also survive when those are
192
+ // stripped (`sudo` without -E, `su`, env-sanitizing launchers/ssh). Do not
193
+ // use CMUX_SOCKET_PATH here: it is a CLI socket override and can be set
194
+ // outside a CMUX terminal.
195
+ if (env.TMUX || env.STY || env.ZELLIJ || env.HERDR_ENV === "1") return true;
196
+ if (env.CMUX_WORKSPACE_ID || env.CMUX_SURFACE_ID || env.CMUX_REMOTE_TRANSPORT) return true;
197
+ const term = env.TERM?.toLowerCase() ?? "";
198
+ return term.startsWith("tmux") || term.startsWith("screen");
199
+ }
200
+
201
+ /**
202
+ * Whether the agent process is running inside a Zellij session. Read fresh on
203
+ * each call (like {@link isInsideTmux}) so a session attached/detached mid-run
204
+ * is observed and tests can toggle `Bun.env.ZELLIJ` per case.
205
+ */
206
+ export function isInsideZellij(env: NodeJS.ProcessEnv = Bun.env): boolean {
207
+ return Boolean(env.ZELLIJ);
208
+ }
209
+
210
+ export function isNotificationSuppressed(): boolean {
211
+ const value = $env.PI_NOTIFICATIONS;
212
+ if (!value) return false;
213
+ return value === "off" || value === "0" || value === "false";
214
+ }
215
+
216
+ function getForcedImageProtocol(): ImageProtocol | null | undefined {
217
+ const raw = $env.PI_FORCE_IMAGE_PROTOCOL?.trim().toLowerCase();
218
+ if (!raw) return undefined;
219
+ if (raw === "kitty") return ImageProtocol.Kitty;
220
+ if (raw === "iterm2" || raw === "iterm") return ImageProtocol.Iterm2;
221
+ if (raw === "sixel") return ImageProtocol.Sixel;
222
+ if (raw === "off" || raw === "none" || raw === "0" || raw === "false") return null;
223
+ return null;
224
+ }
225
+
226
+ function parseMajorMinorVersion(versionRaw?: string): { major: number; minor: number } | null {
227
+ if (!versionRaw) return null;
228
+ const match = /^(\d+)\.(\d+)/u.exec(versionRaw.trim());
229
+ if (!match) return null;
230
+ const major = Number.parseInt(match[1] ?? "", 10);
231
+ const minor = Number.parseInt(match[2] ?? "", 10);
232
+ if (!Number.isFinite(major) || !Number.isFinite(minor)) return null;
233
+ return { major, minor };
234
+ }
235
+
236
+ /**
237
+ * Returns true when running in Windows Terminal with known SIXEL support.
238
+ *
239
+ * Windows Terminal introduced SIXEL support in preview 1.22.
240
+ */
241
+ export function isWindowsTerminalPreviewSixelSupported(
242
+ env: NodeJS.ProcessEnv = Bun.env,
243
+ platform: NodeJS.Platform = process.platform,
244
+ ): boolean {
245
+ if (platform !== "win32") return false;
246
+ if (!env.WT_SESSION) return false;
247
+ if (env.TERM_PROGRAM && env.TERM_PROGRAM.toLowerCase() !== "windows_terminal") {
248
+ return false;
249
+ }
250
+ const version = parseMajorMinorVersion(env.TERM_PROGRAM_VERSION);
251
+ if (!version) return false;
252
+ return version.major > 1 || (version.major === 1 && version.minor >= 22);
253
+ }
254
+
255
+ /**
256
+ * Resolve an explicit user override for DEC 2026 synchronized output. Returns
257
+ * `false` for an opt-out, `true` for a force-on, or `null` when the user has
258
+ * expressed no preference. Shared by the static default and the runtime DECRQM
259
+ * probe so both honor the same precedence — an opt-out beats a force-on.
260
+ */
261
+ export function synchronizedOutputUserOverride(env: NodeJS.ProcessEnv = Bun.env): boolean | null {
262
+ if (env.PI_NO_SYNC_OUTPUT || env.PI_TUI_SYNC_OUTPUT === "0") return false;
263
+ if (env.PI_FORCE_SYNC_OUTPUT === "1" || env.PI_TUI_SYNC_OUTPUT === "1") return true;
264
+ return null;
265
+ }
266
+
267
+ /**
268
+ * Whether `TERM_FEATURES` advertises DEC 2026 synchronized output via the `Sy`
269
+ * capability token. `TERM_FEATURES` is a run of capitalized two-letter codes
270
+ * (e.g. `…Sy…`), so a case-sensitive substring match is unambiguous: `Sy`
271
+ * cannot straddle a code boundary because those are always lowercase→uppercase.
272
+ */
273
+ function advertisesSynchronizedOutput(termFeatures: string | undefined): boolean {
274
+ return termFeatures?.includes("Sy") ?? false;
275
+ }
276
+
277
+ /**
278
+ * Whether DEC 2026 synchronized-output wrappers should be enabled by default.
279
+ *
280
+ * Policy (highest precedence first):
281
+ * 1. Explicit user override (`PI_NO_SYNC_OUTPUT`/`PI_TUI_SYNC_OUTPUT=0` off,
282
+ * `PI_FORCE_SYNC_OUTPUT=1`/`PI_TUI_SYNC_OUTPUT=1` on).
283
+ * 2. Positive `TERM_FEATURES` advertisement (`Sy`) — survives SSH/mux wrapping.
284
+ * 3. Windows Terminal (1.24+) via `WT_SESSION`, on native win32 and the
285
+ * WSL/SSH-fronted host alike.
286
+ * 4. Known direct terminals with confirmed support. SSH does *not* disable —
287
+ * DEC 2026 passes through SSH when the outer terminal honors it.
288
+ * 5. Everything else starts off, including risky multiplexers; the runtime
289
+ * DECRQM probe upgrades any of them when the terminal actually reports
290
+ * `?2026` supported (current zellij, tmux master, foot, contour, mintty…).
291
+ */
292
+ export function shouldEnableSynchronizedOutputByDefault(
293
+ env: NodeJS.ProcessEnv = Bun.env,
294
+ terminalId: TerminalId = TERMINAL_ID,
295
+ ): boolean {
296
+ const override = synchronizedOutputUserOverride(env);
297
+ if (override !== null) return override;
298
+
299
+ if (advertisesSynchronizedOutput(env.TERM_FEATURES)) return true;
300
+ if (env.WT_SESSION) return true;
301
+
302
+ // Risky multiplexers start off even when an inner terminal id leaks through:
303
+ // older tmux/screen synchronized-output handling is flaky and a mux may not
304
+ // pass DEC 2026 to the outer host. The DECRQM probe re-enables sync when the
305
+ // mux reports `?2026` supported.
306
+ if (isInsideTerminalMultiplexer(env)) {
307
+ return false;
308
+ }
309
+
310
+ switch (terminalId) {
311
+ case "kitty":
312
+ case "ghostty":
313
+ case "wezterm":
314
+ case "iterm2":
315
+ case "alacritty":
316
+ case "vscode":
317
+ return true;
318
+ default:
319
+ // VTE family, GNU screen, Apple Terminal, Warp, legacy native console
320
+ // host (no WT_SESSION), and bare/unknown xterm profiles stay off until
321
+ // the DECRQM probe proves support.
322
+ return false;
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Whether the terminal applies Kitty-style DECCARA rectangular SGR changes
328
+ * (`CSI Pt ; Pl ; Pb ; Pr ; <sgr> $ r`) extended to background color, so large
329
+ * filled regions can be painted as rectangles instead of background-padded
330
+ * strings on every row.
331
+ *
332
+ * Verified against terminal sources rather than terminfo, because a bare
333
+ * `Cara`/DECCARA terminfo capability does not imply the Kitty SGR-background
334
+ * extension:
335
+ * - Kitty implements it for *all* SGR attributes including background (see
336
+ * kitty `docs/deccara.rst` and the `test_deccara` parser test).
337
+ * - Ghostty does NOT: its `CSI $ r` dispatch falls through to an "unknown CSI"
338
+ * warning and DECCARA/DECSACE are tracked as unsupported
339
+ * (ghostty-org/ghostty#632). Enabling it there would silently drop panel
340
+ * backgrounds, so ghostty stays on the padded-string fallback.
341
+ *
342
+ * Disabled under tmux/screen/zellij multiplexers — screen-coordinate rectangle
343
+ * protocols are not safe to assume through a multiplexer — and via the
344
+ * `PI_NO_DECCARA` kill switch. Pure helper for tests and `TERMINAL` construction.
345
+ */
346
+ export function detectRectangularSgrSupport(terminalId: TerminalId, env: NodeJS.ProcessEnv = Bun.env): boolean {
347
+ if (terminalId !== "kitty") return false;
348
+ const kill = env.PI_NO_DECCARA;
349
+ if (kill && kill !== "0" && kill.toLowerCase() !== "false") return false;
350
+ if (isInsideTerminalMultiplexer(env)) {
351
+ return false;
352
+ }
353
+ return true;
354
+ }
355
+ /**
356
+ * Resolve an explicit user override for OSC 8 hyperlinks. Returns `false` for
357
+ * an opt-out, `true` for a force-on, or `null` when the user has expressed no
358
+ * preference. Opt-out beats force-on so a kill switch is unambiguous, mirroring
359
+ * {@link synchronizedOutputUserOverride}.
360
+ */
361
+ export function hyperlinksUserOverride(env: NodeJS.ProcessEnv = Bun.env): boolean | null {
362
+ if (env.PI_NO_HYPERLINKS === "1") return false;
363
+ if (env.PI_FORCE_HYPERLINKS === "1") return true;
364
+ return null;
365
+ }
366
+
367
+ /**
368
+ * Parse tmux's self-reported version from `TERM_PROGRAM_VERSION`. tmux sets
369
+ * `TERM_PROGRAM=tmux` and `TERM_PROGRAM_VERSION=<version>` automatically since
370
+ * 3.2a; older releases (or any path that does not surface the version) yield
371
+ * `null` and the caller treats tmux conservatively.
372
+ */
373
+ function parseTmuxVersionFromEnv(env: NodeJS.ProcessEnv): { major: number; minor: number } | null {
374
+ if (env.TERM_PROGRAM?.toLowerCase() !== "tmux") return null;
375
+ return parseMajorMinorVersion(env.TERM_PROGRAM_VERSION);
376
+ }
377
+
378
+ /**
379
+ * Whether OSC 8 hyperlinks should be enabled by default.
380
+ *
381
+ * Policy (highest precedence first):
382
+ * 1. Explicit user override (`PI_NO_HYPERLINKS=1` off, `PI_FORCE_HYPERLINKS=1`
383
+ * on). Opt-out wins ties.
384
+ * 2. Static terminal capability — terminals whose {@link TerminalInfo} marks
385
+ * `hyperlinks: false` (e.g. `base`) stay off unless the user forced on.
386
+ * 3. GNU screen's explicit session marker (`STY`) always off, even if tmux is
387
+ * also present: a screen layer anywhere in the path cannot forward OSC 8.
388
+ * 4. tmux session (`TMUX` set): enabled when tmux self-reports >= 3.4 via
389
+ * `TERM_PROGRAM_VERSION` (tmux 3.4 stores OSC 8 as a cell attribute and
390
+ * forwards it to outer terminals whose `terminal-features` include
391
+ * `hyperlinks`). Older or unknown versions stay off; on outer terminals
392
+ * without the feature configured, tmux silently drops the sequence —
393
+ * identical to today. Checked before the screen-family TERM heuristic
394
+ * because tmux's historical `default-terminal` is `screen-256color`, so
395
+ * `TERM=screen*` inside a tmux session must NOT short-circuit to off.
396
+ * 5. screen-family TERM without `TMUX` always off: screen never gained OSC 8
397
+ * support.
398
+ * 6. tmux-family TERM without `TMUX` env — unusual (e.g. inspection scripts);
399
+ * no version available, so off.
400
+ * 7. Otherwise honor the static terminal capability.
401
+ */
402
+ export function shouldEnableHyperlinksByDefault(
403
+ env: NodeJS.ProcessEnv = Bun.env,
404
+ terminalId: TerminalId = TERMINAL_ID,
405
+ ): boolean {
406
+ const override = hyperlinksUserOverride(env);
407
+ if (override !== null) return override;
408
+
409
+ if (!getTerminalInfo(terminalId).hyperlinks) return false;
410
+
411
+ // STY is GNU screen's explicit session marker. It vetoes tmux enabling when
412
+ // multiplexers are nested because screen cannot forward OSC 8 anywhere in the
413
+ // path.
414
+ if (env.STY) return false;
415
+
416
+ // tmux check before TERM heuristics: TMUX is the authoritative current-session
417
+ // signal and supersedes TERM, which may be `screen-256color` under tmux's
418
+ // historical default-terminal setting.
419
+ if (env.TMUX) {
420
+ const version = parseTmuxVersionFromEnv(env);
421
+ if (!version) return false;
422
+ return version.major > 3 || (version.major === 3 && version.minor >= 4);
423
+ }
424
+
425
+ const term = env.TERM?.toLowerCase() ?? "";
426
+ if (term.startsWith("screen")) return false;
427
+ if (term.startsWith("tmux")) return false;
428
+
429
+ return true;
430
+ }
431
+
432
+ function getFallbackImageProtocol(terminalId: TerminalId): ImageProtocol | null {
433
+ if (!process.stdout.isTTY) return null;
434
+ if (terminalId === "vscode" || terminalId === "alacritty") return null;
435
+ const term = Bun.env.TERM?.toLowerCase() ?? "";
436
+ if (term.includes("screen") || term.includes("tmux") || term.includes("ghostty")) {
437
+ return ImageProtocol.Kitty;
438
+ }
439
+ return null;
440
+ }
441
+ /**
442
+ * Warp implements the Kitty graphics protocol only on macOS/Linux; its Windows
443
+ * build (including Warp-hosted WSL shells) renders the same APC sequences as
444
+ * visible garbage. Keep platform/env injectable so the carve-out is testable
445
+ * without mutating `process.platform`.
446
+ */
447
+ export function resolveWarpImageProtocol(
448
+ platform: NodeJS.Platform = process.platform,
449
+ env: NodeJS.ProcessEnv = Bun.env,
450
+ ): ImageProtocol | null {
451
+ const windowsHost =
452
+ platform === "win32" || (platform === "linux" && Boolean(env.WSL_DISTRO_NAME || env.WSL_INTEROP));
453
+ return windowsHost ? null : ImageProtocol.Kitty;
454
+ }
455
+
456
+ function getWarpTerminalInfo(platform: NodeJS.Platform, env: NodeJS.ProcessEnv = Bun.env): TerminalInfo {
457
+ return new TerminalInfo(
458
+ "warp",
459
+ resolveWarpImageProtocol(platform, env),
460
+ true,
461
+ false,
462
+ NotifyProtocol.Osc9,
463
+ false,
464
+ false,
465
+ false,
466
+ 1,
467
+ );
468
+ }
469
+ const KNOWN_TERMINALS = Object.freeze({
470
+ // Fallback terminals
471
+ base: new TerminalInfo("base", null, false, false, NotifyProtocol.Bell),
472
+ trueColor: new TerminalInfo("trueColor", null, true, false, NotifyProtocol.Bell),
473
+ // Recognized terminals
474
+ kitty: new TerminalInfo("kitty", ImageProtocol.Kitty, true, true, NotifyProtocol.Osc99, true, true, true),
475
+ ghostty: new TerminalInfo("ghostty", ImageProtocol.Kitty, true, true, NotifyProtocol.Osc9, false, false, false, 2),
476
+ wezterm: new TerminalInfo("wezterm", ImageProtocol.Kitty, true, true, NotifyProtocol.Osc9),
477
+ iterm2: new TerminalInfo("iterm2", ImageProtocol.Iterm2, true, true, NotifyProtocol.Osc9),
478
+ vscode: new TerminalInfo("vscode", null, true, true, NotifyProtocol.Bell),
479
+ alacritty: new TerminalInfo("alacritty", null, true, true, NotifyProtocol.Bell),
480
+ // Warp identifies via TERM_PROGRAM=WarpTerminal and ships the Kitty graphics
481
+ // protocol on macOS/Linux (direct placement only — no Unicode placeholders, so
482
+ // detectKittyUnicodePlaceholdersSupport correctly excludes it). It does not
483
+ // honor OSC 8 yet (the escape renders as visible text), so hyperlinks stay off,
484
+ // but it does support OSC 9 notifications.
485
+ warp: new TerminalInfo("warp", ImageProtocol.Kitty, true, false, NotifyProtocol.Osc9, false, false, false, 1),
486
+ });
487
+
488
+ /** Resolve terminal identity from environment markers used by common emulators. */
489
+ export function detectTerminalId(env: NodeJS.ProcessEnv = Bun.env): TerminalId {
490
+ function caseEq(a: string, b: string): boolean {
491
+ return a.toLowerCase() === b.toLowerCase(); // For compiler to pattern match
492
+ }
493
+
494
+ const {
495
+ KITTY_WINDOW_ID,
496
+ GHOSTTY_RESOURCES_DIR,
497
+ WEZTERM_PANE,
498
+ ITERM_SESSION_ID,
499
+ VSCODE_PID,
500
+ ALACRITTY_WINDOW_ID,
501
+ TERM_PROGRAM,
502
+ TERM,
503
+ COLORTERM,
504
+ } = env;
505
+
506
+ if (KITTY_WINDOW_ID) return "kitty";
507
+ if (GHOSTTY_RESOURCES_DIR) return "ghostty";
508
+ if (WEZTERM_PANE) return "wezterm";
509
+ if (ITERM_SESSION_ID) return "iterm2";
510
+ if (VSCODE_PID) return "vscode";
511
+ if (ALACRITTY_WINDOW_ID) return "alacritty";
512
+
513
+ if (TERM_PROGRAM) {
514
+ if (caseEq(TERM_PROGRAM, "kitty")) return "kitty";
515
+ if (caseEq(TERM_PROGRAM, "ghostty")) return "ghostty";
516
+ if (caseEq(TERM_PROGRAM, "wezterm")) return "wezterm";
517
+ if (caseEq(TERM_PROGRAM, "iterm.app")) return "iterm2";
518
+ if (caseEq(TERM_PROGRAM, "vscode")) return "vscode";
519
+ if (caseEq(TERM_PROGRAM, "alacritty")) return "alacritty";
520
+ if (caseEq(TERM_PROGRAM, "warpterminal")) return "warp";
521
+ }
522
+
523
+ if (TERM?.toLowerCase().includes("ghostty")) return "ghostty";
524
+
525
+ if (COLORTERM) {
526
+ if (caseEq(COLORTERM, "truecolor") || caseEq(COLORTERM, "24bit")) return "trueColor";
527
+ }
528
+ return "base";
529
+ }
530
+
531
+ export const TERMINAL_ID: TerminalId = detectTerminalId(Bun.env);
532
+
533
+ /**
534
+ * The process-wide {@link TERMINAL} singleton: a {@link TerminalInfo} whose
535
+ * post-construction capabilities — the image protocol and the probe-driven
536
+ * flags — are writable, so the runtime setters and tests mutate them directly
537
+ * instead of through an unsound cast. Every other field stays readonly.
538
+ */
539
+ export interface RuntimeTerminal extends TerminalInfo {
540
+ imageProtocol: ImageProtocol | null;
541
+ hyperlinks: boolean;
542
+ deccara: boolean;
543
+ supportsScreenToScrollback: boolean;
544
+ textSizing: boolean;
545
+ }
546
+
547
+ export const TERMINAL: RuntimeTerminal = (() => {
548
+ const resolved = getTerminalInfo(TERMINAL_ID).clone();
549
+
550
+ const forcedImageProtocol = getForcedImageProtocol();
551
+ if (forcedImageProtocol !== undefined) {
552
+ resolved.imageProtocol = forcedImageProtocol;
553
+ } else if (resolved.id === "warp") {
554
+ // Warp advertises Kitty graphics on macOS/Linux only; drop it on win32.
555
+ resolved.imageProtocol = resolveWarpImageProtocol();
556
+ } else if (!resolved.imageProtocol) {
557
+ const fallbackImageProtocol = getFallbackImageProtocol(resolved.id);
558
+ if (fallbackImageProtocol) resolved.imageProtocol = fallbackImageProtocol;
559
+ }
560
+ // Hyperlink (OSC 8) capability. The static per-terminal flag lives on
561
+ // KNOWN_TERMINALS; shouldEnableHyperlinksByDefault folds in runtime context —
562
+ // PI_FORCE_HYPERLINKS / PI_NO_HYPERLINKS overrides plus a tmux>=3.4 gate so
563
+ // modern tmux forwards OSC 8 to outer terminals that opt in via
564
+ // `terminal-features "*:hyperlinks"`.
565
+ resolved.hyperlinks = shouldEnableHyperlinksByDefault(Bun.env, resolved.id);
566
+ // DECCARA rectangular-SGR background fills. The static per-terminal capability
567
+ // lives on KNOWN_TERMINALS; here we fold in runtime context — multiplexer and
568
+ // the PI_NO_DECCARA kill switch via detectRectangularSgrSupport — and force it
569
+ // off inside the test runtime so the xterm.js-backed virtual terminal (which
570
+ // ignores DECCARA) exercises the padded-string fallback. Integration tests opt
571
+ // in explicitly through setTerminalDeccara.
572
+ resolved.deccara = detectRectangularSgrSupport(resolved.id, Bun.env) && !isBunTestRuntime();
573
+ return resolved;
574
+ })();
575
+
576
+ // Seed Kitty Unicode placeholder support from the resolved terminal id. Only
577
+ // kitty/ghostty are known to honor `U=1` placement; other Kitty-protocol paths
578
+ // (wezterm, tmux/screen fallback) treat the placeholder cells as literal PUA
579
+ // glyphs, which is the "ASCII artifact + laggy scrolling" reported in #1877.
580
+ setKittyGraphics({ unicodePlaceholders: detectKittyUnicodePlaceholdersSupport(TERMINAL.id, Bun.env) });
581
+
582
+ /**
583
+ * Override terminal image protocol at runtime after capability probes complete.
584
+ */
585
+ export function setTerminalImageProtocol(imageProtocol: ImageProtocol | null): void {
586
+ TERMINAL.imageProtocol = imageProtocol;
587
+ }
588
+
589
+ /**
590
+ * Override DECCARA rectangular-SGR capability at runtime. Used by tests to
591
+ * exercise the optimizer and fallback paths deterministically — the default is
592
+ * resolved once at import and force-disabled under the test runtime.
593
+ */
594
+ export function setTerminalDeccara(enabled: boolean): void {
595
+ TERMINAL.deccara = enabled;
596
+ }
597
+
598
+ /** Override screen-to-scrollback clear support for targeted renderer tests. */
599
+ export function setTerminalScreenToScrollback(enabled: boolean): void {
600
+ TERMINAL.supportsScreenToScrollback = enabled;
601
+ }
602
+
603
+ /**
604
+ * Enable/disable OSC 66 text-sizing at runtime. The coding-agent calls this from
605
+ * the `tui.textSizing` setting (gated on the terminal's static `textSizing`
606
+ * capability); tests flip it directly to exercise the scaled-heading path.
607
+ */
608
+ export function setTerminalTextSizing(enabled: boolean): void {
609
+ TERMINAL.textSizing = enabled;
610
+ }
611
+
612
+ export function getTerminalInfo(
613
+ terminalId: TerminalId,
614
+ platform: NodeJS.Platform = process.platform,
615
+ env: NodeJS.ProcessEnv = Bun.env,
616
+ ): TerminalInfo {
617
+ return terminalId === "warp" ? getWarpTerminalInfo(platform, env) : KNOWN_TERMINALS[terminalId];
618
+ }
619
+
620
+ export interface CellDimensions {
621
+ widthPx: number;
622
+ heightPx: number;
623
+ }
624
+
625
+ export interface ImageDimensions {
626
+ widthPx: number;
627
+ heightPx: number;
628
+ }
629
+
630
+ export interface ImageRenderOptions {
631
+ maxWidthCells?: number;
632
+ maxHeightCells?: number;
633
+ preserveAspectRatio?: boolean;
634
+ /**
635
+ * Stable Kitty image id (`i=`). When set, the image is displayed via a
636
+ * transmit-once + placement scheme keyed off this id instead of re-sending the
637
+ * base64 each frame.
638
+ */
639
+ imageId?: number;
640
+ /** Stable Kitty placement id (`p=`); defaults to {@link imageId}. */
641
+ placementId?: number;
642
+ /** When true (Kitty + {@link imageId}), also return the one-time transmit sequence. */
643
+ includeTransmit?: boolean;
644
+ }
645
+
646
+ // Default cell dimensions - updated by TUI when terminal responds to query
647
+ let cellDimensions: CellDimensions = { widthPx: 9, heightPx: 18 };
648
+
649
+ export function getCellDimensions(): CellDimensions {
650
+ return cellDimensions;
651
+ }
652
+
653
+ export function setCellDimensions(dims: CellDimensions): void {
654
+ cellDimensions = dims;
655
+ }
656
+
657
+ function chunkKittyApc(leadParams: string, base64Data: string): string {
658
+ const CHUNK_SIZE = 4096;
659
+ if (base64Data.length <= CHUNK_SIZE) {
660
+ return wrapTmuxPassthroughIfNeeded(`\x1b_G${leadParams};${base64Data}\x1b\\`);
661
+ }
662
+
663
+ const chunks: string[] = [];
664
+ let offset = 0;
665
+ let isFirst = true;
666
+
667
+ while (offset < base64Data.length) {
668
+ const chunk = base64Data.slice(offset, offset + CHUNK_SIZE);
669
+ const isLast = offset + CHUNK_SIZE >= base64Data.length;
670
+
671
+ if (isFirst) {
672
+ chunks.push(wrapTmuxPassthroughIfNeeded(`\x1b_G${leadParams},m=1;${chunk}\x1b\\`));
673
+ isFirst = false;
674
+ } else if (isLast) {
675
+ chunks.push(wrapTmuxPassthroughIfNeeded(`\x1b_Gq=2,m=0;${chunk}\x1b\\`));
676
+ } else {
677
+ chunks.push(wrapTmuxPassthroughIfNeeded(`\x1b_Gq=2,m=1;${chunk}\x1b\\`));
678
+ }
679
+
680
+ offset += CHUNK_SIZE;
681
+ }
682
+
683
+ return chunks.join("");
684
+ }
685
+
686
+ /** Transmit-and-display (`a=T`) — the self-contained form used when no stable id is available. */
687
+ export function encodeKitty(
688
+ base64Data: string,
689
+ options: {
690
+ columns?: number;
691
+ rows?: number;
692
+ imageId?: number;
693
+ } = {},
694
+ ): string {
695
+ const params: string[] = ["a=T", "f=100", "q=2", "C=1"];
696
+ if (options.columns) params.push(`c=${options.columns}`);
697
+ if (options.rows) params.push(`r=${options.rows}`);
698
+ if (options.imageId) params.push(`i=${options.imageId}`);
699
+ return chunkKittyApc(params.join(","), base64Data);
700
+ }
701
+
702
+ /**
703
+ * Transmit image data only (`a=t`), keyed by `imageId`, without displaying it.
704
+ * Sent once per image; the data then persists in the terminal's store (it
705
+ * survives scroll-off and text clears for images with a non-zero id), so
706
+ * subsequent frames display it with the tiny {@link encodeKittyPlacement}
707
+ * sequence instead of re-sending the base64.
708
+ */
709
+ export function encodeKittyTransmit(base64Data: string, imageId: number): string {
710
+ return chunkKittyApc(`a=t,f=100,q=2,i=${imageId}`, base64Data);
711
+ }
712
+
713
+ /**
714
+ * Display a previously transmitted image (`a=p`) at the cursor. `C=1` keeps
715
+ * the terminal cursor anchored at the placement origin so the renderer's
716
+ * explicit cursor movement remains the only row accounting. Carrying a stable
717
+ * `placementId` (`p=`) means re-emitting the sequence on a repaint *replaces*
718
+ * the existing placement (moving/resizing it without flicker) rather than
719
+ * stacking a duplicate.
720
+ */
721
+ export function encodeKittyPlacement(options: {
722
+ imageId: number;
723
+ placementId?: number;
724
+ columns?: number;
725
+ rows?: number;
726
+ }): string {
727
+ const params: string[] = ["a=p", "q=2", "C=1", `i=${options.imageId}`];
728
+ if (options.placementId) params.push(`p=${options.placementId}`);
729
+ if (options.columns) params.push(`c=${options.columns}`);
730
+ if (options.rows) params.push(`r=${options.rows}`);
731
+ return wrapTmuxPassthroughIfNeeded(`\x1b_G${params.join(",")}\x1b\\`);
732
+ }
733
+
734
+ /**
735
+ * Kitty graphics delete command for a single image id. Uses `d=I` (capital)
736
+ * which removes the image and every one of its placements — on screen *and* in
737
+ * scrollback — and frees the backing data. `q=2` suppresses the terminal reply.
738
+ * Text-clearing escapes (`CSI 2 J` / `CSI 3 J`) do not remove Kitty graphics, so
739
+ * this is the only way to actually purge a placed image.
740
+ */
741
+ export function encodeKittyDeleteImage(imageId: number): string {
742
+ return wrapTmuxPassthroughIfNeeded(`\x1b_Ga=d,d=I,i=${imageId},q=2\x1b\\`);
743
+ }
744
+
745
+ export function encodeITerm2(
746
+ base64Data: string,
747
+ options: {
748
+ width?: number | string;
749
+ height?: number | string;
750
+ name?: string;
751
+ preserveAspectRatio?: boolean;
752
+ inline?: boolean;
753
+ } = {},
754
+ ): string {
755
+ const params: string[] = [`inline=${options.inline !== false ? 1 : 0}`];
756
+
757
+ if (options.width !== undefined) params.push(`width=${options.width}`);
758
+ if (options.height !== undefined) params.push(`height=${options.height}`);
759
+ if (options.name) {
760
+ const nameBase64 = Buffer.from(options.name).toBase64();
761
+ params.push(`name=${nameBase64}`);
762
+ }
763
+ if (options.preserveAspectRatio === false) {
764
+ params.push("preserveAspectRatio=0");
765
+ }
766
+
767
+ return `\x1b]1337;File=${params.join(";")}:${base64Data}\x07`;
768
+ }
769
+
770
+ export function calculateImageRows(
771
+ imageDimensions: ImageDimensions,
772
+ targetWidthCells: number,
773
+ cellDimensions: CellDimensions = { widthPx: 9, heightPx: 18 },
774
+ ): number {
775
+ const targetWidthPx = targetWidthCells * cellDimensions.widthPx;
776
+ const scale = targetWidthPx / imageDimensions.widthPx;
777
+ const scaledHeightPx = imageDimensions.heightPx * scale;
778
+ const rows = Math.ceil(scaledHeightPx / cellDimensions.heightPx);
779
+ return Math.max(1, rows);
780
+ }
781
+
782
+ function calculateImageFit(
783
+ imageDimensions: ImageDimensions,
784
+ options: ImageRenderOptions,
785
+ cellDims: CellDimensions,
786
+ ): { columns: number; rows: number } {
787
+ const maxColumns = options.maxWidthCells !== undefined ? Math.max(1, Math.floor(options.maxWidthCells)) : undefined;
788
+ const maxRows = options.maxHeightCells !== undefined ? Math.max(1, Math.floor(options.maxHeightCells)) : undefined;
789
+
790
+ if (maxColumns === undefined && maxRows === undefined) {
791
+ const columns = Math.max(1, Math.ceil(imageDimensions.widthPx / cellDims.widthPx));
792
+ const rows = Math.max(1, Math.ceil(imageDimensions.heightPx / cellDims.heightPx));
793
+ return { columns, rows };
794
+ }
795
+
796
+ const maxWidthPx = maxColumns !== undefined ? maxColumns * cellDims.widthPx : Number.POSITIVE_INFINITY;
797
+ const maxHeightPx = maxRows !== undefined ? maxRows * cellDims.heightPx : Number.POSITIVE_INFINITY;
798
+ const scale = Math.min(maxWidthPx / imageDimensions.widthPx, maxHeightPx / imageDimensions.heightPx);
799
+ const fittedWidthPx = imageDimensions.widthPx * scale;
800
+ const fittedHeightPx = imageDimensions.heightPx * scale;
801
+
802
+ const columns = Math.max(1, Math.floor(fittedWidthPx / cellDims.widthPx));
803
+ const rows = Math.max(1, Math.ceil(fittedHeightPx / cellDims.heightPx));
804
+
805
+ return {
806
+ columns: maxColumns !== undefined ? Math.min(columns, maxColumns) : columns,
807
+ rows: maxRows !== undefined ? Math.min(rows, maxRows) : rows,
808
+ };
809
+ }
810
+
811
+ export function getPngDimensions(base64Data: string): ImageDimensions | null {
812
+ try {
813
+ const buffer = Buffer.from(base64Data, "base64");
814
+
815
+ if (buffer.length < 24) {
816
+ return null;
817
+ }
818
+
819
+ if (buffer[0] !== 0x89 || buffer[1] !== 0x50 || buffer[2] !== 0x4e || buffer[3] !== 0x47) {
820
+ return null;
821
+ }
822
+
823
+ const width = buffer.readUInt32BE(16);
824
+ const height = buffer.readUInt32BE(20);
825
+
826
+ return { widthPx: width, heightPx: height };
827
+ } catch {
828
+ return null;
829
+ }
830
+ }
831
+
832
+ export function getJpegDimensions(base64Data: string): ImageDimensions | null {
833
+ try {
834
+ const buffer = Buffer.from(base64Data, "base64");
835
+
836
+ if (buffer.length < 2) {
837
+ return null;
838
+ }
839
+
840
+ if (buffer[0] !== 0xff || buffer[1] !== 0xd8) {
841
+ return null;
842
+ }
843
+
844
+ let offset = 2;
845
+ while (offset < buffer.length - 9) {
846
+ if (buffer[offset] !== 0xff) {
847
+ offset++;
848
+ continue;
849
+ }
850
+
851
+ const marker = buffer[offset + 1];
852
+
853
+ if (marker >= 0xc0 && marker <= 0xc2) {
854
+ const height = buffer.readUInt16BE(offset + 5);
855
+ const width = buffer.readUInt16BE(offset + 7);
856
+ return { widthPx: width, heightPx: height };
857
+ }
858
+
859
+ if (offset + 3 >= buffer.length) {
860
+ return null;
861
+ }
862
+ const length = buffer.readUInt16BE(offset + 2);
863
+ if (length < 2) {
864
+ return null;
865
+ }
866
+ offset += 2 + length;
867
+ }
868
+
869
+ return null;
870
+ } catch {
871
+ return null;
872
+ }
873
+ }
874
+
875
+ export function getGifDimensions(base64Data: string): ImageDimensions | null {
876
+ try {
877
+ const buffer = Buffer.from(base64Data, "base64");
878
+
879
+ if (buffer.length < 10) {
880
+ return null;
881
+ }
882
+
883
+ const sig = buffer.slice(0, 6).toString("ascii");
884
+ if (sig !== "GIF87a" && sig !== "GIF89a") {
885
+ return null;
886
+ }
887
+
888
+ const width = buffer.readUInt16LE(6);
889
+ const height = buffer.readUInt16LE(8);
890
+
891
+ return { widthPx: width, heightPx: height };
892
+ } catch {
893
+ return null;
894
+ }
895
+ }
896
+
897
+ export function getWebpDimensions(base64Data: string): ImageDimensions | null {
898
+ try {
899
+ const buffer = Buffer.from(base64Data, "base64");
900
+
901
+ if (buffer.length < 30) {
902
+ return null;
903
+ }
904
+
905
+ const riff = buffer.slice(0, 4).toString("ascii");
906
+ const webp = buffer.slice(8, 12).toString("ascii");
907
+ if (riff !== "RIFF" || webp !== "WEBP") {
908
+ return null;
909
+ }
910
+
911
+ const chunk = buffer.slice(12, 16).toString("ascii");
912
+ if (chunk === "VP8 ") {
913
+ if (buffer.length < 30) return null;
914
+ const width = buffer.readUInt16LE(26) & 0x3fff;
915
+ const height = buffer.readUInt16LE(28) & 0x3fff;
916
+ return { widthPx: width, heightPx: height };
917
+ } else if (chunk === "VP8L") {
918
+ if (buffer.length < 25) return null;
919
+ const bits = buffer.readUInt32LE(21);
920
+ const width = (bits & 0x3fff) + 1;
921
+ const height = ((bits >> 14) & 0x3fff) + 1;
922
+ return { widthPx: width, heightPx: height };
923
+ } else if (chunk === "VP8X") {
924
+ if (buffer.length < 30) return null;
925
+ const width = (buffer[24] | (buffer[25] << 8) | (buffer[26] << 16)) + 1;
926
+ const height = (buffer[27] | (buffer[28] << 8) | (buffer[29] << 16)) + 1;
927
+ return { widthPx: width, heightPx: height };
928
+ }
929
+
930
+ return null;
931
+ } catch {
932
+ return null;
933
+ }
934
+ }
935
+
936
+ export function getImageDimensions(base64Data: string, mimeType: string): ImageDimensions | null {
937
+ if (mimeType === "image/png") {
938
+ return getPngDimensions(base64Data);
939
+ }
940
+ if (mimeType === "image/jpeg") {
941
+ return getJpegDimensions(base64Data);
942
+ }
943
+ if (mimeType === "image/gif") {
944
+ return getGifDimensions(base64Data);
945
+ }
946
+ if (mimeType === "image/webp") {
947
+ return getWebpDimensions(base64Data);
948
+ }
949
+ return null;
950
+ }
951
+
952
+ export function renderImage(
953
+ base64Data: string,
954
+ imageDimensions: ImageDimensions,
955
+ options: ImageRenderOptions = {},
956
+ ): { sequence?: string; lines?: string[]; rows: number; transmit?: string } | null {
957
+ if (!TERMINAL.imageProtocol) {
958
+ return null;
959
+ }
960
+
961
+ const cellDims = getCellDimensions();
962
+ const fit = calculateImageFit(imageDimensions, options, cellDims);
963
+
964
+ if (TERMINAL.imageProtocol === ImageProtocol.Kitty) {
965
+ if (options.imageId != null) {
966
+ const placementId = options.placementId ?? options.imageId;
967
+ const graphics = getKittyGraphics();
968
+ // Transmit-once (keyed by id). Repaints reuse the stored image, so the
969
+ // transmit is only emitted when requested.
970
+ let transmit: string | undefined;
971
+ if (options.includeTransmit) {
972
+ transmit = encodeKittyTransmit(base64Data, options.imageId);
973
+ }
974
+ // Unicode placeholders render the image as real text cells (which survive
975
+ // horizontal slicing, reflow and overlaps) instead of a cursor-positioned
976
+ // `a=p` placement. Falls back to direct placement when disabled or when the
977
+ // grid exceeds the diacritic table's addressable cell range.
978
+ if (graphics.unicodePlaceholders && kittyPlaceholdersFit(fit.columns, fit.rows)) {
979
+ const lines = renderKittyPlaceholderLines({
980
+ imageId: options.imageId,
981
+ placementId,
982
+ columns: fit.columns,
983
+ rows: fit.rows,
984
+ });
985
+ return { lines, rows: fit.rows, transmit };
986
+ }
987
+ // Direct placement: re-emit only the tiny `a=p` on repaints.
988
+ const sequence = encodeKittyPlacement({
989
+ imageId: options.imageId,
990
+ placementId,
991
+ columns: fit.columns,
992
+ rows: fit.rows,
993
+ });
994
+ return { sequence, rows: fit.rows, transmit };
995
+ }
996
+ // No stable id (e.g. no budget): self-contained transmit-and-display.
997
+ const sequence = encodeKitty(base64Data, {
998
+ columns: fit.columns,
999
+ rows: fit.rows,
1000
+ });
1001
+ return { sequence, rows: fit.rows };
1002
+ }
1003
+
1004
+ if (TERMINAL.imageProtocol === ImageProtocol.Sixel) {
1005
+ try {
1006
+ // SIXEL encodes in 6-pixel vertical bands. A height that is not a
1007
+ // multiple of 6 is padded with transparent rows, but the terminal
1008
+ // still allocates cell rows for the padded height. When the padded
1009
+ // height crosses a cell boundary the terminal uses one more row
1010
+ // than fit.rows, so the next line of content overwrites the bottom
1011
+ // of the image — a visible slice stripped from the image. Round the
1012
+ // encode height DOWN to the largest multiple of 6 that fits within
1013
+ // the requested row budget, so the band boundary aligns without
1014
+ // padding and the reserved row count never exceeds fit.rows. Scale
1015
+ // the width by the same ratio so resize_exact preserves the aspect
1016
+ // ratio instead of squashing the image vertically.
1017
+ const rawHeightPx = Math.max(1, fit.rows * cellDims.heightPx);
1018
+ const targetHeightPx = Math.max(6, Math.floor(rawHeightPx / 6) * 6);
1019
+ const heightScale = targetHeightPx / rawHeightPx;
1020
+ const targetWidthPx = Math.max(1, Math.round(fit.columns * cellDims.widthPx * heightScale));
1021
+ const rows = Math.max(1, Math.ceil(targetHeightPx / cellDims.heightPx));
1022
+ const decoded = new Uint8Array(Buffer.from(base64Data, "base64"));
1023
+ const sequence = encodeSixel(decoded, targetWidthPx, targetHeightPx);
1024
+ return { sequence, rows };
1025
+ } catch {
1026
+ return null;
1027
+ }
1028
+ }
1029
+ if (TERMINAL.imageProtocol === ImageProtocol.Iterm2) {
1030
+ const sequence = encodeITerm2(base64Data, {
1031
+ width: fit.columns,
1032
+ height: "auto",
1033
+ preserveAspectRatio: options.preserveAspectRatio ?? true,
1034
+ });
1035
+ return { sequence, rows: fit.rows };
1036
+ }
1037
+
1038
+ return null;
1039
+ }
1040
+
1041
+ export function imageFallback(mimeType: string, dimensions?: ImageDimensions, filename?: string): string {
1042
+ const parts: string[] = [];
1043
+ if (filename) parts.push(filename);
1044
+ parts.push(`[${mimeType}]`);
1045
+ if (dimensions) parts.push(`${dimensions.widthPx}x${dimensions.heightPx}`);
1046
+ return `[Image: ${parts.join(" ")}]`;
1047
+ }
1048
+
1049
+ /**
1050
+ * Structured terminal notification. Rich fields are honored only by OSC 99
1051
+ * (Kitty) once support is confirmed; other protocols and the unconfirmed Kitty
1052
+ * path collapse to a single `title: body` line.
1053
+ */
1054
+ export interface TerminalNotification {
1055
+ title?: string;
1056
+ body?: string;
1057
+ id?: string;
1058
+ type?: string | string[];
1059
+ urgency?: "low" | "normal" | "critical";
1060
+ iconName?: string;
1061
+ sound?: "silent" | "system" | "info" | "warning" | "error" | "question";
1062
+ actions?: "focus" | "report" | "focus-report" | "none";
1063
+ expiresMs?: number;
1064
+ }
1065
+
1066
+ /**
1067
+ * Whether the terminal confirmed OSC 99 desktop-notification support via the
1068
+ * `p=?` query probe. Until confirmed, structured notifications collapse to a
1069
+ * single message line.
1070
+ */
1071
+ let osc99CapabilitiesConfirmed = false;
1072
+
1073
+ /** Record the OSC 99 capability-probe result (called by ProcessTerminal). */
1074
+ export function setOsc99Supported(supported: boolean): void {
1075
+ osc99CapabilitiesConfirmed = supported;
1076
+ }
1077
+
1078
+ /** True when OSC 99 structured notifications have been confirmed available. */
1079
+ export function isOsc99Supported(): boolean {
1080
+ return osc99CapabilitiesConfirmed;
1081
+ }
1082
+
1083
+ /** Collapse a structured notification to a single line for non-OSC-99 sinks. */
1084
+ function notificationToLine(n: TerminalNotification): string {
1085
+ if (n.title && n.body) return `${n.title}: ${n.body}`;
1086
+ return n.title ?? n.body ?? "";
1087
+ }
1088
+
1089
+ // C0/C1 control characters that are unsafe inside an OSC payload (must base64).
1090
+ const OSC99_UNSAFE = /[\x00-\x1f\x7f\x80-\x9f]/u;
1091
+ const OSC99_MAX_PAYLOAD_BYTES = 2048;
1092
+ const OSC99_APP_NAME = "Zeta";
1093
+ let nextOsc99NotificationId = 1;
1094
+
1095
+ function base64Utf8(value: string): string {
1096
+ return Buffer.from(value, "utf8").toString("base64");
1097
+ }
1098
+
1099
+ function sanitizeOsc99Id(id: string | undefined): string {
1100
+ if (!id) return "";
1101
+ const safe = id.replace(/[^a-zA-Z0-9_+\-.]/gu, "");
1102
+ return safe === "0" ? "" : safe;
1103
+ }
1104
+
1105
+ function osc99Id(id: string | undefined): string {
1106
+ return sanitizeOsc99Id(id) || `omp-${nextOsc99NotificationId++}`;
1107
+ }
1108
+
1109
+ function utf8CodePointBytes(char: string): number {
1110
+ const codePoint = char.codePointAt(0) ?? 0;
1111
+ if (codePoint <= 0x7f) return 1;
1112
+ if (codePoint <= 0x7ff) return 2;
1113
+ if (codePoint <= 0xffff) return 3;
1114
+ return 4;
1115
+ }
1116
+
1117
+ function chunkUtf8(payload: string): string[] {
1118
+ if (payload === "") return [""];
1119
+ const chunks: string[] = [];
1120
+ let start = 0;
1121
+ let index = 0;
1122
+ let bytes = 0;
1123
+ for (const char of payload) {
1124
+ const charBytes = utf8CodePointBytes(char);
1125
+ if (bytes > 0 && bytes + charBytes > OSC99_MAX_PAYLOAD_BYTES) {
1126
+ chunks.push(payload.slice(start, index));
1127
+ start = index;
1128
+ bytes = 0;
1129
+ }
1130
+ bytes += charBytes;
1131
+ index += char.length;
1132
+ }
1133
+ chunks.push(payload.slice(start));
1134
+ return chunks;
1135
+ }
1136
+
1137
+ function osc99Chunk(meta: string[], payload: string): string {
1138
+ if (OSC99_UNSAFE.test(payload)) {
1139
+ return `\x1b]99;${[...meta, "e=1"].join(":")};${base64Utf8(payload)}\x1b\\`;
1140
+ }
1141
+ return `\x1b]99;${meta.join(":")};${payload}\x1b\\`;
1142
+ }
1143
+
1144
+ function osc99Payload(meta: string[], payload: string, holdUntilLaterPayload: boolean): string {
1145
+ const chunks = chunkUtf8(payload);
1146
+ let out = "";
1147
+ for (let i = 0; i < chunks.length; i++) {
1148
+ const chunkMeta = [...meta];
1149
+ if (holdUntilLaterPayload || i < chunks.length - 1) chunkMeta.push("d=0");
1150
+ out += osc99Chunk(chunkMeta, chunks[i]!);
1151
+ }
1152
+ return out;
1153
+ }
1154
+
1155
+ function osc99Urgency(urgency: TerminalNotification["urgency"]): string | undefined {
1156
+ switch (urgency) {
1157
+ case "low":
1158
+ return "0";
1159
+ case "normal":
1160
+ return "1";
1161
+ case "critical":
1162
+ return "2";
1163
+ default:
1164
+ return undefined;
1165
+ }
1166
+ }
1167
+
1168
+ function osc99Actions(actions: TerminalNotification["actions"]): string | undefined {
1169
+ switch (actions) {
1170
+ case "focus":
1171
+ return "focus";
1172
+ case "report":
1173
+ return "report";
1174
+ case "focus-report":
1175
+ return "focus,report";
1176
+ case "none":
1177
+ return "-focus";
1178
+ default:
1179
+ return undefined;
1180
+ }
1181
+ }
1182
+
1183
+ /**
1184
+ * Format a structured notification as OSC 99 title/body payloads. Title and
1185
+ * body chunks share one id. Every non-final chunk carries `d=0`; the final
1186
+ * title or body chunk displays the notification. Metadata values that require
1187
+ * it (application name, type, icon name, sound) are base64-encoded.
1188
+ */
1189
+ function formatOsc99Notification(n: TerminalNotification): string {
1190
+ const id = osc99Id(n.id);
1191
+ const meta: string[] = [`i=${id}`, `f=${base64Utf8(OSC99_APP_NAME)}`];
1192
+ const actions = osc99Actions(n.actions);
1193
+ if (actions) meta.push(`a=${actions}`);
1194
+ const urgency = osc99Urgency(n.urgency);
1195
+ if (urgency) meta.push(`u=${urgency}`);
1196
+ const types = n.type === undefined ? [] : Array.isArray(n.type) ? n.type : [n.type];
1197
+ for (const t of types) meta.push(`t=${base64Utf8(t)}`);
1198
+ if (n.iconName) meta.push(`n=${base64Utf8(n.iconName)}`);
1199
+ if (n.sound) meta.push(`s=${base64Utf8(n.sound)}`);
1200
+ if (n.expiresMs !== undefined && Number.isFinite(n.expiresMs)) {
1201
+ meta.push(`w=${Math.max(-1, Math.trunc(n.expiresMs))}`);
1202
+ }
1203
+
1204
+ const title = n.title ?? n.body ?? "";
1205
+ const body = n.title ? n.body : undefined;
1206
+
1207
+ if (body !== undefined && body !== "") {
1208
+ return osc99Payload(meta, title, true) + osc99Payload([`i=${id}`, "p=body"], body, false);
1209
+ }
1210
+ return osc99Payload(meta, title, false);
1211
+ }