This commit is contained in:
Timothy Jaeryang Baek
2026-09-04 22:56:00 -04:00
parent 3facfa61d4
commit 54a7a7a7ce
5 changed files with 483 additions and 271 deletions
+3 -1
View File
@@ -326,7 +326,7 @@ async def ws_terminal(
# For orchestrator-backed servers, pass user_id
upstream_params['user_id'] = user.id
context_id = terminal_context_id(connection, {'chat_id': chat_id}, 'chat')
upstream_headers = {}
upstream_headers = {'X-User-Id': user.id, 'X-Session-Id': chat_id}
if terminal_context_config(connection, 'chat').get('context_id') == 'chat_id' and not context_id:
await ws.close(code=4003, reason='A saved chat is required for this terminal')
return
@@ -362,6 +362,8 @@ async def ws_terminal(
await upstream.send_str(_json.dumps({'type': 'auth', 'token': key}))
elif auth_type == 'session' and is_terminal_orchestrator(connection):
await upstream.send_str(_json.dumps({'type': 'auth', 'token': token}))
else:
await upstream.send_str(_json.dumps({'type': 'auth', 'token': ''}))
await publish_event(
app,
+57
View File
@@ -62,6 +62,63 @@ export type TerminalCwd = {
import { WEBUI_API_BASE_URL } from '$lib/constants';
export type TerminalConnection = {
selector: string;
baseUrl: string;
key: string;
system: boolean;
};
export type TerminalProcess = {
id: string;
command: string;
status: 'running' | 'done' | 'killed';
exit_code: number | null;
};
export type TerminalProcessOutput = TerminalProcess & {
output: { type: string; data: string }[];
next_offset: number;
truncated: boolean;
};
export const resolveTerminalConnection = (
selector: string | null,
servers: any[],
directServers: any[],
token: string
): TerminalConnection | null => {
if (!selector) return null;
if (servers.some((server) => server.id === selector)) {
return {
selector,
baseUrl: `${WEBUI_API_BASE_URL}/terminals/${encodeURIComponent(selector)}`,
key: token,
system: true
};
}
const direct = directServers.find((server) => server.url === selector);
return direct
? { selector, baseUrl: direct.url.replace(/\/$/, ''), key: direct.key ?? '', system: false }
: null;
};
export const terminalRequest = async <T>(
connection: TerminalConnection,
chatId: string | null,
path: string,
options: RequestInit = {}
): Promise<T> => {
const response = await fetch(`${connection.baseUrl}${path}`, {
...options,
headers: {
Authorization: `Bearer ${connection.key.trim()}`,
...(chatId ? { 'X-Session-Id': chatId } : {}),
...options.headers
}
});
if (!response.ok) throw new Error(`Terminal request failed (${response.status})`);
return response.json();
};
const bearerHeaders = (apiKey: string): Record<string, string> => ({
Authorization: `Bearer ${apiKey.trim()}`
});
+8 -42
View File
@@ -56,7 +56,7 @@
import BulkActionBar from './FileNav/BulkActionBar.svelte';
import PortList from './FileNav/PortList.svelte';
import PortPreview from './FileNav/PortPreview.svelte';
import XTerminal from './XTerminal.svelte';
import TerminalDock from './TerminalDock.svelte';
const i18n = getContext('i18n');
@@ -68,14 +68,8 @@
let terminalHeight = 200; // px, default when expanded
let isDraggingHandle = false;
let containerEl: HTMLElement;
let terminalConnected = false;
let terminalConnecting = false;
let terminalEnabled = true;
const toggleTerminal = () => {
terminalExpanded = !terminalExpanded;
};
const onHandleMouseDown = (e: MouseEvent) => {
e.preventDefault();
isDraggingHandle = true;
@@ -2050,42 +2044,14 @@
</div>
{/if}
<!-- Toggle header (full-width button) -->
<button
class="w-full flex items-center gap-2 px-2 py-1 mb-0.5 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition-colors duration-100"
on:click={toggleTerminal}
>
<Icon name="terminal" size={14} strokeWidth={1.4} class="shrink-0" />
<span class="font-normal">{$i18n.t('Terminal')}</span>
{#if terminalExpanded}
<div
class="w-1.5 h-1.5 rounded-full transition-colors {terminalConnected
? 'bg-emerald-500'
: terminalConnecting
? 'bg-yellow-500 animate-pulse'
: 'bg-gray-400'}"
/>
{/if}
<Icon
name="chevron-up"
size={12}
strokeWidth={1.4}
class="ml-auto transition-transform {terminalExpanded ? 'rotate-180' : ''}"
{#key JSON.stringify([chatId, $selectedTerminalId])}
<TerminalDock
overlay={overlay || isDraggingHandle}
bind:expanded={terminalExpanded}
height={terminalHeight}
{chatId}
/>
</button>
{#if terminalExpanded}
<div style="height: {terminalHeight}px" class="min-h-0">
<XTerminal
overlay={overlay || isDraggingHandle}
bind:connected={terminalConnected}
bind:connecting={terminalConnecting}
{chatId}
/>
</div>
{/if}
{/key}
</div>
{/if}
+337
View File
@@ -0,0 +1,337 @@
<script lang="ts">
import { getContext, onMount, tick } from 'svelte';
import type { Readable } from 'svelte/store';
import { settings, terminalServers, selectedTerminalId } from '$lib/stores';
import {
resolveTerminalConnection,
terminalRequest,
type TerminalConnection,
type TerminalProcess,
type TerminalProcessOutput
} from '$lib/apis/terminal';
import XTerminal from './XTerminal.svelte';
import Icon from './FileNav/Icon.svelte';
export let chatId: string | null = null;
export let expanded = false;
export let height = 200;
export let overlay = false;
export let connected = false;
export let connecting = false;
export let running = 0;
const i18n = getContext<Readable<{ t: (key: string) => string }>>('i18n');
type Tab = TerminalProcess & {
offset: number;
loaded: boolean;
available: boolean;
finished: boolean;
};
let connection: TerminalConnection | null = null;
let tabs: Tab[] = [];
let activeId = 'shell';
let shellOpened = false;
let shellDismissed = false;
let error = '';
let panes: Record<string, XTerminal> = {};
const dismissed = new Set<string>();
const controller = new AbortController();
let polling = false;
let disposed = false;
$: if (expanded && activeId === 'shell') shellOpened = true;
$: running = tabs.filter((tab) => tab.status === 'running' && tab.available).length;
async function poll() {
if (!connection || polling || disposed || document.hidden) return;
polling = true;
try {
const processes = await terminalRequest<TerminalProcess[]>(connection, chatId, '/execute', {
signal: controller.signal
});
if (disposed) return;
error = '';
const live = new Map(processes.map((process) => [process.id, process]));
tabs = tabs.map((tab) => ({ ...tab, ...live.get(tab.id), available: live.has(tab.id) }));
const known = new Set(tabs.map((tab) => tab.id));
for (const process of processes) {
if (!known.has(process.id) && !dismissed.has(process.id)) {
tabs = [
...tabs,
{ ...process, offset: 0, loaded: false, available: true, finished: false }
];
}
}
const tab = tabs.find((tab) => tab.id === activeId);
if (expanded && tab?.available && !tab.finished) {
await tick();
if (!panes[tab.id]) return;
const result = await terminalRequest<TerminalProcessOutput>(
connection,
chatId,
`/execute/${encodeURIComponent(tab.id)}/status?wait=0&offset=${tab.offset}&tail=1000`,
{ signal: controller.signal }
);
if (disposed || !panes[tab.id]) return;
if (result.truncated) panes[tab.id].write('\r\n[Earlier output omitted]\r\n');
panes[tab.id].write(result.output.map((entry) => entry.data).join(''));
tabs = tabs.map((item) =>
item.id === tab.id
? {
...item,
status: result.status,
exit_code: result.exit_code,
offset: result.next_offset,
loaded: true,
finished: result.status === 'done'
}
: item
);
}
} catch (cause) {
if (!disposed) error = String(cause);
} finally {
polling = false;
}
}
function select(id: string) {
expanded = true;
activeId = id;
if (id === 'shell') {
shellOpened = true;
shellDismissed = false;
}
tick().then(poll);
}
function dismiss(id: string) {
dismissed.add(id);
tabs = tabs.filter((tab) => tab.id !== id);
delete panes[id];
if (activeId === id) activeId = shellOpened ? 'shell' : (tabs[0]?.id ?? '');
}
function closeShell() {
shellOpened = false;
shellDismissed = true;
connected = false;
connecting = false;
activeId = tabs[0]?.id ?? '';
}
function navigate(event: KeyboardEvent) {
const ids = [...(!shellDismissed ? ['shell'] : []), ...tabs.map((tab) => tab.id)];
const index = ids.indexOf(activeId);
let next: string | undefined;
if (event.key === 'ArrowRight') next = ids[(index + 1) % ids.length];
if (event.key === 'ArrowLeft') next = ids[(index - 1 + ids.length) % ids.length];
if (event.key === 'Home') next = ids[0];
if (event.key === 'End') next = ids.at(-1);
if (next) {
event.preventDefault();
select(next);
(event.currentTarget as HTMLElement)
.querySelector<HTMLButtonElement>(`[data-tab="${next}"]`)
?.focus();
}
}
onMount(() => {
connection = resolveTerminalConnection(
$selectedTerminalId,
$terminalServers ?? [],
($settings as { terminalServers?: { url: string; key?: string }[] })?.terminalServers ?? [],
localStorage.getItem('token') ?? ''
);
poll();
const interval = setInterval(poll, 1000);
document.addEventListener('visibilitychange', poll);
return () => {
disposed = true;
controller.abort();
clearInterval(interval);
document.removeEventListener('visibilitychange', poll);
};
});
</script>
<div class="terminal-dock min-h-0">
<div
class="flex h-7 min-w-0 items-center border-b border-black/5 text-[11px] text-gray-500 dark:border-white/5 dark:text-gray-400"
>
{#if !expanded}
<button
on:click={() => (expanded = true)}
class="flex h-full min-w-0 flex-1 items-center gap-1.5 px-2 text-left hover:text-gray-900 dark:hover:text-gray-100"
>
<Icon name="terminal" size={13} />
<span>{$i18n.t('Terminal')}</span>
{#if running}<span class="tabular-nums text-gray-400">{running}</span>{/if}
</button>
{:else}
<div
role="tablist"
tabindex="-1"
aria-label={$i18n.t('Terminal')}
on:keydown={navigate}
class="terminal-tabs flex h-full min-w-0 flex-1 items-stretch overflow-x-auto"
>
{#if !shellDismissed}
<div
class="terminal-tab group flex shrink-0 items-center border-b"
class:selected={activeId === 'shell'}
>
<button
role="tab"
data-tab="shell"
aria-selected={activeId === 'shell'}
tabindex={activeId === 'shell' ? 0 : -1}
on:click={() => select('shell')}
class="tab-button flex h-full items-center gap-1.5 pl-2 pr-1"
title={$i18n.t('Shell')}
>
<Icon name="terminal" size={12} /><span>{$i18n.t('Shell')}</span>
</button>
{#if shellOpened}<button
on:click={closeShell}
class="tab-close mr-1 flex h-5 w-4 items-center justify-center"
title={$i18n.t('Close terminal')}
aria-label={$i18n.t('Close terminal')}><Icon name="xmark" size={10} /></button
>{/if}
</div>
{:else}
<button
class="tab-button flex w-7 shrink-0 items-center justify-center"
on:click={() => select('shell')}
title={$i18n.t('Open terminal')}
aria-label={$i18n.t('Open terminal')}><Icon name="plus" size={12} /></button
>
{/if}
{#each tabs as tab (tab.id)}
<div
class="terminal-tab group flex shrink-0 items-center border-b"
class:selected={activeId === tab.id}
>
<button
role="tab"
data-tab={tab.id}
aria-selected={activeId === tab.id}
tabindex={activeId === tab.id ? 0 : -1}
on:click={() => select(tab.id)}
class="tab-button flex h-full items-center gap-1.5 px-2"
title={`${tab.command} (${!tab.available ? 'Unavailable' : tab.status === 'running' ? 'Running' : `Exit ${tab.exit_code ?? tab.status}`})`}
>
<span
class="h-[5px] w-[5px] shrink-0 rounded-full"
class:bg-emerald-500={tab.available && tab.status === 'running'}
class:bg-red-400={tab.exit_code !== null && tab.exit_code !== 0}
class:bg-gray-400={!tab.available || (tab.status !== 'running' && !tab.exit_code)}
></span>
<span class="max-w-28 truncate">{tab.command}</span>
</button>
{#if tab.status !== 'running' || !tab.available}<button
on:click={() => dismiss(tab.id)}
class="tab-close mr-1 flex h-5 w-4 items-center justify-center"
title={$i18n.t('Dismiss')}
aria-label={`${$i18n.t('Dismiss')} ${tab.command}`}
><Icon name="xmark" size={10} /></button
>{/if}
</div>
{/each}
</div>
{/if}
<button
on:click={() => (expanded = !expanded)}
class="tab-button flex h-7 w-7 shrink-0 items-center justify-center"
aria-expanded={expanded}
title={$i18n.t(expanded ? 'Collapse terminal' : 'Expand terminal')}
aria-label={$i18n.t(expanded ? 'Collapse terminal' : 'Expand terminal')}
>
<Icon name={expanded ? 'chevron-down' : 'chevron-up'} size={12} />
</button>
</div>
<div style:height={`${height}px`} class="flex min-h-0 flex-col bg-black" class:hidden={!expanded}>
{#if error}<div class="shrink-0 truncate px-2 text-xs text-red-400" title={error}>
{error}
</div>{/if}
<div class="relative min-h-0 flex-1">
{#if connection && shellOpened}
<div class="absolute inset-0" class:hidden={activeId !== 'shell'}>
<XTerminal
{connection}
{chatId}
{overlay}
active={expanded && activeId === 'shell'}
bind:connected
bind:connecting
/>
</div>
{/if}
{#if connection}
{#each tabs as tab (tab.id)}
{#if tab.loaded || activeId === tab.id}
<div class="absolute inset-0" class:hidden={activeId !== tab.id}>
<XTerminal
bind:this={panes[tab.id]}
{connection}
{chatId}
{overlay}
readOnly
active={expanded && activeId === tab.id}
/>
</div>
{/if}
{/each}
{/if}
</div>
</div>
</div>
<style>
.terminal-tabs {
scrollbar-width: none;
}
.terminal-tabs::-webkit-scrollbar {
display: none;
}
.terminal-tab {
border-color: transparent;
}
.terminal-tab.selected {
border-color: currentColor;
color: var(--color-gray-900, #171717);
}
:global(.dark) .terminal-tab.selected {
color: #e5e5e5;
}
.tab-button,
.tab-close {
outline: none;
}
.terminal-dock .tab-button:focus-visible,
.terminal-dock .tab-close:focus-visible {
outline: 1px solid currentColor;
outline-offset: -3px;
}
.tab-button:hover,
.tab-close:hover {
color: var(--color-gray-900, #171717);
}
:global(.dark) .tab-button:hover,
:global(.dark) .tab-close:hover {
color: #f5f5f5;
}
.tab-close {
opacity: 0;
}
.terminal-tab:hover .tab-close,
.terminal-tab:focus-within .tab-close {
opacity: 1;
}
@media (hover: none) {
.tab-close {
opacity: 1;
}
}
</style>
+78 -228
View File
@@ -4,185 +4,99 @@
import { FitAddon } from '@xterm/addon-fit';
import { WebLinksAddon } from '@xterm/addon-web-links';
import '@xterm/xterm/css/xterm.css';
import { terminalRequest, type TerminalConnection } from '$lib/apis/terminal';
import { terminalServers, settings, selectedTerminalId } from '$lib/stores';
import { WEBUI_API_BASE_URL } from '$lib/constants';
export let overlay = false;
export let connection: TerminalConnection;
export let chatId: string | null = null;
export let overlay = false;
export let active = true;
export let readOnly = false;
export let connected = false;
export let connecting = false;
let terminalEl: HTMLDivElement;
let term: Terminal | null = null;
let fitAddon: FitAddon | null = null;
let fitAddon: FitAddon;
let ws: WebSocket | null = null;
export let connected = false;
export let connecting = false;
let resizeObserver: ResizeObserver | null = null;
let pingInterval: ReturnType<typeof setInterval> | null = null;
let resizeObserver: ResizeObserver;
let pingInterval: ReturnType<typeof setInterval>;
let destroyed = false;
let sessionId = '';
// Resolve the active terminal server's info for the WebSocket URL
const getTerminalInfo = (): {
serverId: string;
baseUrl: string;
} | null => {
// System terminal (admin-configured, has an `id`)
const systemTerminals = ($terminalServers ?? []).filter((t: any) => t.id);
const systemMatch = systemTerminals.find((t: any) => t.id === $selectedTerminalId);
if (systemMatch) {
// For system terminals, WS goes through the Open WebUI backend proxy
return {
serverId: systemMatch.id,
baseUrl: WEBUI_API_BASE_URL
};
}
export function write(output: string) {
if (!term || destroyed) return;
const following = term.buffer.active.viewportY >= term.buffer.active.baseY;
term.write(output, () => {
if (following) term?.scrollToBottom();
});
}
// Direct terminal (user-configured, matched by URL)
const directTerminals = ($settings?.terminalServers ?? []).filter((s: any) => s.url);
const directMatch = directTerminals.find((s: any) => s.url === $selectedTerminalId);
if (directMatch) {
// For direct terminals, construct WS URL from the server URL directly
return { serverId: '__direct__', baseUrl: directMatch.url };
}
return null;
};
const connect = async () => {
if (ws) disconnect();
const info = getTerminalInfo();
if (!info) return;
function fit() {
if (active && terminalEl?.clientWidth && terminalEl?.clientHeight) fitAddon?.fit();
}
async function connect() {
connecting = true;
const token = localStorage.getItem('token') ?? '';
try {
let sessionId: string;
let wsUrl: string;
let authToken: string;
let authChatId = '';
if (info.serverId === '__direct__') {
// Direct connection to open-terminal
const base = info.baseUrl.replace(/\/$/, '');
const directTerminals = ($settings?.terminalServers ?? []).filter((s: any) => s.url);
const directMatch = directTerminals.find((s: any) => s.url === $selectedTerminalId);
const apiKey = directMatch?.key ?? '';
authToken = apiKey;
// Create session
const createHeaders: Record<string, string> = { Authorization: `Bearer ${apiKey}` };
if (chatId) createHeaders['X-Session-Id'] = chatId;
const res = await fetch(`${base}/api/terminals`, {
method: 'POST',
headers: createHeaders
});
if (!res.ok) throw new Error(`Failed to create session: ${res.status}`);
const session = await res.json();
sessionId = session.id;
const wsBase = base.replace(/^https:/, 'wss:').replace(/^http:/, 'ws:');
wsUrl = `${wsBase}/api/terminals/${sessionId}`;
} else {
// System terminal — proxy through Open WebUI backend
const base = info.baseUrl.replace(/\/$/, '');
authToken = token;
// Create session via proxy
const proxyHeaders: Record<string, string> = { Authorization: `Bearer ${token}` };
if (chatId) proxyHeaders['X-Session-Id'] = chatId;
const res = await fetch(`${base}/terminals/${info.serverId}/api/terminals`, {
method: 'POST',
headers: proxyHeaders
});
if (!res.ok) throw new Error(`Failed to create session: ${res.status}`);
const session = await res.json();
sessionId = session.id;
const wsBase = base.replace(/^https:/, 'wss:').replace(/^http:/, 'ws:');
wsUrl = `${wsBase}/terminals/${info.serverId}/api/terminals/${sessionId}`;
authChatId = chatId ?? '';
const session = await terminalRequest<{ id: string }>(connection, chatId, '/api/terminals', {
method: 'POST'
});
sessionId = session.id;
if (destroyed) {
await terminalRequest(
connection,
chatId,
`/api/terminals/${encodeURIComponent(sessionId)}`,
{ method: 'DELETE' }
);
return;
}
ws = new WebSocket(wsUrl);
const url = new URL(
`${connection.baseUrl}/api/terminals/${encodeURIComponent(sessionId)}`,
location.href
);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(url);
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
// First-message auth (no token in URL)
if (ws) {
const authPayload: { type: string; token: string; chat_id?: string } = {
type: 'auth',
token: authToken.trim()
};
if (authChatId) authPayload.chat_id = authChatId;
ws.send(JSON.stringify(authPayload));
}
ws?.send(
JSON.stringify({ type: 'auth', token: connection.key.trim(), chat_id: chatId ?? '' })
);
connected = true;
connecting = false;
// Focus the terminal so it receives keyboard input immediately
term?.focus();
// Send initial resize
if (term && ws) {
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
}
// Keepalive ping to prevent idle timeout from proxies/LBs
if (pingInterval) clearInterval(pingInterval);
fit();
if (active) term?.focus();
if (term) ws?.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
pingInterval = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'ping' }));
}, 25000);
};
ws.onmessage = (event) => {
if (term) {
if (event.data instanceof ArrayBuffer) {
term.write(new Uint8Array(event.data));
} else {
term.write(event.data);
}
}
if (event.data instanceof ArrayBuffer) term?.write(new Uint8Array(event.data));
else write(event.data);
};
ws.onclose = () => {
connected = false;
connecting = false;
if (term) {
term.write('\r\n\x1b[90m[Connection closed]\x1b[0m\r\n');
}
clearInterval(pingInterval);
write('\r\n\x1b[90m[Connection closed]\x1b[0m\r\n');
};
ws.onerror = () => {
connected = false;
connecting = false;
write('\r\n\x1b[31m[Terminal connection failed]\x1b[0m\r\n');
};
} catch (err) {
} catch (error) {
connecting = false;
if (term) {
term.write(`\r\n\x1b[31m[Error: ${err}]\x1b[0m\r\n`);
}
write(`\r\n\x1b[31m[${error}]\x1b[0m\r\n`);
}
};
}
const disconnect = () => {
if (pingInterval) {
clearInterval(pingInterval);
pingInterval = null;
}
if (ws) {
ws.close();
ws = null;
}
connected = false;
connecting = false;
};
const initTerminal = () => {
if (!terminalEl || term) return;
$: if (active && term) requestAnimationFrame(fit);
onMount(() => {
term = new Terminal({
cursorBlink: true,
cursorBlink: !readOnly,
disableStdin: readOnly,
fontSize: 13,
fontFamily:
"'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, 'Courier New', monospace",
@@ -190,104 +104,40 @@
background: '#000000',
foreground: '#c0c0c0',
cursor: '#ffffff',
cursorAccent: '#000000',
selectionBackground: '#444444',
selectionForeground: '#ffffff',
black: '#000000',
red: '#cd0000',
green: '#00cd00',
yellow: '#cdcd00',
blue: '#0000ee',
magenta: '#cd00cd',
cyan: '#00cdcd',
white: '#e5e5e5',
brightBlack: '#7f7f7f',
brightRed: '#ff0000',
brightGreen: '#00ff00',
brightYellow: '#ffff00',
brightBlue: '#5c5cff',
brightMagenta: '#ff00ff',
brightCyan: '#00ffff',
brightWhite: '#ffffff'
selectionBackground: '#444444'
},
allowProposedApi: true,
scrollback: 5000
});
fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.loadAddon(new WebLinksAddon());
term.open(terminalEl);
// Fit after a frame so the container has dimensions
requestAnimationFrame(() => {
fitAddon?.fit();
});
// Forward keystrokes to WebSocket
term.onData((data) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(new TextEncoder().encode(data));
}
});
// Forward binary data (e.g. paste with special chars)
term.onBinary((data) => {
if (ws && ws.readyState === WebSocket.OPEN) {
const buffer = new Uint8Array(data.length);
for (let i = 0; i < data.length; i++) {
buffer[i] = data.charCodeAt(i) & 0xff;
}
ws.send(buffer);
}
});
// Ensure all key events are processed by xterm.js and not intercepted
// by the browser or surrounding UI (fixes vi/vim keystroke handling).
term.attachCustomKeyEventHandler(() => true);
// Handle resize
term.onResize(({ cols, rows }) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'resize', cols, rows }));
}
});
// Watch container size changes
resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => {
fitAddon?.fit();
});
});
resizeObserver = new ResizeObserver(fit);
resizeObserver.observe(terminalEl);
// Connection is handled by the reactive block below (which fires
// when `term` is set here), so we intentionally do NOT call
// connect() to avoid creating a duplicate WebSocket whose onclose
// handler would write a spurious "[Connection closed]" message.
};
// Reconnect when the selected terminal changes
$: if (($selectedTerminalId, chatId, term)) {
// Clear the terminal screen and reconnect to the new server
disconnect();
term.clear();
if ($selectedTerminalId) {
requestAnimationFrame(fit);
if (!readOnly) {
term.onData((data) => {
if (ws?.readyState === WebSocket.OPEN) ws.send(new TextEncoder().encode(data));
});
term.onBinary((data) => {
if (ws?.readyState === WebSocket.OPEN)
ws.send(Uint8Array.from(data, (char) => char.charCodeAt(0) & 0xff));
});
term.onResize(({ cols, rows }) => {
if (active && ws?.readyState === WebSocket.OPEN)
ws.send(JSON.stringify({ type: 'resize', cols, rows }));
});
connect();
}
}
onMount(() => {
initTerminal();
});
onDestroy(() => {
disconnect();
destroyed = true;
clearInterval(pingInterval);
ws?.close();
resizeObserver?.disconnect();
term?.dispose();
term = null;
fitAddon = null;
});
</script>