Files
Bohdan Triapitsyn d4a8c4d2e1 feat(terminal): refactor runtime and add mobile workspace (#2280)
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
2026-07-17 13:17:21 +03:00

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: '' };
};