import type { Readable, Writable } from "node:stream"; export type StartupUpdateChoice = "update" | "skip" | "omit" | "cancel"; type TerminalInput = Readable & { isRaw?: boolean; setRawMode?: (raw: boolean) => unknown }; type TerminalOutput = Writable & { columns?: number; rows?: number; isTTY?: boolean }; type UpdateOffer = { current: string; latest: string; notesUrl: string }; type PromptOptions = { input?: TerminalInput; output?: TerminalOutput; color?: boolean }; const choices: StartupUpdateChoice[] = ["update", "skip", "omit"]; const labels = ["Update now (gentle-shell upgrade)", "Not now", "Skip this version"]; function sanitize(value: string, limit: number): string { // Bound work before parsing, remove whole escape sequences (including OSC titles), // then remove remaining control and bidi characters. Never emit an OSC hyperlink. return value.slice(0, 2048) .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\|$)/g, "") .replace(/(?:\x1b\[|\x9b)[0-?]*[ -/]*[@-~]/g, "") .replace(/\x1b[ -/]*[@-~]/g, "") .replace(/[\x00-\x1f\x7f-\x9f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g, "") .slice(0, limit); } function cells(char: string): number { const code = char.codePointAt(0)!; if (/\p{Mark}/u.test(char) || code === 0x200d || code === 0xfe0f) return 0; return code >= 0x1100 && ( code <= 0x115f || code === 0x2329 || code === 0x232a || (code >= 0x2e80 && code <= 0xa4cf) || (code >= 0xac00 && code <= 0xd7a3) || (code >= 0xf900 && code <= 0xfaff) || (code >= 0xfe10 && code <= 0xfe6f) || (code >= 0xff00 && code <= 0xff60) || (code >= 0xffe0 && code <= 0xffe6) || code >= 0x1f000 ) ? 2 : 1; } function wrap(text: string, width: number): string[] { const lines: string[] = []; let line = ""; let used = 0; for (const original of text) { const char = cells(original) > width ? "?" : original; const size = cells(char); if (used + size > width) { lines.push(line); line = ""; used = 0; } line += char; used += size; } lines.push(line); return lines; } /** A pre-Pi chooser: returns intent only; it never invokes upgrade or persists policy. */ export function promptStartupUpdate(offer: UpdateOffer, options: PromptOptions = {}): Promise { const input = options.input ?? process.stdin; const output = options.output ?? process.stdout; const color = options.color ?? output.isTTY === true; const rose = (text: string) => color ? `\x1b[38;2;255;118;195m${text}\x1b[39m` : text; const current = sanitize(offer.current, 40); const latest = sanitize(offer.latest, 40); const notes = sanitize(offer.notesUrl, 160); return new Promise((resolve, reject) => { const wasRaw = input.isRaw === true; const wasFlowing = input.readableFlowing === true; let rawAttempted = false; let cursorHidden = false; let selected = 1; let done = false; let failure: unknown; let outcome: StartupUpdateChoice = "skip"; let pendingWrites = 0; let settlementScheduled = false; let escape = ""; let escapeTimer: ReturnType | undefined; let previousMenuLines = 0; let previousWidth = 0; function settle() { if (!done || pendingWrites || settlementScheduled) return; settlementScheduled = true; // Writable callback errors can be followed by an error event. Keep our // listener until that event is drained, including during cursor cleanup. setImmediate(() => { output.removeListener("error", onError); if (failure) reject(failure); else resolve(outcome); }); } function write(text: string) { pendingWrites++; try { output.write(text, (error?: Error | null) => { pendingWrites--; if (error) onError(error); settle(); }); } catch (error) { pendingWrites--; onError(error); } } function finish(choice: StartupUpdateChoice, error?: unknown) { if (error && !failure) failure = error; if (done) return; done = true; outcome = choice; clearTimeout(escapeTimer); input.removeListener("data", onData); input.removeListener("end", onEnd); input.removeListener("close", onEnd); input.removeListener("error", onError); try { if (rawAttempted) input.setRawMode?.(wasRaw); } catch (error) { failure ??= error; } if (!wasFlowing) input.pause(); if (cursorHidden) write("\x1b[?25h"); settle(); } function onError(error: unknown) { if (!failure) failure = error; finish("skip", error); } function onEnd() { finish("skip"); } function terminalWidth(): number { const width = output.columns; return typeof width === "number" && Number.isFinite(width) ? Math.max(1, Math.min(76, Math.floor(width))) : 76; } function render(initial = false) { const width = terminalWidth(); const bordered = width >= 6; const inner = bordered ? width - 4 : width; const row = (text: string) => wrap(text, inner).map(line => { const used = [...line].reduce((sum, char) => sum + cells(char), 0); return bordered ? `| ${line}${" ".repeat(Math.max(0, inner - used))} |` : line; }); const border = bordered ? rose(`+${"-".repeat(width - 2)}+`) : ""; const menu = labels.flatMap((label, index) => row(`${index === selected ? ">" : " "} ${index + 1}. ${label}`)); if (bordered) menu.push(border); let prefix = ""; if (initial) { // Start on a fresh line so the existing shell prompt is never erased. const header = [ ...(bordered ? [border] : []), ...row("Gentle Shell update available").map(rose), ...row(`${current} -> ${latest}`), ...row("Release notes:"), ...row(notes), ...row("Arrows / 1-3: select; Enter: confirm"), ...row("Esc: not now; Ctrl+C: cancel"), ]; prefix = `\r\n${header.join("\r\n")}\r\n`; } else { const rows = output.rows ?? 24; // On resize or a very short terminal, append rather than moving above // our visible menu. Never clear a screen or enter an alternate screen. if (width === previousWidth && previousMenuLines < rows) { prefix = `\x1b[${previousMenuLines}A\r`; } } const clear = !initial && prefix.startsWith("\x1b[") ? "\x1b[2K" : ""; write(prefix + menu.map(line => `${clear}${line}\r\n`).join("")); previousMenuLines = menu.length; previousWidth = width; } function select(index: number) { selected = (index + choices.length) % choices.length; render(); } function onData(chunk: Buffer | string) { for (const char of chunk.toString()) { if (done) break; if (char === "\x03") { finish("cancel"); break; } if (escape) { clearTimeout(escapeTimer); escape += char; if (escape === "\x1b[" || escape === "\x1bO") { escapeTimer = setTimeout(() => finish("skip"), 40); continue; } if (/^\x1b[\[O][ABCD]$/.test(escape)) { select(selected + (char === "A" || char === "D" ? -1 : 1)); escape = ""; continue; } finish("skip"); break; } if (char === "\x1b") { escape = char; escapeTimer = setTimeout(() => finish("skip"), 40); } else if (/^[123]$/.test(char)) select(Number(char) - 1); else if (char === "\r" || char === "\n") finish(choices[selected]); } } output.on("error", onError); input.on("error", onError); input.on("end", onEnd); input.on("close", onEnd); try { if (input.readableEnded || input.destroyed || output.destroyed) { finish("skip"); return; } if (input.setRawMode) { rawAttempted = true; input.setRawMode(true); } cursorHidden = true; write("\x1b[?25l"); if (!done) render(true); if (!done) { input.on("data", onData); input.resume(); } } catch (error) { finish("skip", error); } }); }