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
84 lines
3.6 KiB
JavaScript
84 lines
3.6 KiB
JavaScript
const isCsiFinalByte = (code) => code >= 0x40 && code <= 0x7e;
|
|
const shouldStripCsi = (body, finalByte) =>
|
|
finalByte === 'n'
|
|
|| (finalByte === 'R' && /^[0-9;?]*$/.test(body))
|
|
|| (finalByte === 'c' && /^[>0-9;?]*$/.test(body))
|
|
|| ((finalByte === 'p' || finalByte === 'y') && /^\?2031(?:;[0-9]+)?\$$/.test(body))
|
|
|| ((finalByte === 'h' || finalByte === 'l') && body === '?2031');
|
|
const shouldStripOsc = (content) => /^(10|11|12);(?:\?|rgb:)/.test(content);
|
|
const stripTerminator = (value) => {
|
|
if (value.endsWith('\u001b\\')) return value.slice(0, -2);
|
|
return value.endsWith('\u0007') || value.endsWith('\u009c') ? value.slice(0, -1) : value;
|
|
};
|
|
const findStringEnd = (input, start) => {
|
|
for (let index = start; index < input.length; index += 1) {
|
|
const code = input.charCodeAt(index);
|
|
if (code === 0x07 || code === 0x9c) return index + 1;
|
|
if (code === 0x1b && input.charCodeAt(index + 1) === 0x5c) return index + 2;
|
|
}
|
|
return null;
|
|
};
|
|
const findEscapeEnd = (input, start) => {
|
|
let cursor = start;
|
|
while (cursor < input.length && input.charCodeAt(cursor) >= 0x20 && input.charCodeAt(cursor) <= 0x2f) cursor += 1;
|
|
if (cursor >= input.length) return null;
|
|
return input.charCodeAt(cursor) >= 0x30 && input.charCodeAt(cursor) <= 0x7e ? cursor + 1 : start + 1;
|
|
};
|
|
|
|
export const sanitizeTerminalHistoryChunk = (pending, data) => {
|
|
const input = `${pending}${data}`;
|
|
let visible = '';
|
|
let index = 0;
|
|
while (index < input.length) {
|
|
const code = input.charCodeAt(index);
|
|
if (code === 0x1b) {
|
|
const next = input.charCodeAt(index + 1);
|
|
if (Number.isNaN(next)) return { visible, pending: input.slice(index) };
|
|
if (next === 0x5b) {
|
|
let cursor = index + 2;
|
|
while (cursor < input.length && !isCsiFinalByte(input.charCodeAt(cursor))) cursor += 1;
|
|
if (cursor >= input.length) return { visible, pending: input.slice(index) };
|
|
const sequence = input.slice(index, cursor + 1);
|
|
if (!shouldStripCsi(input.slice(index + 2, cursor), input[cursor])) visible += sequence;
|
|
index = cursor + 1;
|
|
continue;
|
|
}
|
|
if (next === 0x5d || next === 0x50 || next === 0x5e || next === 0x5f) {
|
|
const end = findStringEnd(input, index + 2);
|
|
if (end === null) return { visible, pending: input.slice(index) };
|
|
const sequence = input.slice(index, end);
|
|
const content = stripTerminator(input.slice(index + 2, end));
|
|
if (next !== 0x5d || !shouldStripOsc(content)) visible += sequence;
|
|
index = end;
|
|
continue;
|
|
}
|
|
const end = findEscapeEnd(input, index + 1);
|
|
if (end === null) return { visible, pending: input.slice(index) };
|
|
visible += input.slice(index, end);
|
|
index = end;
|
|
continue;
|
|
}
|
|
if (code === 0x9b) {
|
|
let cursor = index + 1;
|
|
while (cursor < input.length && !isCsiFinalByte(input.charCodeAt(cursor))) cursor += 1;
|
|
if (cursor >= input.length) return { visible, pending: input.slice(index) };
|
|
const sequence = input.slice(index, cursor + 1);
|
|
if (!shouldStripCsi(input.slice(index + 1, cursor), input[cursor])) visible += sequence;
|
|
index = cursor + 1;
|
|
continue;
|
|
}
|
|
if (code === 0x9d || code === 0x90 || code === 0x9e || code === 0x9f) {
|
|
const end = findStringEnd(input, index + 1);
|
|
if (end === null) return { visible, pending: input.slice(index) };
|
|
const sequence = input.slice(index, end);
|
|
const content = stripTerminator(input.slice(index + 1, end));
|
|
if (code !== 0x9d || !shouldStripOsc(content)) visible += sequence;
|
|
index = end;
|
|
continue;
|
|
}
|
|
visible += input[index];
|
|
index += 1;
|
|
}
|
|
return { visible, pending: '' };
|
|
};
|