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}