Files
openchamber/packages/ui/src/lib/messages/terminalContext.ts
T
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

55 lines
2.5 KiB
TypeScript

export type TerminalContext = {
terminalId: string;
terminalLabel: string;
startLine: number;
endLine: number;
text: string;
};
export type ParsedTerminalContext = Omit<TerminalContext, 'terminalId'>;
const BLOCK = /\n*<terminal_context>\n([\s\S]*?)\n<\/terminal_context>\s*$/;
export const normalizeTerminalContext = (context: TerminalContext): TerminalContext | null => {
const terminalId = context.terminalId.trim();
const terminalLabel = context.terminalLabel.trim();
const text = context.text.replace(/\r\n?/g, '\n').replace(/^\n+|\n+$/g, '');
if (!terminalId || !terminalLabel || !text) return null;
const startLine = Math.max(1, Math.floor(context.startLine));
return { terminalId, terminalLabel, startLine, endLine: Math.max(startLine, Math.floor(context.endLine)), text };
};
export const terminalContextKey = (context: TerminalContext): string =>
`${context.terminalId}:${context.startLine}:${context.endLine}:${context.text}`;
export const appendTerminalContexts = (prompt: string, contexts: readonly TerminalContext[]): string => {
const normalized = contexts.map(normalizeTerminalContext).filter((value): value is TerminalContext => value !== null);
if (normalized.length === 0) return prompt;
const lines = normalized.flatMap((context, index) => [
`- ${context.terminalLabel} lines ${context.startLine}-${context.endLine}:`,
...context.text.split('\n').map((line, offset) => ` ${context.startLine + offset} | ${line}`),
...(index === normalized.length - 1 ? [] : ['']),
]);
const block = ['<terminal_context>', ...lines, '</terminal_context>'].join('\n');
return prompt.trim() ? `${prompt.trim()}\n\n${block}` : block;
};
export const extractTerminalContexts = (prompt: string): { visibleText: string; contexts: ParsedTerminalContext[] } => {
const match = BLOCK.exec(prompt);
if (!match) return { visibleText: prompt, contexts: [] };
const contexts: ParsedTerminalContext[] = [];
let current: ParsedTerminalContext | null = null;
for (const line of (match[1] ?? '').split('\n')) {
const header = /^- (.+) lines (\d+)-(\d+):$/.exec(line);
if (header) {
if (current) contexts.push(current);
current = { terminalLabel: header[1], startLine: Number(header[2]), endLine: Number(header[3]), text: '' };
continue;
}
const body = /^\s{2}\d+ \| ?(.*)$/.exec(line);
if (current && body) current.text += `${current.text ? '\n' : ''}${body[1]}`;
}
if (current) contexts.push(current);
return { visibleText: prompt.slice(0, match.index).trimEnd(), contexts };
};