213 lines
7.8 KiB
Svelte
213 lines
7.8 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy } from 'svelte';
|
|
import { Terminal } from '@xterm/xterm';
|
|
import { CanvasAddon } from '@xterm/addon-canvas';
|
|
import { FitAddon } from '@xterm/addon-fit';
|
|
import { ImageAddon } from '@xterm/addon-image';
|
|
import { appRpc } from './rpc.ts';
|
|
import { fontStore } from './font-store.svelte.ts';
|
|
import { themeStore } from './theme-store.svelte.ts';
|
|
import { getXtermTheme } from './themes.ts';
|
|
|
|
interface Props {
|
|
sessionId: string;
|
|
/** Working directory to open the shell in. */
|
|
cwd?: string;
|
|
}
|
|
|
|
let { sessionId, cwd }: Props = $props();
|
|
|
|
let termEl: HTMLDivElement;
|
|
let term: Terminal;
|
|
let fitAddon: FitAddon;
|
|
let unsubFont: (() => void) | null = null;
|
|
let ro: ResizeObserver | null = null;
|
|
let destroyed = false;
|
|
// Fix #5: Store listener cleanup functions to prevent leaks
|
|
let listenerCleanups: Array<() => void> = [];
|
|
|
|
/** Decode a base64 string from the daemon into a Uint8Array. */
|
|
function decodeBase64(b64: string): Uint8Array {
|
|
const binary = atob(b64);
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
return bytes;
|
|
}
|
|
|
|
onMount(() => {
|
|
const currentTheme = themeStore.currentTheme;
|
|
const termFamily = fontStore.termFontFamily || 'JetBrains Mono, Fira Code, monospace';
|
|
const termSize = fontStore.termFontSize || 13;
|
|
|
|
term = new Terminal({
|
|
theme: getXtermTheme(currentTheme),
|
|
fontFamily: termFamily,
|
|
fontSize: termSize,
|
|
cursorBlink: true,
|
|
allowProposedApi: true,
|
|
scrollback: 5000,
|
|
});
|
|
|
|
fitAddon = new FitAddon();
|
|
term.loadAddon(fitAddon);
|
|
// NOTE: CanvasAddon and ImageAddon MUST be loaded AFTER term.open()
|
|
// because they access _linkifier2 which is created during open()
|
|
|
|
const openAndLoadAddons = () => {
|
|
term.open(termEl);
|
|
// Now safe to load addons that depend on _linkifier2
|
|
try { term.loadAddon(new CanvasAddon()); } catch (e) {
|
|
console.warn('[Terminal] CanvasAddon failed:', (e as Error).message);
|
|
}
|
|
try {
|
|
term.loadAddon(new ImageAddon({
|
|
enableSizeReports: true,
|
|
sixelSupport: true,
|
|
sixelScrolling: true,
|
|
sixelPaletteLimit: 4096,
|
|
showPlaceholder: true,
|
|
}));
|
|
} catch (e) {
|
|
console.warn('[Terminal] ImageAddon failed:', (e as Error).message);
|
|
}
|
|
fitAddon.fit();
|
|
};
|
|
|
|
// Defer if container isn't visible yet (e.g., behind splash)
|
|
if (termEl.offsetParent !== null) {
|
|
openAndLoadAddons();
|
|
} else {
|
|
const observer = new IntersectionObserver((entries) => {
|
|
if (entries[0]?.isIntersecting) {
|
|
observer.disconnect();
|
|
openAndLoadAddons();
|
|
}
|
|
});
|
|
observer.observe(termEl);
|
|
setTimeout(() => { observer.disconnect(); openAndLoadAddons(); }, 2000);
|
|
}
|
|
|
|
// ── Read cursor/scrollback settings ─────────────────────────────────
|
|
|
|
void (async () => {
|
|
try {
|
|
const { settings } = await appRpc.request['settings.getAll']({});
|
|
if (settings['cursor_style']) {
|
|
const style = settings['cursor_style'];
|
|
if (style === 'block' || style === 'underline' || style === 'bar') {
|
|
term.options.cursorStyle = style;
|
|
}
|
|
}
|
|
if (settings['cursor_blink'] === 'false') {
|
|
term.options.cursorBlink = false;
|
|
}
|
|
if (settings['scrollback']) {
|
|
const sb = parseInt(settings['scrollback'], 10);
|
|
if (!isNaN(sb) && sb >= 0) term.options.scrollback = sb;
|
|
}
|
|
} catch { /* non-critical — use defaults */ }
|
|
})();
|
|
|
|
// ── Subscribe to terminal font changes ─────────────────────────────────
|
|
|
|
unsubFont = fontStore.onTermFontChange((family: string, size: number) => {
|
|
term.options.fontFamily = family || 'JetBrains Mono, Fira Code, monospace';
|
|
term.options.fontSize = size;
|
|
fitAddon.fit();
|
|
appRpc.request['pty.resize']({ sessionId, cols: term.cols, rows: term.rows }).catch(() => {});
|
|
});
|
|
|
|
// ── Connect to PTY daemon (fire-and-forget from onMount) ───────────────
|
|
|
|
void (async () => {
|
|
// Read default_shell and default_cwd from settings if not provided
|
|
let effectiveCwd = cwd;
|
|
try {
|
|
const { settings } = await appRpc.request['settings.getAll']({});
|
|
if (!effectiveCwd && settings['default_cwd']) {
|
|
effectiveCwd = settings['default_cwd'];
|
|
}
|
|
// default_shell is handled by agor-ptyd, not needed in create params
|
|
} catch { /* use provided or defaults */ }
|
|
|
|
const { cols, rows } = term;
|
|
const result = await appRpc.request['pty.create']({ sessionId, cols, rows, cwd: effectiveCwd });
|
|
if (!result?.ok) {
|
|
term.writeln(`\x1b[31m[agor] Failed to connect to PTY daemon: ${result?.error ?? 'unknown error'}\x1b[0m`);
|
|
term.writeln('\x1b[33m[agor] Is agor-ptyd running? Start it with: agor-ptyd\x1b[0m');
|
|
}
|
|
})();
|
|
|
|
// ── Receive output from daemon ─────────────────────────────────────────
|
|
|
|
const outputHandler = ({ sessionId: sid, data }: { sessionId: string; data: string }) => {
|
|
if (destroyed || sid !== sessionId) return;
|
|
term.write(decodeBase64(data));
|
|
};
|
|
appRpc.addMessageListener('pty.output', outputHandler);
|
|
|
|
const closedHandler = ({ sessionId: sid, exitCode }: { sessionId: string; exitCode: number | null }) => {
|
|
if (destroyed || sid !== sessionId) return;
|
|
term.writeln(`\r\n\x1b[90m[Process exited${exitCode !== null ? ` with code ${exitCode}` : ''}]\x1b[0m`);
|
|
};
|
|
appRpc.addMessageListener('pty.closed', closedHandler);
|
|
|
|
// Fix #5: Store cleanup functions for message listeners
|
|
listenerCleanups.push(
|
|
() => appRpc.removeMessageListener?.('pty.output', outputHandler),
|
|
() => appRpc.removeMessageListener?.('pty.closed', closedHandler),
|
|
);
|
|
|
|
// ── Send user input to daemon ──────────────────────────────────────────
|
|
|
|
// Feature 5: Max terminal paste chunk (64KB) — truncate with warning
|
|
const MAX_PASTE_CHUNK = 64 * 1024;
|
|
term.onData((data: string) => {
|
|
let payload = data;
|
|
if (payload.length > MAX_PASTE_CHUNK) {
|
|
console.warn(`[terminal] Paste truncated from ${payload.length} to ${MAX_PASTE_CHUNK} bytes`);
|
|
payload = payload.slice(0, MAX_PASTE_CHUNK);
|
|
term.writeln('\r\n\x1b[33m[agor] Paste truncated to 64KB\x1b[0m');
|
|
}
|
|
appRpc.request['pty.write']({ sessionId, data: payload }).catch((err: unknown) => {
|
|
console.error('[pty.write] error:', err);
|
|
});
|
|
});
|
|
|
|
// ── Sync resize events to daemon ───────────────────────────────────────
|
|
|
|
term.onResize(({ cols: c, rows: r }) => {
|
|
appRpc.request['pty.resize']({ sessionId, cols: c, rows: r }).catch(() => {});
|
|
});
|
|
|
|
ro = new ResizeObserver(() => { fitAddon.fit(); });
|
|
ro.observe(termEl);
|
|
});
|
|
|
|
onDestroy(() => {
|
|
destroyed = true;
|
|
unsubFont?.();
|
|
ro?.disconnect();
|
|
// Fix #5: Clean up all message listeners to prevent leaks
|
|
for (const cleanup of listenerCleanups) {
|
|
try { cleanup(); } catch { /* ignore */ }
|
|
}
|
|
listenerCleanups = [];
|
|
appRpc.request['pty.close']({ sessionId }).catch(() => {});
|
|
term?.dispose();
|
|
});
|
|
</script>
|
|
|
|
<div class="terminal-container" bind:this={termEl}></div>
|
|
|
|
<style>
|
|
.terminal-container {
|
|
width: 100%;
|
|
height: 100%;
|
|
min-height: 10rem;
|
|
}
|
|
|
|
:global(.xterm) {
|
|
padding: 0.5rem;
|
|
}
|
|
</style>
|