import { APP_NAME } from "./constants.ts"; import { notificationToSafeLine, type TerminalNotification } from "./notify-protocol.ts"; export const OSC99_UNSAFE = /[\u0000-\u001f\u007f-\u009f]/u; export const OSC99_MAX_PAYLOAD_BYTES = 2048; export const OSC99_APP_NAME = APP_NAME; let nextNotificationId = 1; function base64Utf8(value: string): string { return Buffer.from(value, "utf8").toString("base64"); } export function sanitizeOsc99Id(id: string | undefined): string { if (!id) return ""; const safe = id.replace(/[^a-zA-Z0-9_+\-.]/gu, ""); return safe === "0" ? "" : safe; } function osc99Id(id: string | undefined): string { return sanitizeOsc99Id(id) || `pi-${nextNotificationId++}`; } export function utf8CodePointBytes(character: string): number { const codePoint = character.codePointAt(0) ?? 0; return codePoint <= 0x7f ? 1 : codePoint <= 0x7ff ? 2 : codePoint <= 0xffff ? 3 : 4; } export function chunkUtf8(payload: string): string[] { if (payload === "") return [""]; const chunks: string[] = []; let start = 0; let index = 0; let bytes = 0; for (const character of payload) { const characterBytes = utf8CodePointBytes(character); if (bytes > 0 && bytes + characterBytes > OSC99_MAX_PAYLOAD_BYTES) { chunks.push(payload.slice(start, index)); start = index; bytes = 0; } bytes += characterBytes; index += character.length; } chunks.push(payload.slice(start)); return chunks; } function osc99Chunk(meta: string[], payload: string): string { if (OSC99_UNSAFE.test(payload)) { return `\x1b]99;${[...meta, "e=1"].join(":")};${base64Utf8(payload)}\x1b\\`; } return `\x1b]99;${meta.join(":")};${payload}\x1b\\`; } function osc99Payload(meta: string[], payload: string, holdUntilLaterPayload: boolean): string { const chunks = chunkUtf8(payload); return chunks.map((chunk, index) => { const chunkMeta = [...meta]; if (holdUntilLaterPayload || index < chunks.length - 1) chunkMeta.push("d=0"); return osc99Chunk(chunkMeta, chunk); }).join(""); } export function osc99Urgency(urgency: TerminalNotification["urgency"]): string | undefined { return urgency === "low" ? "0" : urgency === "normal" ? "1" : urgency === "critical" ? "2" : undefined; } export function osc99Actions(actions: TerminalNotification["actions"]): string | undefined { switch (actions) { case "focus": return "focus"; case "report": return "report"; case "focus-report": return "focus,report"; case "none": return "-focus"; default: return undefined; } } export function formatOsc99Notification(notification: TerminalNotification): string { const id = osc99Id(notification.id); const meta = [`i=${id}`, `f=${base64Utf8(OSC99_APP_NAME)}`]; const action = osc99Actions(notification.actions); const urgency = osc99Urgency(notification.urgency); if (action) meta.push(`a=${action}`); if (urgency) meta.push(`u=${urgency}`); const types = notification.type === undefined ? [] : Array.isArray(notification.type) ? notification.type : [notification.type]; for (const type of types) meta.push(`t=${base64Utf8(type)}`); if (notification.iconName) meta.push(`n=${base64Utf8(notification.iconName)}`); if (notification.sound) meta.push(`s=${base64Utf8(notification.sound)}`); if (notification.expiresMs !== undefined && Number.isFinite(notification.expiresMs)) meta.push(`w=${Math.max(-1, Math.trunc(notification.expiresMs))}`); const title = notification.title ?? notification.body ?? ""; const body = notification.title ? notification.body : undefined; if (body !== undefined && body !== "") return osc99Payload(meta, title, true) + osc99Payload([`i=${id}`, "p=body"], body, false); return osc99Payload(meta, title, false); } export function formatOsc9(input: TerminalNotification): string { return `\x1b]9;${notificationToSafeLine(input)}\x1b\\`; } export function formatCollapsedOsc99(input: TerminalNotification): string { return `\x1b]99;;${notificationToSafeLine(input)}\x1b\\`; } /** Rich OSC 99 is opt-in; no stdin probe or mutable capability state exists. */ export function formatNotification(input: string | TerminalNotification, protocol: string, rich = false): string { if (protocol === "\x07") return "\x07"; if (typeof input === "string") { const safe = input.replace(/[\u0000-\u001f\u007f-\u009f]/gu, " "); return `${protocol}${safe}\x1b\\`; } if (protocol === "\x1b]99;;" && rich) return formatOsc99Notification(input); return protocol === "\x1b]9;" ? formatOsc9(input) : formatCollapsedOsc99(input); }