TerminalPanel.svelte

  1<script lang="ts">
  2	import {
  3		terminalOpen,
  4		terminalHeight,
  5		sendReviewToClaude,
  6		stopClaudeSession,
  7		restartSession,
  8		hasPendingReview
  9	} from '$lib/stores/terminal';
 10	import { theme } from '$lib/stores/ui';
 11	import { onDestroy } from 'svelte';
 12
 13	let isResizing = $state(false);
 14	let minimized = $state(false);
 15	let connected = $state(false);
 16	let initError = $state<string | null>(null);
 17
 18	let term: any = null;
 19	let fitAddon: any = null;
 20	let ws: WebSocket | null = null;
 21	let resizeObserver: ResizeObserver | null = null;
 22
 23	const terminalThemes: Record<string, Record<string, string>> = {
 24		dark: {
 25			background: '#1e1e1e',
 26			foreground: '#d4d4d4',
 27			cursor: '#d4d4d4',
 28			selectionBackground: '#264f78',
 29			black: '#1e1e1e',
 30			red: '#f44747',
 31			green: '#6a9955',
 32			yellow: '#d7ba7d',
 33			blue: '#569cd6',
 34			magenta: '#c586c0',
 35			cyan: '#4ec9b0',
 36			white: '#d4d4d4',
 37			brightBlack: '#808080',
 38			brightRed: '#f44747',
 39			brightGreen: '#6a9955',
 40			brightYellow: '#d7ba7d',
 41			brightBlue: '#569cd6',
 42			brightMagenta: '#c586c0',
 43			brightCyan: '#4ec9b0',
 44			brightWhite: '#ffffff'
 45		},
 46		light: {
 47			background: '#f8f8f8',
 48			foreground: '#383a42',
 49			cursor: '#383a42',
 50			selectionBackground: '#add6ff',
 51			black: '#383a42',
 52			red: '#e45649',
 53			green: '#50a14f',
 54			yellow: '#c18401',
 55			blue: '#4078f2',
 56			magenta: '#a626a4',
 57			cyan: '#0184bc',
 58			white: '#fafafa',
 59			brightBlack: '#a0a1a7',
 60			brightRed: '#e45649',
 61			brightGreen: '#50a14f',
 62			brightYellow: '#c18401',
 63			brightBlue: '#4078f2',
 64			brightMagenta: '#a626a4',
 65			brightCyan: '#0184bc',
 66			brightWhite: '#ffffff'
 67		}
 68	};
 69
 70	// React to theme changes
 71	$effect(() => {
 72		if (term) {
 73			term.options.theme = terminalThemes[$theme];
 74		}
 75	});
 76
 77	onDestroy(() => {
 78		resizeObserver?.disconnect();
 79		ws?.close();
 80		ws = null;
 81		term?.dispose();
 82		term = null;
 83	});
 84
 85	// Svelte action — runs when the DOM element is actually mounted
 86	function terminalAction(node: HTMLDivElement) {
 87		initTerminal(node);
 88		return {
 89			destroy() {
 90				resizeObserver?.disconnect();
 91				resizeObserver = null;
 92			}
 93		};
 94	}
 95
 96	async function initTerminal(node: HTMLDivElement) {
 97		try {
 98			const [{ Terminal }, { FitAddon }] = await Promise.all([
 99				import('@xterm/xterm'),
100				import('@xterm/addon-fit')
101			]);
102			await import('@xterm/xterm/css/xterm.css');
103
104			term = new Terminal({
105				cursorBlink: true,
106				fontSize: 13,
107				fontFamily: 'Menlo, Monaco, "Courier New", monospace',
108				theme: terminalThemes[$theme],
109				allowProposedApi: true
110			});
111
112			fitAddon = new FitAddon();
113			term.loadAddon(fitAddon);
114			term.open(node);
115
116			requestAnimationFrame(() => fitAddon?.fit());
117
118			connectWs();
119
120			term.onData((data: string) => {
121				if (ws?.readyState === WebSocket.OPEN) {
122					ws.send(JSON.stringify({ type: 'input', data }));
123				}
124			});
125
126			resizeObserver = new ResizeObserver(() => {
127				if (fitAddon && !minimized) {
128					fitAddon.fit();
129					if (ws?.readyState === WebSocket.OPEN && term) {
130						ws.send(JSON.stringify({
131							type: 'resize',
132							cols: term.cols,
133							rows: term.rows
134						}));
135					}
136				}
137			});
138			resizeObserver.observe(node);
139		} catch (err) {
140			initError = String(err);
141			console.error('Terminal init failed:', err);
142		}
143	}
144
145	function getWsUrl(): string {
146		const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
147		if (import.meta.env.DEV) {
148			// If VITE_LCR_WS_PORT is set, use it. Otherwise derive from page port + 1000.
149			const wsPort = import.meta.env.VITE_LCR_WS_PORT || String(parseInt(window.location.port, 10) + 1000);
150			return `ws://localhost:${wsPort}/ws/terminal`;
151		}
152		return `${protocol}//${window.location.host}/ws/terminal`;
153	}
154
155	function connectWs() {
156		ws = new WebSocket(getWsUrl());
157
158		ws.onopen = () => {
159			connected = true;
160			// Send resize info
161			if (term) {
162				ws!.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
163			}
164			// Check for pending review from ExportDialog
165			if (hasPendingReview()) {
166				ws!.send(JSON.stringify({ type: 'review' }));
167			}
168		};
169
170		ws.onmessage = (event) => {
171			try {
172				const msg = JSON.parse(event.data);
173				if (msg.type === 'output' && term) {
174					term.write(msg.data);
175				} else if (msg.type === 'exit') {
176					term?.write('\r\n\x1b[33mSession ended.\x1b[0m\r\n');
177					connected = false;
178				} else if (msg.type === 'request-resize' && term) {
179					// Server needs dimensions to start a new session
180					ws!.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
181				}
182			} catch {
183				term?.write(event.data);
184			}
185		};
186
187		ws.onclose = () => {
188			connected = false;
189			setTimeout(() => {
190				if ($terminalOpen && (!ws || ws.readyState === WebSocket.CLOSED)) {
191					connectWs();
192				}
193			}, 2000);
194		};
195
196		ws.onerror = () => {
197			connected = false;
198		};
199
200		(globalThis as any).__lcr_ws = ws;
201	}
202
203	function handleResizeKey(e: KeyboardEvent) {
204		if (e.key === 'ArrowUp') {
205			e.preventDefault();
206			$terminalHeight = Math.min(window.innerHeight * 0.8, $terminalHeight + 16);
207			requestAnimationFrame(() => fitAddon?.fit());
208		} else if (e.key === 'ArrowDown') {
209			e.preventDefault();
210			$terminalHeight = Math.max(100, $terminalHeight - 16);
211			requestAnimationFrame(() => fitAddon?.fit());
212		}
213	}
214
215	function startResize(e: MouseEvent) {
216		e.preventDefault();
217		isResizing = true;
218		const startY = e.clientY;
219		const startHeight = $terminalHeight;
220
221		function onMove(e: MouseEvent) {
222			const delta = startY - e.clientY;
223			const newHeight = Math.max(100, Math.min(window.innerHeight * 0.8, startHeight + delta));
224			$terminalHeight = newHeight;
225		}
226
227		function onUp() {
228			isResizing = false;
229			window.removeEventListener('mousemove', onMove);
230			window.removeEventListener('mouseup', onUp);
231			requestAnimationFrame(() => fitAddon?.fit());
232		}
233
234		window.addEventListener('mousemove', onMove);
235		window.addEventListener('mouseup', onUp);
236	}
237</script>
238
239{#if $terminalOpen}
240	<div
241		class="flex flex-col border-t border-border bg-terminal"
242		style="height: {minimized ? 'auto' : `${$terminalHeight}px`}"
243	>
244		{#if !minimized}
245			<!-- svelte-ignore a11y_no_noninteractive_element_interactions, a11y_no_noninteractive_tabindex (focusable resize separator) -->
246			<div
247				class="h-1 cursor-row-resize hover:bg-blue-500/50 transition-colors {isResizing ? 'bg-blue-500/50' : ''}"
248				onmousedown={startResize}
249				onkeydown={handleResizeKey}
250				role="separator"
251				aria-orientation="horizontal"
252				aria-label="Resize terminal panel"
253				tabindex="0"
254			></div>
255		{/if}
256
257		<div class="flex items-center gap-2 px-3 py-1.5 bg-surface border-b border-border/50 select-none shrink-0">
258			<div class="flex items-center gap-2">
259				<span class="text-xs font-medium text-secondary">Claude Terminal</span>
260				<span class="flex items-center gap-1">
261					<span class="w-2 h-2 rounded-full {connected ? 'bg-green-400' : 'bg-muted'}"></span>
262					<span class="text-[10px] text-muted">{connected ? 'Connected' : 'Disconnected'}</span>
263				</span>
264			</div>
265
266			<div class="flex-1"></div>
267
268			<div class="flex items-center gap-1">
269				<button
270					onclick={sendReviewToClaude}
271					class="text-[10px] px-2 py-0.5 rounded text-accent-blue hover:bg-accent-blue/10 transition-colors"
272					title="Send current review comments to Claude"
273				>
274					Send Review
275				</button>
276
277				<button
278					onclick={stopClaudeSession}
279					class="text-[10px] px-2 py-0.5 rounded text-accent-red hover:bg-accent-red/10 transition-colors"
280				>
281					Stop
282				</button>
283
284				<button
285					onclick={restartSession}
286					class="text-[10px] px-2 py-0.5 rounded text-tertiary hover:bg-hover transition-colors"
287				>
288					Restart
289				</button>
290
291				<button
292					onclick={() => term?.clear()}
293					class="text-[10px] px-2 py-0.5 rounded text-tertiary hover:bg-hover transition-colors"
294				>
295					Clear
296				</button>
297
298				<button
299					onclick={() => minimized = !minimized}
300					class="text-[10px] px-2 py-0.5 rounded text-tertiary hover:bg-hover transition-colors"
301				>
302					{minimized ? 'Expand' : 'Minimize'}
303				</button>
304
305				<button
306					onclick={() => $terminalOpen = false}
307					class="text-[10px] px-2 py-0.5 rounded text-tertiary hover:bg-hover transition-colors"
308				>
309					Close
310				</button>
311			</div>
312		</div>
313
314		{#if !minimized}
315			<div
316				use:terminalAction
317				class="flex-1 overflow-hidden"
318			></div>
319			{#if initError}
320				<div class="p-3 text-accent-red text-xs">{initError}</div>
321			{/if}
322		{/if}
323	</div>
324{/if}