feat(v2): add xterm.js terminal pane with Canvas addon

- Install @xterm/xterm, @xterm/addon-canvas, @xterm/addon-fit, @tauri-apps/api
- TerminalPane: xterm.js with Catppuccin Mocha theme, auto-fit, resize observer
- PTY bridge: Tauri invoke wrappers + event listeners for data/exit
- Bidirectional streaming: keyboard input -> PTY write, PTY output -> xterm write
- 100ms debounced resize propagation to PTY backend
This commit is contained in:
Hibryda 2026-03-05 23:42:31 +01:00
parent f15e60be60
commit bb0e9283fc
4 changed files with 207 additions and 19 deletions

View file

@ -1,30 +1,38 @@
// PTY Bridge — IPC wrapper for Rust PTY backend
// Phase 2: terminal spawn, resize, input/output streaming
import { invoke } from '@tauri-apps/api/core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
export interface PtyOptions {
shell?: string;
cwd?: string;
env?: Record<string, string>;
args?: string[];
cols?: number;
rows?: number;
}
/**
* Spawn a new PTY session via Tauri IPC.
* Phase 2: implement with @tauri-apps/api invoke
*/
export async function spawnPty(_options: PtyOptions): Promise<string> {
throw new Error('Not implemented — Phase 2');
export async function spawnPty(options: PtyOptions): Promise<string> {
return invoke<string>('pty_spawn', { options });
}
export async function writePty(_id: string, _data: string): Promise<void> {
throw new Error('Not implemented — Phase 2');
export async function writePty(id: string, data: string): Promise<void> {
return invoke('pty_write', { id, data });
}
export async function resizePty(_id: string, _cols: number, _rows: number): Promise<void> {
throw new Error('Not implemented — Phase 2');
export async function resizePty(id: string, cols: number, rows: number): Promise<void> {
return invoke('pty_resize', { id, cols, rows });
}
export async function killPty(_id: string): Promise<void> {
throw new Error('Not implemented — Phase 2');
export async function killPty(id: string): Promise<void> {
return invoke('pty_kill', { id });
}
export async function onPtyData(id: string, callback: (data: string) => void): Promise<UnlistenFn> {
return listen<string>(`pty-data-${id}`, (event) => {
callback(event.payload);
});
}
export async function onPtyExit(id: string, callback: () => void): Promise<UnlistenFn> {
return listen(`pty-exit-${id}`, () => {
callback();
});
}

View file

@ -0,0 +1,134 @@
<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 { spawnPty, writePty, resizePty, killPty, onPtyData, onPtyExit } from '../../adapters/pty-bridge';
import type { UnlistenFn } from '@tauri-apps/api/event';
import '@xterm/xterm/css/xterm.css';
interface Props {
shell?: string;
cwd?: string;
args?: string[];
onExit?: () => void;
}
let { shell, cwd, args, onExit }: Props = $props();
let terminalEl: HTMLDivElement;
let term: Terminal;
let fitAddon: FitAddon;
let ptyId: string | null = null;
let unlistenData: UnlistenFn | null = null;
let unlistenExit: UnlistenFn | null = null;
let resizeObserver: ResizeObserver | null = null;
// Catppuccin Mocha xterm theme
const catppuccinTheme = {
background: '#1e1e2e',
foreground: '#cdd6f4',
cursor: '#f5e0dc',
cursorAccent: '#1e1e2e',
selectionBackground: '#45475a',
selectionForeground: '#cdd6f4',
black: '#45475a',
red: '#f38ba8',
green: '#a6e3a1',
yellow: '#f9e2af',
blue: '#89b4fa',
magenta: '#f5c2e7',
cyan: '#94e2d5',
white: '#bac2de',
brightBlack: '#585b70',
brightRed: '#f38ba8',
brightGreen: '#a6e3a1',
brightYellow: '#f9e2af',
brightBlue: '#89b4fa',
brightMagenta: '#f5c2e7',
brightCyan: '#94e2d5',
brightWhite: '#a6adc8',
};
onMount(async () => {
term = new Terminal({
theme: catppuccinTheme,
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace",
fontSize: 13,
lineHeight: 1.2,
cursorBlink: true,
cursorStyle: 'block',
scrollback: 10000,
allowProposedApi: true,
});
fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.loadAddon(new CanvasAddon());
term.open(terminalEl);
fitAddon.fit();
const { cols, rows } = term;
// Spawn PTY
try {
ptyId = await spawnPty({ shell, cwd, args, cols, rows });
// Listen for PTY output
unlistenData = await onPtyData(ptyId, (data) => {
term.write(data);
});
unlistenExit = await onPtyExit(ptyId, () => {
term.write('\r\n\x1b[90m[Process exited]\x1b[0m\r\n');
onExit?.();
});
// Forward keyboard input to PTY
term.onData((data) => {
if (ptyId) writePty(ptyId, data);
});
} catch (e) {
term.write(`\x1b[31mFailed to spawn terminal: ${e}\x1b[0m\r\n`);
}
// Resize handling with debounce
let resizeTimer: ReturnType<typeof setTimeout>;
resizeObserver = new ResizeObserver(() => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
fitAddon.fit();
if (ptyId) {
const { cols, rows } = term;
resizePty(ptyId, cols, rows);
}
}, 100);
});
resizeObserver.observe(terminalEl);
});
onDestroy(async () => {
resizeObserver?.disconnect();
unlistenData?.();
unlistenExit?.();
if (ptyId) {
try { await killPty(ptyId); } catch { /* already dead */ }
}
term?.dispose();
});
</script>
<div class="terminal-container" bind:this={terminalEl}></div>
<style>
.terminal-container {
width: 100%;
height: 100%;
overflow: hidden;
}
.terminal-container :global(.xterm) {
height: 100%;
padding: 4px;
}
</style>