619 lines
20 KiB
Svelte
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>
|