@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,1009 @@
1
+ import { encodeSixel } from "@prometheus-ai/natives";
2
+ import { $env, isBunTestRuntime } from "@prometheus-ai/utils";
3
+ import {
4
+ detectKittyUnicodePlaceholdersSupport,
5
+ encodeKittyTempFileTransmit,
6
+ getKittyGraphics,
7
+ isPngBase64,
8
+ KITTY_PLACEHOLDER,
9
+ kittyPlaceholdersFit,
10
+ renderKittyPlaceholderLines,
11
+ setKittyGraphics,
12
+ } from "./kitty-graphics";
13
+
14
+ export enum ImageProtocol {
15
+ Kitty = "\x1b_G",
16
+ Iterm2 = "\x1b]1337;File=",
17
+ Sixel = "\x1bPq",
18
+ }
19
+
20
+ export enum NotifyProtocol {
21
+ Bell = "\x07",
22
+ Osc99 = "\x1b]99;;",
23
+ Osc9 = "\x1b]9;",
24
+ }
25
+
26
+ export type TerminalId = "kitty" | "ghostty" | "wezterm" | "iterm2" | "vscode" | "alacritty" | "base" | "trueColor";
27
+
28
+ const SIXEL_DCS_START_REGEX = /\x1bP(?:[0-9;]*)q/u;
29
+ /** Terminal capability details used for rendering and protocol selection. */
30
+ export class TerminalInfo {
31
+ constructor(
32
+ public readonly id: TerminalId,
33
+ public readonly imageProtocol: ImageProtocol | null,
34
+ public readonly trueColor: boolean,
35
+ public readonly hyperlinks: boolean,
36
+ public readonly notifyProtocol: NotifyProtocol = NotifyProtocol.Bell,
37
+ public readonly eagerEraseScrollbackRisk: boolean = false,
38
+ public readonly deccara: boolean = false,
39
+ readonly supportsScreenToScrollback: boolean = false,
40
+ /** Renders the Kitty OSC 66 text-sizing protocol (scaled spans). Kitty only. */
41
+ public readonly textSizing: boolean = false,
42
+ ) {}
43
+
44
+ isImageLine(line: string): boolean {
45
+ if (!this.imageProtocol) return false;
46
+ if (this.imageProtocol === ImageProtocol.Sixel) {
47
+ return SIXEL_DCS_START_REGEX.test(line.slice(0, 128));
48
+ }
49
+ const head = line.slice(0, 64);
50
+ return head.includes(this.imageProtocol) || head.includes(KITTY_PLACEHOLDER);
51
+ }
52
+
53
+ formatNotification(message: string | TerminalNotification): string {
54
+ if (this.notifyProtocol === NotifyProtocol.Bell) {
55
+ return NotifyProtocol.Bell;
56
+ }
57
+ // Structured notifications use OSC 99's rich metadata only once the
58
+ // terminal confirms support; otherwise collapse to a single message line
59
+ // (basic OSC 99 / OSC 9 still work).
60
+ if (typeof message !== "string") {
61
+ if (this.notifyProtocol === NotifyProtocol.Osc99 && osc99CapabilitiesConfirmed) {
62
+ return formatOsc99Notification(message);
63
+ }
64
+ return `${this.notifyProtocol}${notificationToLine(message)}\x1b\\`;
65
+ }
66
+ return `${this.notifyProtocol}${message}\x1b\\`;
67
+ }
68
+
69
+ sendNotification(message: string | TerminalNotification): void {
70
+ if (isNotificationSuppressed()) return;
71
+ process.stdout.write(this.formatNotification(message));
72
+ }
73
+ }
74
+
75
+ export function isNotificationSuppressed(): boolean {
76
+ const value = $env.PROMETHEUS_NOTIFICATIONS;
77
+ if (!value) return false;
78
+ return value === "off" || value === "0" || value === "false";
79
+ }
80
+
81
+ function getForcedImageProtocol(): ImageProtocol | null | undefined {
82
+ const raw = $env.PROMETHEUS_FORCE_IMAGE_PROTOCOL?.trim().toLowerCase();
83
+ if (!raw) return undefined;
84
+ if (raw === "kitty") return ImageProtocol.Kitty;
85
+ if (raw === "iterm2" || raw === "iterm") return ImageProtocol.Iterm2;
86
+ if (raw === "sixel") return ImageProtocol.Sixel;
87
+ if (raw === "off" || raw === "none" || raw === "0" || raw === "false") return null;
88
+ return null;
89
+ }
90
+
91
+ function parseMajorMinorVersion(versionRaw?: string): { major: number; minor: number } | null {
92
+ if (!versionRaw) return null;
93
+ const match = /^(\d+)\.(\d+)/u.exec(versionRaw.trim());
94
+ if (!match) return null;
95
+ const major = Number.parseInt(match[1] ?? "", 10);
96
+ const minor = Number.parseInt(match[2] ?? "", 10);
97
+ if (!Number.isFinite(major) || !Number.isFinite(minor)) return null;
98
+ return { major, minor };
99
+ }
100
+
101
+ /**
102
+ * Returns true when running in Windows Terminal with known SIXEL support.
103
+ *
104
+ * Windows Terminal introduced SIXEL support in preview 1.22.
105
+ */
106
+ export function isWindowsTerminalPreviewSixelSupported(
107
+ env: NodeJS.ProcessEnv = Bun.env,
108
+ platform: NodeJS.Platform = process.platform,
109
+ ): boolean {
110
+ if (platform !== "win32") return false;
111
+ if (!env.WT_SESSION) return false;
112
+ if (env.TERM_PROGRAM && env.TERM_PROGRAM.toLowerCase() !== "windows_terminal") {
113
+ return false;
114
+ }
115
+ const version = parseMajorMinorVersion(env.TERM_PROGRAM_VERSION);
116
+ if (!version) return false;
117
+ return version.major > 1 || (version.major === 1 && version.minor >= 22);
118
+ }
119
+
120
+ /**
121
+ * Whether live-frame native scrollback rebuilds are unsafe when the terminal
122
+ * viewport position is unobservable.
123
+ *
124
+ * A TUI history rebuild emits xterm ED3 (`CSI 3 J`, erase saved lines). Many
125
+ * terminals either clamp a scrolled reader back to the active tail or erase host
126
+ * scrollback when ED3 lands. The important property is not the brand name — it
127
+ * is that an unknown viewport position cannot be proven safe. Environment
128
+ * markers are therefore only used to prove *risk* or a strongly-known profile;
129
+ * unknown POSIX/remote/multiplexer shapes default to risky for passive renders.
130
+ *
131
+ * Native win32 is excluded here because the renderer has dedicated ConPTY
132
+ * deferral paths; a `WT_SESSION` sighting on POSIX means Windows Terminal is the
133
+ * outer host fronting WSL, where the same ED3 yank applies. See #1610/#1682/#1799.
134
+ */
135
+ export function detectTerminalEagerEraseScrollbackRisk(
136
+ env: NodeJS.ProcessEnv = Bun.env,
137
+ platform: NodeJS.Platform = process.platform,
138
+ ): boolean {
139
+ if (platform === "win32") return false;
140
+
141
+ const term = env.TERM?.toLowerCase() ?? "";
142
+ const termProgram = env.TERM_PROGRAM?.toLowerCase() ?? "";
143
+ const colorTerm = env.COLORTERM?.toLowerCase() ?? "";
144
+
145
+ if (env.PROMETHEUS_TUI_ED3_SAFE === "1") return false;
146
+ if (env.WT_SESSION) return true;
147
+ if (
148
+ env.SSH_CONNECTION ||
149
+ env.SSH_CLIENT ||
150
+ env.SSH_TTY ||
151
+ env.TMUX ||
152
+ env.STY ||
153
+ env.ZELLIJ ||
154
+ term.startsWith("tmux") ||
155
+ term.startsWith("screen")
156
+ ) {
157
+ return true;
158
+ }
159
+ if (
160
+ env.WEZTERM_PANE ||
161
+ env.KITTY_WINDOW_ID ||
162
+ env.GHOSTTY_RESOURCES_DIR ||
163
+ env.ALACRITTY_WINDOW_ID ||
164
+ env.VTE_VERSION ||
165
+ env.ITERM_SESSION_ID
166
+ ) {
167
+ return true;
168
+ }
169
+ switch (termProgram) {
170
+ case "alacritty":
171
+ case "apple_terminal":
172
+ case "ghostty":
173
+ case "gnome-terminal":
174
+ case "iterm.app":
175
+ case "kgx":
176
+ case "kitty":
177
+ case "ptyxis":
178
+ case "wezterm":
179
+ case "xfce4-terminal":
180
+ return true;
181
+ default:
182
+ break;
183
+ }
184
+ if (platform === "linux" && (colorTerm === "truecolor" || colorTerm === "24bit")) return true;
185
+ // Unknown POSIX terminals have no scroll-position oracle. Treat them as risky
186
+ // for passive ED3 until a positive terminal-specific integration proves safe.
187
+ return true;
188
+ }
189
+
190
+ /** Whether DEC 2026 synchronized-output wrappers should be enabled by default. */
191
+ export function shouldEnableSynchronizedOutputByDefault(
192
+ env: NodeJS.ProcessEnv = Bun.env,
193
+ platform: NodeJS.Platform = process.platform,
194
+ terminalId: TerminalId = TERMINAL_ID,
195
+ ): boolean {
196
+ if (env.PROMETHEUS_NO_SYNC_OUTPUT || env.PROMETHEUS_TUI_SYNC_OUTPUT === "0") return false;
197
+ if (env.PROMETHEUS_FORCE_SYNC_OUTPUT === "1" || env.PROMETHEUS_TUI_SYNC_OUTPUT === "1") return true;
198
+ if (platform === "win32") return false;
199
+
200
+ const term = env.TERM?.toLowerCase() ?? "";
201
+ const termProgram = env.TERM_PROGRAM?.toLowerCase() ?? "";
202
+ if (
203
+ env.SSH_CONNECTION ||
204
+ env.SSH_CLIENT ||
205
+ env.SSH_TTY ||
206
+ env.TMUX ||
207
+ env.STY ||
208
+ env.ZELLIJ ||
209
+ term.startsWith("tmux") ||
210
+ term.startsWith("screen")
211
+ ) {
212
+ return false;
213
+ }
214
+ if (env.VTE_VERSION) return false;
215
+ switch (termProgram) {
216
+ case "gnome-terminal":
217
+ case "kgx":
218
+ case "ptyxis":
219
+ case "xfce4-terminal":
220
+ return false;
221
+ default:
222
+ break;
223
+ }
224
+ return terminalId === "kitty" || terminalId === "ghostty" || terminalId === "wezterm" || terminalId === "iterm2";
225
+ }
226
+
227
+ /**
228
+ * Whether the terminal applies Kitty-style DECCARA rectangular SGR changes
229
+ * (`CSI Pt ; Pl ; Pb ; Pr ; <sgr> $ r`) extended to background color, so large
230
+ * filled regions can be painted as rectangles instead of background-padded
231
+ * strings on every row.
232
+ *
233
+ * Verified against terminal sources rather than terminfo, because a bare
234
+ * `Cara`/DECCARA terminfo capability does not imply the Kitty SGR-background
235
+ * extension:
236
+ * - Kitty implements it for *all* SGR attributes including background (see
237
+ * kitty `docs/deccara.rst` and the `test_deccara` parser test).
238
+ * - Ghostty does NOT: its `CSI $ r` dispatch falls through to an "unknown CSI"
239
+ * warning and DECCARA/DECSACE are tracked as unsupported
240
+ * (ghostty-org/ghostty#632). Enabling it there would silently drop panel
241
+ * backgrounds, so ghostty stays on the padded-string fallback.
242
+ *
243
+ * Disabled under tmux/screen/zellij multiplexers — screen-coordinate rectangle
244
+ * protocols are not safe to assume through a multiplexer — and via the
245
+ * `PROMETHEUS_NO_DECCARA` kill switch. Pure helper for tests and `TERMINAL` construction.
246
+ */
247
+ export function detectRectangularSgrSupport(terminalId: TerminalId, env: NodeJS.ProcessEnv = Bun.env): boolean {
248
+ if (terminalId !== "kitty") return false;
249
+ const kill = env.PROMETHEUS_NO_DECCARA;
250
+ if (kill && kill !== "0" && kill.toLowerCase() !== "false") return false;
251
+ const term = env.TERM?.toLowerCase() ?? "";
252
+ if (env.TMUX || env.STY || env.ZELLIJ || term.startsWith("tmux") || term.startsWith("screen")) {
253
+ return false;
254
+ }
255
+ return true;
256
+ }
257
+ function getFallbackImageProtocol(terminalId: TerminalId): ImageProtocol | null {
258
+ if (!process.stdout.isTTY) return null;
259
+ if (terminalId === "vscode" || terminalId === "alacritty") return null;
260
+ const term = Bun.env.TERM?.toLowerCase() ?? "";
261
+ if (term.includes("screen") || term.includes("tmux") || term.includes("ghostty")) {
262
+ return ImageProtocol.Kitty;
263
+ }
264
+ return null;
265
+ }
266
+ const KNOWN_TERMINALS = Object.freeze({
267
+ // Fallback terminals
268
+ base: new TerminalInfo("base", null, false, false, NotifyProtocol.Bell),
269
+ trueColor: new TerminalInfo("trueColor", null, true, false, NotifyProtocol.Bell),
270
+ // Recognized terminals
271
+ kitty: new TerminalInfo("kitty", ImageProtocol.Kitty, true, true, NotifyProtocol.Osc99, true, true, true, true),
272
+ ghostty: new TerminalInfo("ghostty", ImageProtocol.Kitty, true, true, NotifyProtocol.Osc9, true),
273
+ wezterm: new TerminalInfo("wezterm", ImageProtocol.Kitty, true, true, NotifyProtocol.Osc9, true),
274
+ iterm2: new TerminalInfo("iterm2", ImageProtocol.Iterm2, true, true, NotifyProtocol.Osc9, true),
275
+ vscode: new TerminalInfo("vscode", null, true, true, NotifyProtocol.Bell),
276
+ alacritty: new TerminalInfo("alacritty", null, true, true, NotifyProtocol.Bell, true),
277
+ });
278
+
279
+ export const TERMINAL_ID: TerminalId = (() => {
280
+ function caseEq(a: string, b: string): boolean {
281
+ return a.toLowerCase() === b.toLowerCase(); // For compiler to pattern match
282
+ }
283
+
284
+ const {
285
+ KITTY_WINDOW_ID,
286
+ GHOSTTY_RESOURCES_DIR,
287
+ WEZTERM_PANE,
288
+ ITERM_SESSION_ID,
289
+ VSCODE_PID,
290
+ ALACRITTY_WINDOW_ID,
291
+ TERM_PROGRAM,
292
+ TERM,
293
+ COLORTERM,
294
+ } = Bun.env;
295
+
296
+ if (KITTY_WINDOW_ID) return "kitty";
297
+ if (GHOSTTY_RESOURCES_DIR) return "ghostty";
298
+ if (WEZTERM_PANE) return "wezterm";
299
+ if (ITERM_SESSION_ID) return "iterm2";
300
+ if (VSCODE_PID) return "vscode";
301
+ if (ALACRITTY_WINDOW_ID) return "alacritty";
302
+
303
+ if (TERM_PROGRAM) {
304
+ if (caseEq(TERM_PROGRAM, "kitty")) return "kitty";
305
+ if (caseEq(TERM_PROGRAM, "ghostty")) return "ghostty";
306
+ if (caseEq(TERM_PROGRAM, "wezterm")) return "wezterm";
307
+ if (caseEq(TERM_PROGRAM, "iterm.app")) return "iterm2";
308
+ if (caseEq(TERM_PROGRAM, "vscode")) return "vscode";
309
+ if (caseEq(TERM_PROGRAM, "alacritty")) return "alacritty";
310
+ }
311
+
312
+ if (TERM?.toLowerCase().includes("ghostty")) return "ghostty";
313
+
314
+ if (COLORTERM) {
315
+ if (caseEq(COLORTERM, "truecolor") || caseEq(COLORTERM, "24bit")) return "trueColor";
316
+ }
317
+ return "base";
318
+ })();
319
+
320
+ /** Clone a {@link TerminalInfo} with selected fields overridden, preserving the rest. */
321
+ function withTerminalOverrides(
322
+ base: TerminalInfo,
323
+ overrides: {
324
+ imageProtocol?: ImageProtocol | null;
325
+ hyperlinks?: boolean;
326
+ eagerEraseScrollbackRisk?: boolean;
327
+ deccara?: boolean;
328
+ supportsScreenToScrollback?: boolean;
329
+ },
330
+ ): TerminalInfo {
331
+ return new TerminalInfo(
332
+ base.id,
333
+ overrides.imageProtocol !== undefined ? overrides.imageProtocol : base.imageProtocol,
334
+ base.trueColor,
335
+ overrides.hyperlinks !== undefined ? overrides.hyperlinks : base.hyperlinks,
336
+ base.notifyProtocol,
337
+ overrides.eagerEraseScrollbackRisk !== undefined
338
+ ? overrides.eagerEraseScrollbackRisk
339
+ : base.eagerEraseScrollbackRisk,
340
+ overrides.deccara !== undefined ? overrides.deccara : base.deccara,
341
+ overrides.supportsScreenToScrollback !== undefined
342
+ ? overrides.supportsScreenToScrollback
343
+ : base.supportsScreenToScrollback,
344
+ );
345
+ }
346
+
347
+ export const TERMINAL = (() => {
348
+ let resolved = getTerminalInfo(TERMINAL_ID);
349
+ const eagerEraseScrollbackRisk = detectTerminalEagerEraseScrollbackRisk(Bun.env, process.platform);
350
+ if (resolved.eagerEraseScrollbackRisk !== eagerEraseScrollbackRisk) {
351
+ resolved = withTerminalOverrides(resolved, { eagerEraseScrollbackRisk });
352
+ }
353
+
354
+ const forcedImageProtocol = getForcedImageProtocol();
355
+ if (forcedImageProtocol !== undefined) {
356
+ resolved = withTerminalOverrides(resolved, { imageProtocol: forcedImageProtocol });
357
+ } else if (!resolved.imageProtocol) {
358
+ const fallbackImageProtocol = getFallbackImageProtocol(resolved.id);
359
+ if (fallbackImageProtocol) {
360
+ resolved = withTerminalOverrides(resolved, { imageProtocol: fallbackImageProtocol });
361
+ }
362
+ }
363
+ // tmux and screen multiplexers do not reliably forward OSC 8 hyperlinks
364
+ // to the outer terminal, so force them off regardless of detected terminal.
365
+ const term = Bun.env.TERM?.toLowerCase() ?? "";
366
+ if (resolved.hyperlinks && (Bun.env.TMUX || term.startsWith("tmux") || term.startsWith("screen"))) {
367
+ resolved = withTerminalOverrides(resolved, { hyperlinks: false });
368
+ }
369
+ // DECCARA rectangular-SGR background fills. The static per-terminal capability
370
+ // lives on KNOWN_TERMINALS; here we fold in runtime context — multiplexer and
371
+ // the PROMETHEUS_NO_DECCARA kill switch via detectRectangularSgrSupport — and force it
372
+ // off inside the test runtime so the xterm.js-backed virtual terminal (which
373
+ // ignores DECCARA) exercises the padded-string fallback. Integration tests opt
374
+ // in explicitly through setTerminalDeccara.
375
+ const deccara = detectRectangularSgrSupport(resolved.id, Bun.env) && !isBunTestRuntime();
376
+ if (resolved.deccara !== deccara) {
377
+ resolved = withTerminalOverrides(resolved, { deccara });
378
+ }
379
+ return resolved;
380
+ })();
381
+
382
+ // Seed Kitty Unicode placeholder support from the resolved terminal id. Only
383
+ // kitty/ghostty are known to honor `U=1` placement; other Kitty-protocol paths
384
+ // (wezterm, tmux/screen fallback) treat the placeholder cells as literal PUA
385
+ // glyphs, which is the "ASCII artifact + laggy scrolling" reported in #1877.
386
+ setKittyGraphics({ unicodePlaceholders: detectKittyUnicodePlaceholdersSupport(TERMINAL.id, Bun.env) });
387
+
388
+ type MutableTerminalInfo = {
389
+ imageProtocol: ImageProtocol | null;
390
+ deccara: boolean;
391
+ supportsScreenToScrollback: boolean;
392
+ textSizing: boolean;
393
+ };
394
+
395
+ /**
396
+ * Override terminal image protocol at runtime after capability probes complete.
397
+ */
398
+ export function setTerminalImageProtocol(imageProtocol: ImageProtocol | null): void {
399
+ (TERMINAL as unknown as MutableTerminalInfo).imageProtocol = imageProtocol;
400
+ }
401
+
402
+ /**
403
+ * Override DECCARA rectangular-SGR capability at runtime. Used by tests to
404
+ * exercise the optimizer and fallback paths deterministically — the default is
405
+ * resolved once at import and force-disabled under the test runtime.
406
+ */
407
+ export function setTerminalDeccara(enabled: boolean): void {
408
+ (TERMINAL as unknown as MutableTerminalInfo).deccara = enabled;
409
+ }
410
+
411
+ /** Override screen-to-scrollback clear support for targeted renderer tests. */
412
+ export function setTerminalScreenToScrollback(enabled: boolean): void {
413
+ (TERMINAL as unknown as MutableTerminalInfo).supportsScreenToScrollback = enabled;
414
+ }
415
+
416
+ /**
417
+ * Enable/disable OSC 66 text-sizing at runtime. The coding-agent calls this from
418
+ * the `tui.textSizing` setting (gated on the terminal's static `textSizing`
419
+ * capability); tests flip it directly to exercise the scaled-heading path.
420
+ */
421
+ export function setTerminalTextSizing(enabled: boolean): void {
422
+ (TERMINAL as unknown as MutableTerminalInfo).textSizing = enabled;
423
+ }
424
+
425
+ export function getTerminalInfo(terminalId: TerminalId): TerminalInfo {
426
+ return KNOWN_TERMINALS[terminalId];
427
+ }
428
+
429
+ export interface CellDimensions {
430
+ widthPx: number;
431
+ heightPx: number;
432
+ }
433
+
434
+ export interface ImageDimensions {
435
+ widthPx: number;
436
+ heightPx: number;
437
+ }
438
+
439
+ export interface ImageRenderOptions {
440
+ maxWidthCells?: number;
441
+ maxHeightCells?: number;
442
+ preserveAspectRatio?: boolean;
443
+ /**
444
+ * Stable Kitty image id (`i=`). When set, the image is displayed via a
445
+ * transmit-once + placement scheme keyed off this id instead of re-sending the
446
+ * base64 each frame.
447
+ */
448
+ imageId?: number;
449
+ /** Stable Kitty placement id (`p=`); defaults to {@link imageId}. */
450
+ placementId?: number;
451
+ /** When true (Kitty + {@link imageId}), also return the one-time transmit sequence. */
452
+ includeTransmit?: boolean;
453
+ }
454
+
455
+ // Default cell dimensions - updated by TUI when terminal responds to query
456
+ let cellDimensions: CellDimensions = { widthPx: 9, heightPx: 18 };
457
+
458
+ export function getCellDimensions(): CellDimensions {
459
+ return cellDimensions;
460
+ }
461
+
462
+ export function setCellDimensions(dims: CellDimensions): void {
463
+ cellDimensions = dims;
464
+ }
465
+
466
+ function chunkKittyApc(leadParams: string, base64Data: string): string {
467
+ const CHUNK_SIZE = 4096;
468
+ if (base64Data.length <= CHUNK_SIZE) {
469
+ return `\x1b_G${leadParams};${base64Data}\x1b\\`;
470
+ }
471
+
472
+ const chunks: string[] = [];
473
+ let offset = 0;
474
+ let isFirst = true;
475
+
476
+ while (offset < base64Data.length) {
477
+ const chunk = base64Data.slice(offset, offset + CHUNK_SIZE);
478
+ const isLast = offset + CHUNK_SIZE >= base64Data.length;
479
+
480
+ if (isFirst) {
481
+ chunks.push(`\x1b_G${leadParams},m=1;${chunk}\x1b\\`);
482
+ isFirst = false;
483
+ } else if (isLast) {
484
+ chunks.push(`\x1b_Gm=0;${chunk}\x1b\\`);
485
+ } else {
486
+ chunks.push(`\x1b_Gm=1;${chunk}\x1b\\`);
487
+ }
488
+
489
+ offset += CHUNK_SIZE;
490
+ }
491
+
492
+ return chunks.join("");
493
+ }
494
+
495
+ /** Transmit-and-display (`a=T`) — the self-contained form used when no stable id is available. */
496
+ export function encodeKitty(
497
+ base64Data: string,
498
+ options: {
499
+ columns?: number;
500
+ rows?: number;
501
+ imageId?: number;
502
+ } = {},
503
+ ): string {
504
+ const params: string[] = ["a=T", "f=100", "q=2"];
505
+ if (options.columns) params.push(`c=${options.columns}`);
506
+ if (options.rows) params.push(`r=${options.rows}`);
507
+ if (options.imageId) params.push(`i=${options.imageId}`);
508
+ return chunkKittyApc(params.join(","), base64Data);
509
+ }
510
+
511
+ /**
512
+ * Transmit image data only (`a=t`), keyed by `imageId`, without displaying it.
513
+ * Sent once per image; the data then persists in the terminal's store (it
514
+ * survives scroll-off and text clears for images with a non-zero id), so
515
+ * subsequent frames display it with the tiny {@link encodeKittyPlacement}
516
+ * sequence instead of re-sending the base64.
517
+ */
518
+ export function encodeKittyTransmit(base64Data: string, imageId: number): string {
519
+ return chunkKittyApc(`a=t,f=100,q=2,i=${imageId}`, base64Data);
520
+ }
521
+
522
+ /**
523
+ * Display a previously transmitted image (`a=p`) at the cursor. Carrying a
524
+ * stable `placementId` (`p=`) means re-emitting the sequence on a repaint
525
+ * *replaces* the existing placement (moving/resizing it without flicker) rather
526
+ * than stacking a duplicate.
527
+ */
528
+ export function encodeKittyPlacement(options: {
529
+ imageId: number;
530
+ placementId?: number;
531
+ columns?: number;
532
+ rows?: number;
533
+ }): string {
534
+ const params: string[] = ["a=p", "q=2", `i=${options.imageId}`];
535
+ if (options.placementId) params.push(`p=${options.placementId}`);
536
+ if (options.columns) params.push(`c=${options.columns}`);
537
+ if (options.rows) params.push(`r=${options.rows}`);
538
+ return `\x1b_G${params.join(",")}\x1b\\`;
539
+ }
540
+
541
+ /**
542
+ * Kitty graphics delete command for a single image id. Uses `d=I` (capital)
543
+ * which removes the image and every one of its placements — on screen *and* in
544
+ * scrollback — and frees the backing data. `q=2` suppresses the terminal reply.
545
+ * Text-clearing escapes (`CSI 2 J` / `CSI 3 J`) do not remove Kitty graphics, so
546
+ * this is the only way to actually purge a placed image.
547
+ */
548
+ export function encodeKittyDeleteImage(imageId: number): string {
549
+ return `\x1b_Ga=d,d=I,i=${imageId},q=2\x1b\\`;
550
+ }
551
+
552
+ export function encodeITerm2(
553
+ base64Data: string,
554
+ options: {
555
+ width?: number | string;
556
+ height?: number | string;
557
+ name?: string;
558
+ preserveAspectRatio?: boolean;
559
+ inline?: boolean;
560
+ } = {},
561
+ ): string {
562
+ const params: string[] = [`inline=${options.inline !== false ? 1 : 0}`];
563
+
564
+ if (options.width !== undefined) params.push(`width=${options.width}`);
565
+ if (options.height !== undefined) params.push(`height=${options.height}`);
566
+ if (options.name) {
567
+ const nameBase64 = Buffer.from(options.name).toBase64();
568
+ params.push(`name=${nameBase64}`);
569
+ }
570
+ if (options.preserveAspectRatio === false) {
571
+ params.push("preserveAspectRatio=0");
572
+ }
573
+
574
+ return `\x1b]1337;File=${params.join(";")}:${base64Data}\x07`;
575
+ }
576
+
577
+ export function calculateImageRows(
578
+ imageDimensions: ImageDimensions,
579
+ targetWidthCells: number,
580
+ cellDimensions: CellDimensions = { widthPx: 9, heightPx: 18 },
581
+ ): number {
582
+ const targetWidthPx = targetWidthCells * cellDimensions.widthPx;
583
+ const scale = targetWidthPx / imageDimensions.widthPx;
584
+ const scaledHeightPx = imageDimensions.heightPx * scale;
585
+ const rows = Math.ceil(scaledHeightPx / cellDimensions.heightPx);
586
+ return Math.max(1, rows);
587
+ }
588
+
589
+ function calculateImageFit(
590
+ imageDimensions: ImageDimensions,
591
+ options: ImageRenderOptions,
592
+ cellDims: CellDimensions,
593
+ ): { columns: number; rows: number } {
594
+ const maxColumns = options.maxWidthCells !== undefined ? Math.max(1, Math.floor(options.maxWidthCells)) : undefined;
595
+ const maxRows = options.maxHeightCells !== undefined ? Math.max(1, Math.floor(options.maxHeightCells)) : undefined;
596
+
597
+ if (maxColumns === undefined && maxRows === undefined) {
598
+ const columns = Math.max(1, Math.ceil(imageDimensions.widthPx / cellDims.widthPx));
599
+ const rows = Math.max(1, Math.ceil(imageDimensions.heightPx / cellDims.heightPx));
600
+ return { columns, rows };
601
+ }
602
+
603
+ const maxWidthPx = maxColumns !== undefined ? maxColumns * cellDims.widthPx : Number.POSITIVE_INFINITY;
604
+ const maxHeightPx = maxRows !== undefined ? maxRows * cellDims.heightPx : Number.POSITIVE_INFINITY;
605
+ const scale = Math.min(maxWidthPx / imageDimensions.widthPx, maxHeightPx / imageDimensions.heightPx);
606
+ const fittedWidthPx = imageDimensions.widthPx * scale;
607
+ const fittedHeightPx = imageDimensions.heightPx * scale;
608
+
609
+ const columns = Math.max(1, Math.floor(fittedWidthPx / cellDims.widthPx));
610
+ const rows = Math.max(1, Math.ceil(fittedHeightPx / cellDims.heightPx));
611
+
612
+ return {
613
+ columns: maxColumns !== undefined ? Math.min(columns, maxColumns) : columns,
614
+ rows: maxRows !== undefined ? Math.min(rows, maxRows) : rows,
615
+ };
616
+ }
617
+
618
+ export function getPngDimensions(base64Data: string): ImageDimensions | null {
619
+ try {
620
+ const buffer = Buffer.from(base64Data, "base64");
621
+
622
+ if (buffer.length < 24) {
623
+ return null;
624
+ }
625
+
626
+ if (buffer[0] !== 0x89 || buffer[1] !== 0x50 || buffer[2] !== 0x4e || buffer[3] !== 0x47) {
627
+ return null;
628
+ }
629
+
630
+ const width = buffer.readUInt32BE(16);
631
+ const height = buffer.readUInt32BE(20);
632
+
633
+ return { widthPx: width, heightPx: height };
634
+ } catch {
635
+ return null;
636
+ }
637
+ }
638
+
639
+ export function getJpegDimensions(base64Data: string): ImageDimensions | null {
640
+ try {
641
+ const buffer = Buffer.from(base64Data, "base64");
642
+
643
+ if (buffer.length < 2) {
644
+ return null;
645
+ }
646
+
647
+ if (buffer[0] !== 0xff || buffer[1] !== 0xd8) {
648
+ return null;
649
+ }
650
+
651
+ let offset = 2;
652
+ while (offset < buffer.length - 9) {
653
+ if (buffer[offset] !== 0xff) {
654
+ offset++;
655
+ continue;
656
+ }
657
+
658
+ const marker = buffer[offset + 1];
659
+
660
+ if (marker >= 0xc0 && marker <= 0xc2) {
661
+ const height = buffer.readUInt16BE(offset + 5);
662
+ const width = buffer.readUInt16BE(offset + 7);
663
+ return { widthPx: width, heightPx: height };
664
+ }
665
+
666
+ if (offset + 3 >= buffer.length) {
667
+ return null;
668
+ }
669
+ const length = buffer.readUInt16BE(offset + 2);
670
+ if (length < 2) {
671
+ return null;
672
+ }
673
+ offset += 2 + length;
674
+ }
675
+
676
+ return null;
677
+ } catch {
678
+ return null;
679
+ }
680
+ }
681
+
682
+ export function getGifDimensions(base64Data: string): ImageDimensions | null {
683
+ try {
684
+ const buffer = Buffer.from(base64Data, "base64");
685
+
686
+ if (buffer.length < 10) {
687
+ return null;
688
+ }
689
+
690
+ const sig = buffer.slice(0, 6).toString("ascii");
691
+ if (sig !== "GIF87a" && sig !== "GIF89a") {
692
+ return null;
693
+ }
694
+
695
+ const width = buffer.readUInt16LE(6);
696
+ const height = buffer.readUInt16LE(8);
697
+
698
+ return { widthPx: width, heightPx: height };
699
+ } catch {
700
+ return null;
701
+ }
702
+ }
703
+
704
+ export function getWebpDimensions(base64Data: string): ImageDimensions | null {
705
+ try {
706
+ const buffer = Buffer.from(base64Data, "base64");
707
+
708
+ if (buffer.length < 30) {
709
+ return null;
710
+ }
711
+
712
+ const riff = buffer.slice(0, 4).toString("ascii");
713
+ const webp = buffer.slice(8, 12).toString("ascii");
714
+ if (riff !== "RIFF" || webp !== "WEBP") {
715
+ return null;
716
+ }
717
+
718
+ const chunk = buffer.slice(12, 16).toString("ascii");
719
+ if (chunk === "VP8 ") {
720
+ if (buffer.length < 30) return null;
721
+ const width = buffer.readUInt16LE(26) & 0x3fff;
722
+ const height = buffer.readUInt16LE(28) & 0x3fff;
723
+ return { widthPx: width, heightPx: height };
724
+ } else if (chunk === "VP8L") {
725
+ if (buffer.length < 25) return null;
726
+ const bits = buffer.readUInt32LE(21);
727
+ const width = (bits & 0x3fff) + 1;
728
+ const height = ((bits >> 14) & 0x3fff) + 1;
729
+ return { widthPx: width, heightPx: height };
730
+ } else if (chunk === "VP8X") {
731
+ if (buffer.length < 30) return null;
732
+ const width = (buffer[24] | (buffer[25] << 8) | (buffer[26] << 16)) + 1;
733
+ const height = (buffer[27] | (buffer[28] << 8) | (buffer[29] << 16)) + 1;
734
+ return { widthPx: width, heightPx: height };
735
+ }
736
+
737
+ return null;
738
+ } catch {
739
+ return null;
740
+ }
741
+ }
742
+
743
+ export function getImageDimensions(base64Data: string, mimeType: string): ImageDimensions | null {
744
+ if (mimeType === "image/png") {
745
+ return getPngDimensions(base64Data);
746
+ }
747
+ if (mimeType === "image/jpeg") {
748
+ return getJpegDimensions(base64Data);
749
+ }
750
+ if (mimeType === "image/gif") {
751
+ return getGifDimensions(base64Data);
752
+ }
753
+ if (mimeType === "image/webp") {
754
+ return getWebpDimensions(base64Data);
755
+ }
756
+ return null;
757
+ }
758
+
759
+ export function renderImage(
760
+ base64Data: string,
761
+ imageDimensions: ImageDimensions,
762
+ options: ImageRenderOptions = {},
763
+ ): { sequence?: string; lines?: string[]; rows: number; transmit?: string } | null {
764
+ if (!TERMINAL.imageProtocol) {
765
+ return null;
766
+ }
767
+
768
+ const cellDims = getCellDimensions();
769
+ const fit = calculateImageFit(imageDimensions, options, cellDims);
770
+
771
+ if (TERMINAL.imageProtocol === ImageProtocol.Kitty) {
772
+ if (options.imageId != null) {
773
+ const placementId = options.placementId ?? options.imageId;
774
+ const graphics = getKittyGraphics();
775
+ // Transmit-once (keyed by id). Prefer a local temp file for PNGs when the
776
+ // medium has been promoted; otherwise send in-band base64. Repaints reuse
777
+ // the stored image, so the transmit is only emitted when requested.
778
+ let transmit: string | undefined;
779
+ if (options.includeTransmit) {
780
+ const tempFile =
781
+ graphics.transmissionMedium === "temp-file" && isPngBase64(base64Data)
782
+ ? encodeKittyTempFileTransmit(base64Data, options.imageId)
783
+ : null;
784
+ transmit = tempFile ?? encodeKittyTransmit(base64Data, options.imageId);
785
+ }
786
+ // Unicode placeholders render the image as real text cells (which survive
787
+ // horizontal slicing, reflow and overlaps) instead of a cursor-positioned
788
+ // `a=p` placement. Falls back to direct placement when disabled or when the
789
+ // grid exceeds the diacritic table's addressable cell range.
790
+ if (graphics.unicodePlaceholders && kittyPlaceholdersFit(fit.columns, fit.rows)) {
791
+ const lines = renderKittyPlaceholderLines({
792
+ imageId: options.imageId,
793
+ placementId,
794
+ columns: fit.columns,
795
+ rows: fit.rows,
796
+ });
797
+ return { lines, rows: fit.rows, transmit };
798
+ }
799
+ // Direct placement: re-emit only the tiny `a=p` on repaints.
800
+ const sequence = encodeKittyPlacement({
801
+ imageId: options.imageId,
802
+ placementId,
803
+ columns: fit.columns,
804
+ rows: fit.rows,
805
+ });
806
+ return { sequence, rows: fit.rows, transmit };
807
+ }
808
+ // No stable id (e.g. no budget): self-contained transmit-and-display.
809
+ const sequence = encodeKitty(base64Data, {
810
+ columns: fit.columns,
811
+ rows: fit.rows,
812
+ });
813
+ return { sequence, rows: fit.rows };
814
+ }
815
+
816
+ if (TERMINAL.imageProtocol === ImageProtocol.Sixel) {
817
+ try {
818
+ const targetWidthPx = Math.max(1, fit.columns * cellDims.widthPx);
819
+ const targetHeightPx = Math.max(1, fit.rows * cellDims.heightPx);
820
+ const decoded = new Uint8Array(Buffer.from(base64Data, "base64"));
821
+ const sequence = encodeSixel(decoded, targetWidthPx, targetHeightPx);
822
+ return { sequence, rows: fit.rows };
823
+ } catch {
824
+ return null;
825
+ }
826
+ }
827
+ if (TERMINAL.imageProtocol === ImageProtocol.Iterm2) {
828
+ const sequence = encodeITerm2(base64Data, {
829
+ width: fit.columns,
830
+ height: "auto",
831
+ preserveAspectRatio: options.preserveAspectRatio ?? true,
832
+ });
833
+ return { sequence, rows: fit.rows };
834
+ }
835
+
836
+ return null;
837
+ }
838
+
839
+ export function imageFallback(mimeType: string, dimensions?: ImageDimensions, filename?: string): string {
840
+ const parts: string[] = [];
841
+ if (filename) parts.push(filename);
842
+ parts.push(`[${mimeType}]`);
843
+ if (dimensions) parts.push(`${dimensions.widthPx}x${dimensions.heightPx}`);
844
+ return `[Image: ${parts.join(" ")}]`;
845
+ }
846
+
847
+ /**
848
+ * Structured terminal notification. Rich fields are honored only by OSC 99
849
+ * (Kitty) once support is confirmed; other protocols and the unconfirmed Kitty
850
+ * path collapse to a single `title: body` line.
851
+ */
852
+ export interface TerminalNotification {
853
+ title?: string;
854
+ body?: string;
855
+ id?: string;
856
+ type?: string | string[];
857
+ urgency?: "low" | "normal" | "critical";
858
+ iconName?: string;
859
+ sound?: "silent" | "system" | "info" | "warning" | "error" | "question";
860
+ actions?: "focus" | "report" | "focus-report" | "none";
861
+ expiresMs?: number;
862
+ }
863
+
864
+ /**
865
+ * Whether the terminal confirmed OSC 99 desktop-notification support via the
866
+ * `p=?` query probe. Until confirmed, structured notifications collapse to a
867
+ * single message line.
868
+ */
869
+ let osc99CapabilitiesConfirmed = false;
870
+
871
+ /** Record the OSC 99 capability-probe result (called by ProcessTerminal). */
872
+ export function setOsc99Supported(supported: boolean): void {
873
+ osc99CapabilitiesConfirmed = supported;
874
+ }
875
+
876
+ /** True when OSC 99 structured notifications have been confirmed available. */
877
+ export function isOsc99Supported(): boolean {
878
+ return osc99CapabilitiesConfirmed;
879
+ }
880
+
881
+ /** Collapse a structured notification to a single line for non-OSC-99 sinks. */
882
+ function notificationToLine(n: TerminalNotification): string {
883
+ if (n.title && n.body) return `${n.title}: ${n.body}`;
884
+ return n.title ?? n.body ?? "";
885
+ }
886
+
887
+ // C0/C1 control characters that are unsafe inside an OSC payload (must base64).
888
+ const OSC99_UNSAFE = /[\x00-\x1f\x7f\x80-\x9f]/u;
889
+ const OSC99_MAX_PAYLOAD_BYTES = 2048;
890
+ const OSC99_APP_NAME = "Prometheus";
891
+ let nextOsc99NotificationId = 1;
892
+
893
+ function base64Utf8(value: string): string {
894
+ return Buffer.from(value, "utf8").toString("base64");
895
+ }
896
+
897
+ function sanitizeOsc99Id(id: string | undefined): string {
898
+ if (!id) return "";
899
+ const safe = id.replace(/[^a-zA-Z0-9_+\-.]/gu, "");
900
+ return safe === "0" ? "" : safe;
901
+ }
902
+
903
+ function osc99Id(id: string | undefined): string {
904
+ return sanitizeOsc99Id(id) || `prometheus-${nextOsc99NotificationId++}`;
905
+ }
906
+
907
+ function utf8CodePointBytes(char: string): number {
908
+ const codePoint = char.codePointAt(0) ?? 0;
909
+ if (codePoint <= 0x7f) return 1;
910
+ if (codePoint <= 0x7ff) return 2;
911
+ if (codePoint <= 0xffff) return 3;
912
+ return 4;
913
+ }
914
+
915
+ function chunkUtf8(payload: string): string[] {
916
+ if (payload === "") return [""];
917
+ const chunks: string[] = [];
918
+ let start = 0;
919
+ let index = 0;
920
+ let bytes = 0;
921
+ for (const char of payload) {
922
+ const charBytes = utf8CodePointBytes(char);
923
+ if (bytes > 0 && bytes + charBytes > OSC99_MAX_PAYLOAD_BYTES) {
924
+ chunks.push(payload.slice(start, index));
925
+ start = index;
926
+ bytes = 0;
927
+ }
928
+ bytes += charBytes;
929
+ index += char.length;
930
+ }
931
+ chunks.push(payload.slice(start));
932
+ return chunks;
933
+ }
934
+
935
+ function osc99Chunk(meta: string[], payload: string): string {
936
+ if (OSC99_UNSAFE.test(payload)) {
937
+ return `\x1b]99;${[...meta, "e=1"].join(":")};${base64Utf8(payload)}\x1b\\`;
938
+ }
939
+ return `\x1b]99;${meta.join(":")};${payload}\x1b\\`;
940
+ }
941
+
942
+ function osc99Payload(meta: string[], payload: string, holdUntilLaterPayload: boolean): string {
943
+ const chunks = chunkUtf8(payload);
944
+ let out = "";
945
+ for (let i = 0; i < chunks.length; i++) {
946
+ const chunkMeta = [...meta];
947
+ if (holdUntilLaterPayload || i < chunks.length - 1) chunkMeta.push("d=0");
948
+ out += osc99Chunk(chunkMeta, chunks[i]!);
949
+ }
950
+ return out;
951
+ }
952
+
953
+ function osc99Urgency(urgency: TerminalNotification["urgency"]): string | undefined {
954
+ switch (urgency) {
955
+ case "low":
956
+ return "0";
957
+ case "normal":
958
+ return "1";
959
+ case "critical":
960
+ return "2";
961
+ default:
962
+ return undefined;
963
+ }
964
+ }
965
+
966
+ function osc99Actions(actions: TerminalNotification["actions"]): string | undefined {
967
+ switch (actions) {
968
+ case "focus":
969
+ return "focus";
970
+ case "report":
971
+ return "report";
972
+ case "focus-report":
973
+ return "focus,report";
974
+ case "none":
975
+ return "-focus";
976
+ default:
977
+ return undefined;
978
+ }
979
+ }
980
+
981
+ /**
982
+ * Format a structured notification as OSC 99 title/body payloads. Title and
983
+ * body chunks share one id. Every non-final chunk carries `d=0`; the final
984
+ * title or body chunk displays the notification. Metadata values that require
985
+ * it (application name, type, icon name, sound) are base64-encoded.
986
+ */
987
+ function formatOsc99Notification(n: TerminalNotification): string {
988
+ const id = osc99Id(n.id);
989
+ const meta: string[] = [`i=${id}`, `f=${base64Utf8(OSC99_APP_NAME)}`];
990
+ const actions = osc99Actions(n.actions);
991
+ if (actions) meta.push(`a=${actions}`);
992
+ const urgency = osc99Urgency(n.urgency);
993
+ if (urgency) meta.push(`u=${urgency}`);
994
+ const types = n.type === undefined ? [] : Array.isArray(n.type) ? n.type : [n.type];
995
+ for (const t of types) meta.push(`t=${base64Utf8(t)}`);
996
+ if (n.iconName) meta.push(`n=${base64Utf8(n.iconName)}`);
997
+ if (n.sound) meta.push(`s=${base64Utf8(n.sound)}`);
998
+ if (n.expiresMs !== undefined && Number.isFinite(n.expiresMs)) {
999
+ meta.push(`w=${Math.max(-1, Math.trunc(n.expiresMs))}`);
1000
+ }
1001
+
1002
+ const title = n.title ?? n.body ?? "";
1003
+ const body = n.title ? n.body : undefined;
1004
+
1005
+ if (body !== undefined && body !== "") {
1006
+ return osc99Payload(meta, title, true) + osc99Payload([`i=${id}`, "p=body"], body, false);
1007
+ }
1008
+ return osc99Payload(meta, title, false);
1009
+ }