Replace the legacy terminal flow with a shared authenticated WebSocket runtime used across web, desktop, relay, and mobile surfaces. - introduce the v3 terminal protocol with scoped attachments, snapshots, ordered output, bounded replay history, reconnects, and explicit lifecycle - harden PTY creation, restart, resize, close, force-kill, idle cleanup, shell selection, login mode, environment sanitization, and appearance sync - add runtime-aware terminal APIs with relay authentication and Electron parity - add a fullscreen mobile terminal workspace with touch scrolling, long-press selection, safe-area controls, quick keys, and Ctrl/Alt input - add terminal selection attachments, preview detection, project actions, shell settings, and localized UI - harden Ghostty rendering, resize recovery, Unicode handling, block characters, line height, and stale-row behavior - remove the obsolete terminal SSE path and update reverse-proxy guidance - expand terminal runtime, transport, input, selection, and store coverage - avoid duplicate web builds when preparing mobile assets in root CI builds
75 lines
3.1 KiB
JavaScript
75 lines
3.1 KiB
JavaScript
const MODE_SET = '\u001b[?2031h';
|
|
const MODE_RESET = '\u001b[?2031l';
|
|
const CAPABILITY_QUERY = '\u001b[?2031$p';
|
|
const MODE_QUERIES = ['\u001b[?996n', '\u001b[?997n'];
|
|
const OSC_QUERIES = [10, 11].flatMap((code) => [
|
|
{ sequence: `\u001b]${code};?\u0007`, code },
|
|
{ sequence: `\u001b]${code};?\u001b\\`, code },
|
|
]);
|
|
const CONTROL_SEQUENCES = [MODE_SET, MODE_RESET, CAPABILITY_QUERY, ...MODE_QUERIES, ...OSC_QUERIES.map(({ sequence }) => sequence)];
|
|
|
|
const parseColor = (value) => {
|
|
if (typeof value !== 'string') return null;
|
|
const hex = value.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i)?.[1];
|
|
if (hex) {
|
|
const expanded = hex.length === 3 ? [...hex].map((part) => part + part).join('') : hex;
|
|
return [0, 2, 4].map((offset) => Number.parseInt(expanded.slice(offset, offset + 2), 16));
|
|
}
|
|
const rgb = value.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i);
|
|
return rgb ? rgb.slice(1, 4).map((part) => Math.min(255, Number(part))) : null;
|
|
};
|
|
|
|
const colorReport = (code, color) => {
|
|
const rgb = parseColor(color);
|
|
if (!rgb) return null;
|
|
const channels = rgb.map((channel) => channel.toString(16).padStart(2, '0').repeat(2));
|
|
return `\u001b]${code};rgb:${channels.join('/')}\u001b\\`;
|
|
};
|
|
|
|
export const terminalThemeModeReport = (themeMode) => `\u001b[?997;${themeMode === 'light' ? 2 : 1}n`;
|
|
|
|
export const consumeTerminalThemeQueries = (pending, data, appearance) => {
|
|
if (!pending && !data.includes('\u001b')) return { pending: '', responses: [], modeEnabled: appearance.modeEnabled === true };
|
|
const input = `${pending}${data}`;
|
|
const responses = [];
|
|
let modeEnabled = appearance.modeEnabled === true;
|
|
|
|
for (let index = 0; index < input.length; index += 1) {
|
|
if (input.startsWith(MODE_SET, index)) {
|
|
modeEnabled = true;
|
|
index += MODE_SET.length - 1;
|
|
continue;
|
|
}
|
|
if (input.startsWith(MODE_RESET, index)) {
|
|
modeEnabled = false;
|
|
index += MODE_RESET.length - 1;
|
|
continue;
|
|
}
|
|
if (input.startsWith(CAPABILITY_QUERY, index)) {
|
|
responses.push(`\u001b[?2031;${modeEnabled ? 1 : 2}$y`);
|
|
index += CAPABILITY_QUERY.length - 1;
|
|
continue;
|
|
}
|
|
const modeQuery = MODE_QUERIES.find((query) => input.startsWith(query, index));
|
|
if (modeQuery) {
|
|
responses.push(terminalThemeModeReport(appearance.themeMode));
|
|
index += modeQuery.length - 1;
|
|
continue;
|
|
}
|
|
const oscQuery = OSC_QUERIES.find(({ sequence }) => input.startsWith(sequence, index));
|
|
if (oscQuery) {
|
|
const response = colorReport(oscQuery.code, oscQuery.code === 10 ? appearance.foreground : appearance.background);
|
|
if (response) responses.push(response);
|
|
index += oscQuery.sequence.length - 1;
|
|
}
|
|
}
|
|
|
|
let nextPending = '';
|
|
const maxLength = Math.max(...CONTROL_SEQUENCES.map((sequence) => sequence.length));
|
|
for (let length = 1; length < Math.min(input.length + 1, maxLength); length += 1) {
|
|
const suffix = input.slice(-length);
|
|
if (CONTROL_SEQUENCES.some((sequence) => sequence.length > length && sequence.startsWith(suffix))) nextPending = suffix;
|
|
}
|
|
return { pending: nextPending, responses, modeEnabled };
|
|
};
|