@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.
- package/CHANGELOG.md +2219 -0
- package/README.md +705 -0
- package/dist/types/autocomplete.d.ts +116 -0
- package/dist/types/bracketed-paste.d.ts +51 -0
- package/dist/types/components/box.d.ts +31 -0
- package/dist/types/components/cancellable-loader.d.ts +21 -0
- package/dist/types/components/editor.d.ts +162 -0
- package/dist/types/components/image.d.ts +112 -0
- package/dist/types/components/input.d.ts +25 -0
- package/dist/types/components/loader.d.ts +25 -0
- package/dist/types/components/markdown.d.ts +88 -0
- package/dist/types/components/scroll-view.d.ts +62 -0
- package/dist/types/components/select-list.d.ts +69 -0
- package/dist/types/components/settings-list.d.ts +123 -0
- package/dist/types/components/spacer.d.ts +11 -0
- package/dist/types/components/tab-bar.d.ts +89 -0
- package/dist/types/components/text.d.ts +27 -0
- package/dist/types/components/truncated-text.d.ts +10 -0
- package/dist/types/deccara.d.ts +49 -0
- package/dist/types/desktop-notify.d.ts +52 -0
- package/dist/types/editor-component.d.ts +38 -0
- package/dist/types/fuzzy.d.ts +48 -0
- package/dist/types/index.d.ts +32 -0
- package/dist/types/keybindings.d.ts +197 -0
- package/dist/types/keys.d.ts +210 -0
- package/dist/types/kill-ring.d.ts +20 -0
- package/dist/types/kitty-graphics.d.ts +76 -0
- package/dist/types/latex-block.d.ts +8 -0
- package/dist/types/latex-to-unicode.d.ts +50 -0
- package/dist/types/loop-watchdog.d.ts +44 -0
- package/dist/types/mouse.d.ts +67 -0
- package/dist/types/stdin-buffer.d.ts +60 -0
- package/dist/types/symbols.d.ts +25 -0
- package/dist/types/terminal-capabilities.d.ts +285 -0
- package/dist/types/terminal.d.ts +175 -0
- package/dist/types/tmux.d.ts +6 -0
- package/dist/types/ttyid.d.ts +9 -0
- package/dist/types/tui.d.ts +457 -0
- package/dist/types/utils.d.ts +100 -0
- package/package.json +70 -0
- package/src/autocomplete.ts +1079 -0
- package/src/bracketed-paste.ts +123 -0
- package/src/components/box.ts +236 -0
- package/src/components/cancellable-loader.ts +40 -0
- package/src/components/editor.ts +3301 -0
- package/src/components/image.ts +460 -0
- package/src/components/input.ts +482 -0
- package/src/components/loader.ts +174 -0
- package/src/components/markdown.ts +3119 -0
- package/src/components/scroll-view.ts +227 -0
- package/src/components/select-list.ts +539 -0
- package/src/components/settings-list.ts +793 -0
- package/src/components/spacer.ts +32 -0
- package/src/components/tab-bar.ts +300 -0
- package/src/components/text.ts +173 -0
- package/src/components/truncated-text.ts +69 -0
- package/src/deccara.ts +314 -0
- package/src/desktop-notify.ts +192 -0
- package/src/editor-component.ts +74 -0
- package/src/fuzzy.ts +384 -0
- package/src/index.ts +51 -0
- package/src/keybindings.ts +346 -0
- package/src/keys.ts +566 -0
- package/src/kill-ring.ts +51 -0
- package/src/kitty-graphics.ts +171 -0
- package/src/latex-block.ts +1338 -0
- package/src/latex-to-unicode.ts +2017 -0
- package/src/loop-watchdog.ts +115 -0
- package/src/mouse.ts +105 -0
- package/src/stdin-buffer.ts +781 -0
- package/src/symbols.ts +26 -0
- package/src/terminal-capabilities.ts +1211 -0
- package/src/terminal.ts +1854 -0
- package/src/tmux.ts +14 -0
- package/src/ttyid.ts +84 -0
- package/src/tui.ts +4275 -0
- 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
|
+
}
|