enh: open terminal
This commit is contained in:
@@ -8,13 +8,14 @@ Routes:
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from fastapi import APIRouter, Depends, Request, Response
|
from fastapi import APIRouter, Depends, Request, Response, WebSocket
|
||||||
from fastapi.responses import JSONResponse, StreamingResponse
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
from starlette.background import BackgroundTask
|
from starlette.background import BackgroundTask
|
||||||
|
|
||||||
from open_webui.utils.auth import get_verified_user
|
from open_webui.utils.auth import get_verified_user
|
||||||
from open_webui.utils.access_control import has_connection_access
|
from open_webui.utils.access_control import has_connection_access
|
||||||
from open_webui.models.groups import Groups
|
from open_webui.models.groups import Groups
|
||||||
|
from open_webui.models.users import Users
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -149,3 +150,151 @@ async def proxy_terminal(
|
|||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{"error": f"Terminal proxy error: {error}"}, status_code=502
|
{"error": f"Terminal proxy error: {error}"}, status_code=502
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# WebSocket proxy for interactive terminal sessions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_authenticated_connection(ws: WebSocket, server_id: str):
|
||||||
|
"""Authenticate a WebSocket via first-message auth and resolve the terminal server.
|
||||||
|
|
||||||
|
The client must send ``{"type": "auth", "token": "<jwt>"}`` as its first
|
||||||
|
message after connecting.
|
||||||
|
|
||||||
|
Returns ``(user, connection)`` on success, or ``None`` after closing *ws*
|
||||||
|
with an appropriate error code.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from open_webui.utils.auth import decode_token
|
||||||
|
|
||||||
|
# First-message authentication
|
||||||
|
try:
|
||||||
|
raw = await asyncio.wait_for(ws.receive_text(), timeout=10.0)
|
||||||
|
payload = json.loads(raw)
|
||||||
|
if payload.get("type") != "auth":
|
||||||
|
await ws.close(code=4001, reason="Expected auth message")
|
||||||
|
return None
|
||||||
|
token = payload.get("token", "")
|
||||||
|
data = decode_token(token)
|
||||||
|
if data is None or "id" not in data:
|
||||||
|
await ws.close(code=4001, reason="Invalid token")
|
||||||
|
return None
|
||||||
|
user = Users.get_user_by_id(data["id"])
|
||||||
|
if user is None:
|
||||||
|
await ws.close(code=4001, reason="User not found")
|
||||||
|
return None
|
||||||
|
except (asyncio.TimeoutError, json.JSONDecodeError):
|
||||||
|
await ws.close(code=4001, reason="Auth timeout or invalid payload")
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
await ws.close(code=4001, reason="Invalid token")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Resolve terminal server
|
||||||
|
connections = ws.app.state.config.TERMINAL_SERVER_CONNECTIONS or []
|
||||||
|
connection = next((c for c in connections if c.get("id") == server_id), None)
|
||||||
|
|
||||||
|
if connection is None:
|
||||||
|
await ws.close(code=4004, reason="Terminal server not found")
|
||||||
|
return None
|
||||||
|
|
||||||
|
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)}
|
||||||
|
if not has_connection_access(user, connection, user_group_ids):
|
||||||
|
await ws.close(code=4003, reason="Access denied")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return user, connection
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/{server_id}/api/terminals/{session_id}")
|
||||||
|
async def ws_terminal(
|
||||||
|
ws: WebSocket,
|
||||||
|
server_id: str,
|
||||||
|
session_id: str,
|
||||||
|
):
|
||||||
|
"""Proxy an interactive WebSocket terminal session to a terminal server.
|
||||||
|
|
||||||
|
Uses first-message auth: the client sends ``{"type": "auth", "token": "<jwt>"}``
|
||||||
|
as its first message. The proxy validates the JWT, then connects to the
|
||||||
|
upstream terminal server and authenticates with the server's API key.
|
||||||
|
"""
|
||||||
|
await ws.accept()
|
||||||
|
|
||||||
|
result = await _resolve_authenticated_connection(ws, server_id)
|
||||||
|
if result is None:
|
||||||
|
return
|
||||||
|
user, connection = result
|
||||||
|
|
||||||
|
base_url = (connection.get("url") or "").rstrip("/")
|
||||||
|
if not base_url:
|
||||||
|
await ws.close(code=4003, reason="Terminal server URL not configured")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Build upstream WebSocket URL (no token in URL)
|
||||||
|
ws_base = base_url.replace("https://", "wss://").replace("http://", "ws://")
|
||||||
|
|
||||||
|
auth_type = connection.get("auth_type", "bearer")
|
||||||
|
upstream_params = {}
|
||||||
|
# For orchestrator-backed servers, pass user_id
|
||||||
|
upstream_params["user_id"] = user.id
|
||||||
|
|
||||||
|
import urllib.parse
|
||||||
|
upstream_url = f"{ws_base}/api/terminals/{session_id}"
|
||||||
|
if upstream_params:
|
||||||
|
upstream_url += f"?{urllib.parse.urlencode(upstream_params)}"
|
||||||
|
|
||||||
|
session = aiohttp.ClientSession()
|
||||||
|
try:
|
||||||
|
async with session.ws_connect(upstream_url) as upstream:
|
||||||
|
import asyncio
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
# First-message auth to upstream terminal server
|
||||||
|
auth_type = connection.get("auth_type", "bearer")
|
||||||
|
if auth_type == "bearer":
|
||||||
|
key = connection.get("key", "")
|
||||||
|
await upstream.send_str(_json.dumps({"type": "auth", "token": key}))
|
||||||
|
|
||||||
|
async def _client_to_upstream():
|
||||||
|
"""Forward client → upstream."""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
msg = await ws.receive()
|
||||||
|
if msg["type"] == "websocket.disconnect":
|
||||||
|
break
|
||||||
|
elif "bytes" in msg and msg["bytes"]:
|
||||||
|
await upstream.send_bytes(msg["bytes"])
|
||||||
|
elif "text" in msg and msg["text"]:
|
||||||
|
await upstream.send_str(msg["text"])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _upstream_to_client():
|
||||||
|
"""Forward upstream → client."""
|
||||||
|
try:
|
||||||
|
async for msg in upstream:
|
||||||
|
if msg.type == aiohttp.WSMsgType.BINARY:
|
||||||
|
await ws.send_bytes(msg.data)
|
||||||
|
elif msg.type == aiohttp.WSMsgType.TEXT:
|
||||||
|
await ws.send_text(msg.data)
|
||||||
|
elif msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.ERROR):
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
await asyncio.gather(
|
||||||
|
_client_to_upstream(),
|
||||||
|
_upstream_to_client(),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
log.exception("Terminal WebSocket proxy error: %s", e)
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
|
try:
|
||||||
|
await ws.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|||||||
Generated
+123
-546
File diff suppressed because it is too large
Load Diff
@@ -82,6 +82,8 @@
|
|||||||
"@tiptap/pm": "^3.0.7",
|
"@tiptap/pm": "^3.0.7",
|
||||||
"@tiptap/starter-kit": "^3.0.7",
|
"@tiptap/starter-kit": "^3.0.7",
|
||||||
"@tiptap/suggestion": "^3.4.2",
|
"@tiptap/suggestion": "^3.4.2",
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"@xyflow/svelte": "^0.1.19",
|
"@xyflow/svelte": "^0.1.19",
|
||||||
"alpinejs": "^3.15.0",
|
"alpinejs": "^3.15.0",
|
||||||
|
|||||||
@@ -36,12 +36,47 @@
|
|||||||
import FileNavToolbar from './FileNav/FileNavToolbar.svelte';
|
import FileNavToolbar from './FileNav/FileNavToolbar.svelte';
|
||||||
import FilePreview from './FileNav/FilePreview.svelte';
|
import FilePreview from './FileNav/FilePreview.svelte';
|
||||||
import FileEntryRow from './FileNav/FileEntryRow.svelte';
|
import FileEntryRow from './FileNav/FileEntryRow.svelte';
|
||||||
|
import XTerminal from './XTerminal.svelte';
|
||||||
|
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
export let onAttach: ((blob: Blob, name: string, contentType: string) => void) | null = null;
|
export let onAttach: ((blob: Blob, name: string, contentType: string) => void) | null = null;
|
||||||
export let overlay = false;
|
export let overlay = false;
|
||||||
|
|
||||||
|
// ── Terminal panel state ────────────────────────────────────────────
|
||||||
|
let terminalExpanded = false;
|
||||||
|
let terminalHeight = 200; // px, default when expanded
|
||||||
|
let isDraggingHandle = false;
|
||||||
|
let containerEl: HTMLElement;
|
||||||
|
let terminalConnected = false;
|
||||||
|
let terminalConnecting = false;
|
||||||
|
|
||||||
|
const toggleTerminal = () => {
|
||||||
|
terminalExpanded = !terminalExpanded;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onHandleMouseDown = (e: MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
isDraggingHandle = true;
|
||||||
|
const startY = e.clientY;
|
||||||
|
const startHeight = terminalHeight;
|
||||||
|
|
||||||
|
const onMouseMove = (ev: MouseEvent) => {
|
||||||
|
const delta = startY - ev.clientY;
|
||||||
|
const maxH = containerEl ? containerEl.clientHeight - 100 : 500;
|
||||||
|
terminalHeight = Math.max(80, Math.min(maxH, startHeight + delta));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseUp = () => {
|
||||||
|
isDraggingHandle = false;
|
||||||
|
window.removeEventListener('mousemove', onMouseMove);
|
||||||
|
window.removeEventListener('mouseup', onMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', onMouseMove);
|
||||||
|
window.addEventListener('mouseup', onMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
// ── Directory state ──────────────────────────────────────────────────
|
// ── Directory state ──────────────────────────────────────────────────
|
||||||
let currentPath = savedPath;
|
let currentPath = savedPath;
|
||||||
let entries: FileEntry[] = [];
|
let entries: FileEntry[] = [];
|
||||||
@@ -454,6 +489,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
|
bind:this={containerEl}
|
||||||
class="flex flex-col h-full min-h-0 relative"
|
class="flex flex-col h-full min-h-0 relative"
|
||||||
on:dragover={handleDragOver}
|
on:dragover={handleDragOver}
|
||||||
on:dragleave={() => (isDragOver = false)}
|
on:dragleave={() => (isDragOver = false)}
|
||||||
@@ -754,5 +790,55 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Terminal bottom panel -->
|
||||||
|
<div class="shrink-0 border-t border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-850">
|
||||||
|
{#if terminalExpanded}
|
||||||
|
<!-- Drag handle (at top of panel) -->
|
||||||
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||||
|
<div
|
||||||
|
class="h-1 cursor-row-resize hover:bg-blue-400/30 transition group relative"
|
||||||
|
on:mousedown={onHandleMouseDown}
|
||||||
|
>
|
||||||
|
<div class="absolute inset-x-0 -top-1 -bottom-1" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Toggle header (full-width button) -->
|
||||||
|
<button
|
||||||
|
class="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition"
|
||||||
|
on:click={toggleTerminal}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-3.5">
|
||||||
|
<path fill-rule="evenodd" d="M3.25 3A2.25 2.25 0 0 0 1 5.25v9.5A2.25 2.25 0 0 0 3.25 17h13.5A2.25 2.25 0 0 0 19 14.75v-9.5A2.25 2.25 0 0 0 16.75 3H3.25Zm.943 8.752a.75.75 0 0 1 .055-1.06L6.128 9l-1.88-1.693a.75.75 0 1 1 1.004-1.114l2.5 2.25a.75.75 0 0 1 0 1.114l-2.5 2.25a.75.75 0 0 1-1.06-.055ZM9.75 10.25a.75.75 0 0 0 0 1.5h2.5a.75.75 0 0 0 0-1.5h-2.5Z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<span class="font-medium">{$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}
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
class="size-3 ml-auto transition-transform {terminalExpanded ? 'rotate-180' : ''}"
|
||||||
|
>
|
||||||
|
<path fill-rule="evenodd" d="M9.47 6.47a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 1 1-1.06 1.06L10 8.06l-3.72 3.72a.75.75 0 0 1-1.06-1.06l4.25-4.25Z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if terminalExpanded}
|
||||||
|
<div style="height: {terminalHeight}px" class="min-h-0">
|
||||||
|
<XTerminal {overlay} bind:connected={terminalConnected} bind:connecting={terminalConnecting} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy, getContext } from 'svelte';
|
||||||
|
import { Terminal } from '@xterm/xterm';
|
||||||
|
import { FitAddon } from '@xterm/addon-fit';
|
||||||
|
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||||
|
import '@xterm/xterm/css/xterm.css';
|
||||||
|
|
||||||
|
import {
|
||||||
|
terminalServers,
|
||||||
|
settings,
|
||||||
|
selectedTerminalId,
|
||||||
|
user
|
||||||
|
} from '$lib/stores';
|
||||||
|
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||||
|
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||||
|
|
||||||
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
|
export let overlay = false;
|
||||||
|
|
||||||
|
let terminalEl: HTMLDivElement;
|
||||||
|
let term: Terminal | null = null;
|
||||||
|
let fitAddon: FitAddon | null = null;
|
||||||
|
let ws: WebSocket | null = null;
|
||||||
|
export let connected = false;
|
||||||
|
export let connecting = false;
|
||||||
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
|
||||||
|
// 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
connecting = true;
|
||||||
|
|
||||||
|
const token = localStorage.getItem('token') ?? '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
let sessionId: string;
|
||||||
|
let wsUrl: string;
|
||||||
|
let authToken: string;
|
||||||
|
|
||||||
|
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 res = await fetch(`${base}/api/terminals`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${apiKey}` }
|
||||||
|
});
|
||||||
|
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 res = await fetch(`${base}/terminals/${info.serverId}/api/terminals`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
ws = new WebSocket(wsUrl);
|
||||||
|
ws.binaryType = 'arraybuffer';
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
// First-message auth (no token in URL)
|
||||||
|
if (ws) {
|
||||||
|
ws.send(JSON.stringify({ type: 'auth', token: authToken }));
|
||||||
|
}
|
||||||
|
connected = true;
|
||||||
|
connecting = false;
|
||||||
|
// Send initial resize
|
||||||
|
if (term && ws) {
|
||||||
|
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
if (term) {
|
||||||
|
if (event.data instanceof ArrayBuffer) {
|
||||||
|
term.write(new Uint8Array(event.data));
|
||||||
|
} else {
|
||||||
|
term.write(event.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
connected = false;
|
||||||
|
connecting = false;
|
||||||
|
if (term) {
|
||||||
|
term.write('\r\n\x1b[90m[Connection closed]\x1b[0m\r\n');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
connected = false;
|
||||||
|
connecting = false;
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
connecting = false;
|
||||||
|
if (term) {
|
||||||
|
term.write(`\r\n\x1b[31m[Error: ${err}]\x1b[0m\r\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const disconnect = () => {
|
||||||
|
if (ws) {
|
||||||
|
ws.close();
|
||||||
|
ws = null;
|
||||||
|
}
|
||||||
|
connected = false;
|
||||||
|
connecting = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initTerminal = () => {
|
||||||
|
if (!terminalEl || term) return;
|
||||||
|
|
||||||
|
term = new Terminal({
|
||||||
|
cursorBlink: true,
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, 'Courier New', monospace",
|
||||||
|
theme: {
|
||||||
|
background: '#1a1b26',
|
||||||
|
foreground: '#c0caf5',
|
||||||
|
cursor: '#c0caf5',
|
||||||
|
cursorAccent: '#1a1b26',
|
||||||
|
selectionBackground: '#33467c',
|
||||||
|
selectionForeground: '#c0caf5',
|
||||||
|
black: '#15161e',
|
||||||
|
red: '#f7768e',
|
||||||
|
green: '#9ece6a',
|
||||||
|
yellow: '#e0af68',
|
||||||
|
blue: '#7aa2f7',
|
||||||
|
magenta: '#bb9af7',
|
||||||
|
cyan: '#7dcfff',
|
||||||
|
white: '#a9b1d6',
|
||||||
|
brightBlack: '#414868',
|
||||||
|
brightRed: '#f7768e',
|
||||||
|
brightGreen: '#9ece6a',
|
||||||
|
brightYellow: '#e0af68',
|
||||||
|
brightBlue: '#7aa2f7',
|
||||||
|
brightMagenta: '#bb9af7',
|
||||||
|
brightCyan: '#7dcfff',
|
||||||
|
brightWhite: '#c0caf5'
|
||||||
|
},
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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.observe(terminalEl);
|
||||||
|
|
||||||
|
// Auto-connect
|
||||||
|
connect();
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
initTerminal();
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
disconnect();
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
term?.dispose();
|
||||||
|
term = null;
|
||||||
|
fitAddon = null;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="h-full min-h-0 relative">
|
||||||
|
<div
|
||||||
|
bind:this={terminalEl}
|
||||||
|
class="absolute inset-0 p-1"
|
||||||
|
class:pointer-events-none={overlay}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user