Files
spiceflow/client/src/lib/components/TerminalView.svelte
2026-01-21 00:52:19 -05:00

619 lines
20 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy, tick, createEventDispatcher } from 'svelte';
import { api, wsClient, type StreamEvent, type TerminalDiff } from '$lib/api';
import AnsiToHtml from 'ansi-to-html';
export let sessionId: string;
export let autoScroll: boolean = true;
export let autoFocus: boolean = true;
export let lastSessionId: string | null = null;
const dispatch = createEventDispatcher<{ aliveChange: boolean; goToLastSession: void }>();
// ANSI to HTML converter for terminal colors
const ansiConverter = new AnsiToHtml({
fg: '#22c55e', // green-500 default foreground
bg: 'transparent',
newline: false,
escapeXML: true
});
// Internal state: store content as array of lines for efficient diffing
let terminalLines: string[] = [];
let isAlive = false;
let loading = true;
let error = '';
let terminalElement: HTMLPreElement;
let terminalInput: HTMLInputElement;
let refreshInterval: ReturnType<typeof setInterval> | null = null;
let unsubscribe: (() => void) | null = null;
let ctrlMode = false;
let lastHash: number | null = null;
let lastFrameId: number | null = null; // Track frame ordering to prevent out-of-order updates
let initialLoadComplete = false; // Track whether initial load has happened
let screenMode: 'fullscreen' | 'desktop' | 'landscape' | 'portrait' | null = null;
let resizing = false;
let inputBuffer = '';
let batchTimeout: ReturnType<typeof setTimeout> | null = null;
let isSending = false;
let fontScale = 1; // 50% to 150% in 5% increments
const fontScales = [0.5, 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, 0.9, 0.95, 1, 1.05, 1.1, 1.15, 1.2, 1.25, 1.3, 1.35, 1.4, 1.45, 1.5];
// Consistent button styling for quick action bar
const btnBase = 'px-2.5 py-1.5 rounded-sm text-sm font-mono transition-colors disabled:opacity-50';
const btnDefault = `${btnBase} bg-zinc-700 hover:bg-zinc-600 text-zinc-200`;
const btnRed = `${btnBase} bg-red-600/80 hover:bg-red-600 text-white`;
const btnGreen = `${btnBase} bg-green-600/80 hover:bg-green-600 text-white`;
const btnAmber = `${btnBase} bg-amber-600/80 hover:bg-amber-600 text-white`;
const btnCyan = `${btnBase} bg-cyan-600/80 hover:bg-cyan-600 text-white`;
const btnViolet = `${btnBase} bg-violet-600/80 hover:bg-violet-600 text-white`;
function zoomIn() {
const idx = fontScales.indexOf(fontScale);
if (idx < fontScales.length - 1) {
fontScale = fontScales[idx + 1];
}
}
function zoomOut() {
const idx = fontScales.indexOf(fontScale);
if (idx > 0) {
fontScale = fontScales[idx - 1];
}
}
// Big mode: desktop sizing (120x36) with 70% zoom for mobile users who want more content
export function setBigMode(enabled: boolean) {
if (enabled) {
fontScale = 0.7;
resizeScreen('desktop');
}
}
// Computed content from lines
$: terminalContent = terminalLines.join('\n');
// Convert ANSI codes to HTML for rendering
$: terminalHtml = terminalContent ? ansiConverter.toHtml(terminalContent) : '';
/**
* Check if a frame should be applied based on frame ID ordering.
* Handles wrap-around: if new ID is much smaller than current, it's likely a wrap.
* Threshold for wrap detection: if difference > MAX_SAFE_INTEGER / 2
*/
function shouldApplyFrame(newFrameId: number | undefined): boolean {
if (newFrameId === undefined || newFrameId === null) return true; // No frame ID, always apply
if (lastFrameId === null) return true; // First frame, always apply
// Handle wrap-around: if new ID is much smaller, it probably wrapped
const wrapThreshold = Number.MAX_SAFE_INTEGER / 2;
if (lastFrameId > wrapThreshold && newFrameId < wrapThreshold / 2) {
// Likely a wrap-around, accept the new frame
return true;
}
// Normal case: only apply if frame ID is newer (greater)
return newFrameId > lastFrameId;
}
/**
* Apply a terminal diff to the current lines array.
* Handles full refresh, partial updates, and unchanged states.
* Checks frame ID to prevent out-of-order updates.
*/
function applyDiff(diff: TerminalDiff): boolean {
if (!diff) return false;
const frameId = diff['frame-id'] ?? diff.frameId;
const totalLines = diff['total-lines'] ?? diff.totalLines ?? 0;
// Check frame ordering (skip out-of-order frames)
if (!shouldApplyFrame(frameId)) {
console.debug('[Terminal] Skipping out-of-order frame:', frameId, 'last:', lastFrameId);
return false;
}
switch (diff.type) {
case 'unchanged':
// Content hasn't changed
lastHash = diff.hash ?? null;
if (frameId !== undefined) lastFrameId = frameId;
return false;
case 'full':
// Full refresh - replace all lines
terminalLines = diff.lines ?? [];
lastHash = diff.hash ?? null;
if (frameId !== undefined) lastFrameId = frameId;
return true;
case 'diff':
// Partial update - apply changes to specific lines
if (diff.changes) {
// Create a working copy to avoid in-place mutation issues
let newLines = [...terminalLines];
// Ensure array is long enough
while (newLines.length < totalLines) {
newLines.push('');
}
// Truncate if needed
if (newLines.length > totalLines) {
newLines = newLines.slice(0, totalLines);
}
// Apply changes
for (const [lineNumStr, content] of Object.entries(diff.changes)) {
const lineNum = parseInt(lineNumStr, 10);
if (lineNum >= 0 && lineNum < totalLines) {
newLines[lineNum] = content ?? '';
}
}
// Assign new array reference to trigger reactivity
terminalLines = newLines;
}
lastHash = diff.hash ?? null;
if (frameId !== undefined) lastFrameId = frameId;
return true;
default:
return false;
}
}
async function fetchTerminalContent(fresh: boolean = false) {
try {
const result = await api.getTerminalContent(sessionId, fresh);
let changed = false;
// On initial load, always use full content directly for reliability
// Use diffs only for incremental updates after initial load
if (!initialLoadComplete) {
// First load: use raw content, ignore diff
const newLines = result.content ? result.content.split('\n') : [];
terminalLines = newLines;
lastHash = result.diff?.hash ?? null;
// Initialize frame ID tracking from first response
lastFrameId = result.diff?.['frame-id'] ?? result.diff?.frameId ?? null;
changed = newLines.length > 0;
initialLoadComplete = true;
// Set screen mode from server-detected layout
if (result.layout) {
screenMode = result.layout;
}
} else if (result.diff) {
// Subsequent loads: apply diff for efficiency
changed = applyDiff(result.diff);
} else if (result.content !== undefined) {
// Fallback: full content replacement
const newLines = result.content ? result.content.split('\n') : [];
if (newLines.join('\n') !== terminalLines.join('\n')) {
terminalLines = newLines;
changed = true;
}
}
if (isAlive !== result.alive) {
isAlive = result.alive;
dispatch('aliveChange', isAlive);
}
error = '';
if (changed) {
await tick();
scrollToBottom();
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch terminal content';
} finally {
loading = false;
}
}
function scrollToBottom(force = false) {
if ((force || autoScroll) && terminalElement) {
terminalElement.scrollTop = terminalElement.scrollHeight;
}
}
async function pasteFromClipboard() {
try {
const text = await navigator.clipboard.readText();
if (text) {
await sendInput(text);
}
} catch (e) {
console.error('Failed to read clipboard:', e);
}
}
function copySelection() {
const selection = window.getSelection();
const text = selection?.toString();
if (text) {
navigator.clipboard.writeText(text).catch(e => {
console.error('Failed to copy:', e);
});
}
}
async function handleKeydown(event: KeyboardEvent) {
// Ctrl+Down scrolls to bottom (don't send to tmux)
if (event.ctrlKey && event.key === 'ArrowDown') {
event.preventDefault();
scrollToBottom(true);
return;
}
// Ctrl+Shift+V pastes from clipboard
if (event.ctrlKey && event.shiftKey && event.key === 'V') {
event.preventDefault();
await pasteFromClipboard();
return;
}
if (!isAlive) return;
// Prevent default for all keys we handle
event.preventDefault();
if (event.key === 'Enter' && event.shiftKey) {
// Shift+Enter sends literal newline
await sendInput('\n');
} else if (event.key === 'Enter') {
// Enter sends Enter key
await sendInput('\r');
} else if (event.key === 'Backspace') {
await sendInput('\x7f');
} else if (event.key === 'Tab' && event.shiftKey) {
// Shift+Tab escape sequence
await sendInput('\x1b[Z');
} else if (event.key === 'Tab') {
await sendInput('\t');
} else if (event.key === 'Escape') {
await sendInput('\x1b');
} else if (event.key === 'ArrowUp') {
await sendInput('\x1b[A');
} else if (event.key === 'ArrowDown') {
await sendInput('\x1b[B');
} else if (event.key === 'ArrowRight') {
await sendInput('\x1b[C');
} else if (event.key === 'ArrowLeft') {
await sendInput('\x1b[D');
} else if (event.ctrlKey && event.key.length === 1) {
// Ctrl+letter = control character
const code = event.key.toLowerCase().charCodeAt(0) - 96;
if (code > 0 && code < 27) {
await sendInput(String.fromCharCode(code));
}
} else if (ctrlMode && event.key.length === 1 && /[a-z]/i.test(event.key)) {
// Ctrl mode toggle + letter = control character
const code = event.key.toLowerCase().charCodeAt(0) - 96;
if (code > 0 && code < 27) {
await sendInput(String.fromCharCode(code));
ctrlMode = false;
}
} else if (event.key.length === 1) {
// Regular printable character
await sendInput(event.key);
}
}
async function sendInput(input: string) {
// Scroll to bottom when sending input (force regardless of autoScroll)
scrollToBottom(true);
// Buffer the input for batching
inputBuffer += input;
// If already scheduled to send, let it pick up the new input
if (batchTimeout) return;
// Schedule a batch send after a short delay to collect rapid keystrokes
batchTimeout = setTimeout(flushInputBuffer, 30);
}
async function flushInputBuffer() {
batchTimeout = null;
// Skip if nothing to send or already sending
if (!inputBuffer || isSending) return;
const toSend = inputBuffer;
inputBuffer = '';
isSending = true;
try {
await api.sendTerminalInput(sessionId, toSend);
// Fetch update shortly after
setTimeout(() => fetchTerminalContent(false), 100);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to send input';
} finally {
isSending = false;
// If more input accumulated while sending, flush it
if (inputBuffer) {
batchTimeout = setTimeout(flushInputBuffer, 30);
}
}
}
async function sendCtrlC() {
await sendInput('\x03');
}
async function sendKey(key: string) {
await sendInput(key);
}
async function resizeScreen(mode: 'fullscreen' | 'desktop' | 'landscape' | 'portrait') {
if (resizing) return;
resizing = true;
try {
await api.resizeTerminal(sessionId, mode);
screenMode = mode;
// Invalidate terminal cache and fetch fresh content after resize
setTimeout(() => fetchTerminalContent(true), 150);
} catch (e) {
console.error('Resize failed:', e);
error = e instanceof Error ? e.message : 'Failed to resize terminal';
} finally {
resizing = false;
}
}
function handleWebSocketEvent(event: StreamEvent) {
if (event.event === 'terminal-update') {
// Apply diff if provided (includes frame ID ordering check)
if (event.diff) {
const changed = applyDiff(event.diff);
if (changed) {
tick().then(() => scrollToBottom());
}
} else if (event.content !== undefined) {
// Fallback: full content replacement (no frame ID available)
const newContent = event.content as string;
const newLines = newContent ? newContent.split('\n') : [];
if (newLines.join('\n') !== terminalLines.join('\n')) {
terminalLines = newLines;
tick().then(() => scrollToBottom());
}
}
}
}
function handleOrientationChange() {
// Only auto-switch on mobile (screen width < 640px or height < 450px)
const isMobile = window.innerWidth < 640 || window.innerHeight < 450;
if (!isMobile) return;
const orientation = screen.orientation?.type || '';
if (orientation.includes('portrait')) {
resizeScreen('portrait');
} else if (orientation.includes('landscape')) {
resizeScreen('landscape');
}
}
onMount(async () => {
// Initial fetch with fresh=true to ensure we get full current content
await fetchTerminalContent(true);
// Subscribe to WebSocket updates
await wsClient.connect();
unsubscribe = wsClient.subscribe(sessionId, handleWebSocketEvent);
// Periodic refresh every 1 second (no fresh flag for incremental diffs)
refreshInterval = setInterval(() => fetchTerminalContent(false), 1000);
// Auto-focus input after content loads
if (autoFocus) {
setTimeout(() => terminalInput?.focus(), 100);
}
// Listen for orientation changes on mobile
if (screen.orientation) {
screen.orientation.addEventListener('change', handleOrientationChange);
}
});
onDestroy(() => {
if (refreshInterval) {
clearInterval(refreshInterval);
}
if (unsubscribe) {
unsubscribe();
}
if (batchTimeout) {
clearTimeout(batchTimeout);
}
if (screen.orientation) {
screen.orientation.removeEventListener('change', handleOrientationChange);
}
});
</script>
<div class="flex flex-col flex-1 min-h-0 bg-black">
{#if loading}
<div class="flex-1 flex items-center justify-center">
<svg class="animate-spin h-8 w-8 text-cyan-500" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</div>
{:else if error}
<div class="flex-1 flex items-center justify-center p-4">
<div class="text-red-400">{error}</div>
</div>
{:else}
<!-- Terminal output area -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<pre
bind:this={terminalElement}
on:mouseup={copySelection}
on:click={() => terminalInput?.focus()}
class="flex-1 min-h-0 overflow-auto p-3 font-mono text-green-400 whitespace-pre-wrap break-words leading-relaxed terminal-content cursor-text"
style="font-size: {fontScale * 0.875}rem;"
>{@html terminalHtml}</pre>
<!-- Quick action buttons -->
<div class="flex-shrink-0 px-2 py-1.5 bg-zinc-900 border-t border-zinc-800 flex items-center gap-1.5 overflow-x-auto">
<button
on:click={() => ctrlMode = !ctrlMode}
disabled={!isAlive}
class="portrait-hide {btnBase} bg-zinc-700 hover:bg-zinc-600 text-zinc-200 {ctrlMode ? 'font-bold ring-1 ring-cyan-400 rounded' : 'rounded-sm'}"
>^</button>
<button
on:click={sendCtrlC}
disabled={!isAlive}
class={btnRed}
>^C</button>
<button
on:click={() => sendInput('\x04')}
disabled={!isAlive}
class={btnAmber}
>^D</button>
<span class="w-px h-6 bg-zinc-700 portrait-hide"></span>
<button
on:click={() => sendKey('y')}
disabled={!isAlive}
class="portrait-hide {btnGreen}"
>y</button>
<button
on:click={() => sendKey('n')}
disabled={!isAlive}
class="portrait-hide {btnRed}"
>n</button>
<span class="w-px h-6 bg-zinc-700 portrait-hide"></span>
{#each ['1', '2', '3', '4'] as num}
<button
on:click={() => sendKey(num)}
disabled={!isAlive}
class="portrait-hide {btnDefault}"
>{num}</button>
{/each}
<span class="w-px h-6 bg-zinc-700"></span>
<button
on:click={() => sendInput('\t')}
disabled={!isAlive}
class={btnCyan}
>⇥</button>
<button
on:click={() => sendInput('\x1b[Z')}
disabled={!isAlive}
class={btnCyan}
>⇤</button>
<button
on:click={() => sendInput('\r')}
disabled={!isAlive}
class={btnGreen}
>↵</button>
<span class="w-px h-6 bg-zinc-700 portrait-hide"></span>
<button
on:click={pasteFromClipboard}
disabled={!isAlive}
class="portrait-hide {btnViolet}"
title="Paste (Ctrl+Shift+V)"
>📋</button>
<!-- Screen size selector (pushed right on wider screens) -->
<span class="w-px h-6 bg-zinc-700 ml-auto"></span>
<!-- Text zoom (hidden on mobile portrait, visible on landscape/desktop) -->
<div class="hidden landscape:flex sm:flex items-center gap-1.5">
<button
on:click={zoomOut}
disabled={fontScale <= fontScales[0]}
class={btnDefault}
title="Zoom out"
>-</button>
<button
on:click={() => fontScale = 1}
class="text-xs text-zinc-400 hover:text-zinc-200 font-mono w-8 text-center transition-colors"
title="Reset to 100%"
>{Math.round(fontScale * 100)}%</button>
<button
on:click={zoomIn}
disabled={fontScale >= fontScales[fontScales.length - 1]}
class={btnDefault}
title="Zoom in"
>+</button>
<span class="w-px h-6 bg-zinc-700"></span>
</div>
<button
on:click={() => resizeScreen('portrait')}
disabled={resizing}
class="mobile-only {btnBase} {screenMode === 'portrait' ? 'bg-cyan-600 text-white' : 'bg-zinc-700 hover:bg-zinc-600 text-zinc-200'}"
title="Portrait (42x24)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-3 inline-block" fill="none" viewBox="0 0 10 16" stroke="currentColor" stroke-width="1.5">
<rect x="1" y="1" width="8" height="14" rx="1" />
</svg>
</button>
<button
on:click={() => resizeScreen('landscape')}
disabled={resizing}
class="mobile-only {btnBase} {screenMode === 'landscape' ? 'bg-cyan-600 text-white' : 'bg-zinc-700 hover:bg-zinc-600 text-zinc-200'}"
title="Landscape (86x24)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-5 inline-block" fill="none" viewBox="0 0 16 10" stroke="currentColor" stroke-width="1.5">
<rect x="1" y="1" width="14" height="8" rx="1" />
</svg>
</button>
<button
on:click={() => resizeScreen('desktop')}
disabled={resizing}
class="desktop-only {btnBase} {screenMode === 'desktop' ? 'bg-cyan-600 text-white' : 'bg-zinc-700 hover:bg-zinc-600 text-zinc-200'}"
title="Split screen (120x36)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-5 inline-block" fill="none" viewBox="0 0 20 14" stroke="currentColor" stroke-width="1.5">
<rect x="1" y="1" width="8" height="12" rx="1" />
<rect x="11" y="1" width="8" height="12" rx="1" />
</svg>
</button>
<button
on:click={() => resizeScreen('fullscreen')}
disabled={resizing}
class="desktop-only {btnBase} {screenMode === 'fullscreen' ? 'bg-cyan-600 text-white' : 'bg-zinc-700 hover:bg-zinc-600 text-zinc-200'}"
title="Fullscreen (260x36)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-6 inline-block" fill="none" viewBox="0 0 22 14" stroke="currentColor" stroke-width="1.5">
<rect x="1" y="1" width="20" height="12" rx="1" />
</svg>
</button>
<span class="w-px h-6 bg-zinc-700"></span>
<button
on:click={() => scrollToBottom(true)}
class={btnDefault}
aria-label="Scroll to bottom"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
</button>
{#if lastSessionId}
<span class="w-px h-6 bg-zinc-700"></span>
<button
on:click={() => dispatch('goToLastSession')}
class="{btnBase} bg-spice-600/80 hover:bg-spice-600 text-white"
aria-label="Go to last session"
title="Go to last session"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
</svg>
</button>
{/if}
</div>
<!-- Hidden input for keyboard capture (invisible but functional for mobile) -->
<input
bind:this={terminalInput}
type="text"
on:keydown={handleKeydown}
on:focus={() => setTimeout(() => scrollToBottom(true), 59)}
class="sr-only"
disabled={!isAlive}
aria-label="Terminal input"
/>
{/if}
</div>