|
|
|
@@ -1,6 +1,7 @@
|
|
|
|
|
<script lang="ts">
|
|
|
|
|
import { onMount, onDestroy, tick, createEventDispatcher } from 'svelte';
|
|
|
|
|
import { api, wsClient, type StreamEvent } from '$lib/api';
|
|
|
|
|
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;
|
|
|
|
@@ -8,8 +9,16 @@
|
|
|
|
|
|
|
|
|
|
const dispatch = createEventDispatcher<{ aliveChange: boolean }>();
|
|
|
|
|
|
|
|
|
|
let terminalContent = '';
|
|
|
|
|
let prevContentLength = 0;
|
|
|
|
|
// 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 = '';
|
|
|
|
@@ -18,19 +27,158 @@
|
|
|
|
|
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: 'desktop' | 'landscape' | 'portrait' = 'landscape';
|
|
|
|
|
let resizing = false;
|
|
|
|
|
let inputBuffer = '';
|
|
|
|
|
let batchTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
|
let isSending = false;
|
|
|
|
|
let fontScale = 1; // 0.75, 0.875, 1, 1.125, 1.25, 1.5
|
|
|
|
|
const fontScales = [0.75, 0.875, 1, 1.125, 1.25, 1.5];
|
|
|
|
|
|
|
|
|
|
async function fetchTerminalContent() {
|
|
|
|
|
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];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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) {
|
|
|
|
|
// Ensure array is long enough
|
|
|
|
|
while (terminalLines.length < totalLines) {
|
|
|
|
|
terminalLines.push('');
|
|
|
|
|
}
|
|
|
|
|
// Truncate if needed
|
|
|
|
|
if (terminalLines.length > totalLines) {
|
|
|
|
|
terminalLines = terminalLines.slice(0, totalLines);
|
|
|
|
|
}
|
|
|
|
|
// Apply changes
|
|
|
|
|
for (const [lineNumStr, content] of Object.entries(diff.changes)) {
|
|
|
|
|
const lineNum = parseInt(lineNumStr, 10);
|
|
|
|
|
if (lineNum >= 0 && lineNum < totalLines) {
|
|
|
|
|
terminalLines[lineNum] = content ?? '';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Trigger reactivity
|
|
|
|
|
terminalLines = terminalLines;
|
|
|
|
|
}
|
|
|
|
|
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);
|
|
|
|
|
const contentGrew = result.content.length > prevContentLength;
|
|
|
|
|
prevContentLength = result.content.length;
|
|
|
|
|
terminalContent = result.content;
|
|
|
|
|
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 (contentGrew) {
|
|
|
|
|
|
|
|
|
|
if (changed) {
|
|
|
|
|
await tick();
|
|
|
|
|
scrollToBottom();
|
|
|
|
|
}
|
|
|
|
@@ -96,11 +244,38 @@
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function sendInput(input: string) {
|
|
|
|
|
// 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, input);
|
|
|
|
|
setTimeout(fetchTerminalContent, 200);
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -112,28 +287,52 @@
|
|
|
|
|
await sendInput(key);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function resizeScreen(mode: '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' && event.content !== undefined) {
|
|
|
|
|
const newContent = event.content as string;
|
|
|
|
|
const contentGrew = newContent.length > prevContentLength;
|
|
|
|
|
prevContentLength = newContent.length;
|
|
|
|
|
terminalContent = newContent;
|
|
|
|
|
if (contentGrew) {
|
|
|
|
|
tick().then(scrollToBottom);
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onMount(async () => {
|
|
|
|
|
// Initial fetch
|
|
|
|
|
await fetchTerminalContent();
|
|
|
|
|
// 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
|
|
|
|
|
refreshInterval = setInterval(fetchTerminalContent, 1000);
|
|
|
|
|
// Periodic refresh every 1 second (no fresh flag for incremental diffs)
|
|
|
|
|
refreshInterval = setInterval(() => fetchTerminalContent(false), 1000);
|
|
|
|
|
|
|
|
|
|
// Auto-focus input after content loads
|
|
|
|
|
if (autoFocus) {
|
|
|
|
@@ -148,6 +347,9 @@
|
|
|
|
|
if (unsubscribe) {
|
|
|
|
|
unsubscribe();
|
|
|
|
|
}
|
|
|
|
|
if (batchTimeout) {
|
|
|
|
|
clearTimeout(batchTimeout);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
@@ -165,98 +367,130 @@
|
|
|
|
|
</div>
|
|
|
|
|
{:else}
|
|
|
|
|
<!-- Terminal output area -->
|
|
|
|
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
|
|
|
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
|
|
|
|
<pre
|
|
|
|
|
bind:this={terminalElement}
|
|
|
|
|
class="flex-1 min-h-0 overflow-auto p-3 font-mono text-sm text-green-400 whitespace-pre-wrap break-words leading-relaxed"
|
|
|
|
|
>{terminalContent || 'Terminal ready. Type a command below.'}</pre>
|
|
|
|
|
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">
|
|
|
|
|
<div class="flex-shrink-0 px-1 py-0.5 bg-zinc-900 border-t border-zinc-800 flex items-center gap-0.5 overflow-x-auto">
|
|
|
|
|
<button
|
|
|
|
|
on:click={() => ctrlMode = !ctrlMode}
|
|
|
|
|
disabled={!isAlive}
|
|
|
|
|
class="px-2.5 py-1 bg-zinc-700 hover:bg-zinc-600 disabled:opacity-50 text-xs font-mono text-zinc-200 transition-colors {ctrlMode ? 'font-bold ring-2 ring-cyan-400 rounded-lg' : 'rounded'}"
|
|
|
|
|
>
|
|
|
|
|
Ctrl
|
|
|
|
|
</button>
|
|
|
|
|
class="px-1.5 py-0.5 bg-zinc-700 hover:bg-zinc-600 disabled:opacity-50 text-[10px] font-mono text-zinc-200 transition-colors {ctrlMode ? 'font-bold ring-1 ring-cyan-400 rounded' : 'rounded-sm'}"
|
|
|
|
|
>^</button>
|
|
|
|
|
<button
|
|
|
|
|
on:click={sendCtrlC}
|
|
|
|
|
disabled={!isAlive}
|
|
|
|
|
class="px-2.5 py-1 bg-red-600/80 hover:bg-red-600 disabled:opacity-50 rounded text-xs font-mono font-medium text-white transition-colors"
|
|
|
|
|
>
|
|
|
|
|
Ctrl+C
|
|
|
|
|
</button>
|
|
|
|
|
class="px-1.5 py-0.5 bg-red-600/80 hover:bg-red-600 disabled:opacity-50 rounded-sm text-[10px] font-mono text-white transition-colors"
|
|
|
|
|
>^C</button>
|
|
|
|
|
<button
|
|
|
|
|
on:click={() => sendInput('\x04')}
|
|
|
|
|
disabled={!isAlive}
|
|
|
|
|
class="px-2.5 py-1 bg-amber-600/80 hover:bg-amber-600 disabled:opacity-50 rounded text-xs font-mono font-medium text-white transition-colors"
|
|
|
|
|
>
|
|
|
|
|
Ctrl+D
|
|
|
|
|
</button>
|
|
|
|
|
<span class="w-px h-4 bg-zinc-700"></span>
|
|
|
|
|
class="px-1.5 py-0.5 bg-amber-600/80 hover:bg-amber-600 disabled:opacity-50 rounded-sm text-[10px] font-mono text-white transition-colors"
|
|
|
|
|
>^D</button>
|
|
|
|
|
<span class="w-px h-3 bg-zinc-700"></span>
|
|
|
|
|
<button
|
|
|
|
|
on:click={() => sendKey('y')}
|
|
|
|
|
disabled={!isAlive}
|
|
|
|
|
class="px-2.5 py-1 bg-green-600/80 hover:bg-green-600 disabled:opacity-50 rounded text-xs font-mono font-medium text-white transition-colors"
|
|
|
|
|
>
|
|
|
|
|
y
|
|
|
|
|
</button>
|
|
|
|
|
class="px-1.5 py-0.5 bg-green-600/80 hover:bg-green-600 disabled:opacity-50 rounded-sm text-[10px] font-mono text-white transition-colors"
|
|
|
|
|
>y</button>
|
|
|
|
|
<button
|
|
|
|
|
on:click={() => sendKey('n')}
|
|
|
|
|
disabled={!isAlive}
|
|
|
|
|
class="px-2.5 py-1 bg-red-600/80 hover:bg-red-600 disabled:opacity-50 rounded text-xs font-mono font-medium text-white transition-colors"
|
|
|
|
|
>
|
|
|
|
|
n
|
|
|
|
|
</button>
|
|
|
|
|
<span class="w-px h-4 bg-zinc-700"></span>
|
|
|
|
|
{#each ['1', '2', '3', '4', '5'] as num}
|
|
|
|
|
class="px-1.5 py-0.5 bg-red-600/80 hover:bg-red-600 disabled:opacity-50 rounded-sm text-[10px] font-mono text-white transition-colors"
|
|
|
|
|
>n</button>
|
|
|
|
|
<span class="w-px h-3 bg-zinc-700"></span>
|
|
|
|
|
{#each ['1', '2', '3', '4'] as num}
|
|
|
|
|
<button
|
|
|
|
|
on:click={() => sendKey(num)}
|
|
|
|
|
disabled={!isAlive}
|
|
|
|
|
class="px-2.5 py-1 bg-zinc-700 hover:bg-zinc-600 disabled:opacity-50 rounded text-xs font-mono text-zinc-200 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
{num}
|
|
|
|
|
</button>
|
|
|
|
|
class="px-1.5 py-0.5 bg-zinc-700 hover:bg-zinc-600 disabled:opacity-50 rounded-sm text-[10px] font-mono text-zinc-200 transition-colors"
|
|
|
|
|
>{num}</button>
|
|
|
|
|
{/each}
|
|
|
|
|
<span class="w-px h-4 bg-zinc-700"></span>
|
|
|
|
|
<span class="w-px h-3 bg-zinc-700"></span>
|
|
|
|
|
<button
|
|
|
|
|
on:click={() => sendInput('\t')}
|
|
|
|
|
disabled={!isAlive}
|
|
|
|
|
class="px-2.5 py-1 bg-cyan-600/80 hover:bg-cyan-600 disabled:opacity-50 rounded text-xs font-mono font-medium text-white transition-colors"
|
|
|
|
|
>
|
|
|
|
|
Tab
|
|
|
|
|
</button>
|
|
|
|
|
class="px-1.5 py-0.5 bg-cyan-600/80 hover:bg-cyan-600 disabled:opacity-50 rounded-sm text-[10px] font-mono text-white transition-colors"
|
|
|
|
|
>⇥</button>
|
|
|
|
|
<button
|
|
|
|
|
on:click={() => sendInput('\x1b[Z')}
|
|
|
|
|
disabled={!isAlive}
|
|
|
|
|
class="px-2.5 py-1 bg-cyan-600/80 hover:bg-cyan-600 disabled:opacity-50 rounded text-xs font-mono font-medium text-white transition-colors"
|
|
|
|
|
class="px-1.5 py-0.5 bg-cyan-600/80 hover:bg-cyan-600 disabled:opacity-50 rounded-sm text-[10px] font-mono text-white transition-colors"
|
|
|
|
|
>⇤</button>
|
|
|
|
|
<!-- Text zoom -->
|
|
|
|
|
<span class="w-px h-3 bg-zinc-700 ml-auto"></span>
|
|
|
|
|
<button
|
|
|
|
|
on:click={zoomOut}
|
|
|
|
|
disabled={fontScale <= fontScales[0]}
|
|
|
|
|
class="px-1 py-0.5 bg-zinc-700 hover:bg-zinc-600 disabled:opacity-50 rounded-sm text-[10px] font-mono text-zinc-200 transition-colors"
|
|
|
|
|
title="Zoom out"
|
|
|
|
|
>-</button>
|
|
|
|
|
<span class="text-[9px] text-zinc-400 font-mono w-6 text-center">{Math.round(fontScale * 100)}%</span>
|
|
|
|
|
<button
|
|
|
|
|
on:click={zoomIn}
|
|
|
|
|
disabled={fontScale >= fontScales[fontScales.length - 1]}
|
|
|
|
|
class="px-1 py-0.5 bg-zinc-700 hover:bg-zinc-600 disabled:opacity-50 rounded-sm text-[10px] font-mono text-zinc-200 transition-colors"
|
|
|
|
|
title="Zoom in"
|
|
|
|
|
>+</button>
|
|
|
|
|
<!-- Screen size selector -->
|
|
|
|
|
<span class="w-px h-3 bg-zinc-700"></span>
|
|
|
|
|
<button
|
|
|
|
|
on:click={() => resizeScreen('portrait')}
|
|
|
|
|
disabled={resizing}
|
|
|
|
|
class="px-1 py-0.5 rounded-sm text-[10px] font-mono transition-colors {screenMode === 'portrait' ? 'bg-cyan-600 text-white' : 'bg-zinc-700 hover:bg-zinc-600 text-zinc-200'} disabled:opacity-50"
|
|
|
|
|
title="Portrait (50x60)"
|
|
|
|
|
>
|
|
|
|
|
S-Tab
|
|
|
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-2.5 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="px-1 py-0.5 rounded-sm text-[10px] font-mono transition-colors {screenMode === 'landscape' ? 'bg-cyan-600 text-white' : 'bg-zinc-700 hover:bg-zinc-600 text-zinc-200'} disabled:opacity-50"
|
|
|
|
|
title="Landscape (100x30)"
|
|
|
|
|
>
|
|
|
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-2.5 w-3 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="px-1 py-0.5 rounded-sm text-[10px] font-mono transition-colors {screenMode === 'desktop' ? 'bg-cyan-600 text-white' : 'bg-zinc-700 hover:bg-zinc-600 text-zinc-200'} disabled:opacity-50"
|
|
|
|
|
title="Desktop (180x50)"
|
|
|
|
|
>
|
|
|
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3.5 inline-block" fill="none" viewBox="0 0 20 16" stroke="currentColor" stroke-width="1.5">
|
|
|
|
|
<rect x="1" y="1" width="18" height="11" rx="1" />
|
|
|
|
|
<path d="M6 14h8M10 12v2" />
|
|
|
|
|
</svg>
|
|
|
|
|
</button>
|
|
|
|
|
<span class="w-px h-3 bg-zinc-700"></span>
|
|
|
|
|
<button
|
|
|
|
|
on:click={scrollToBottom}
|
|
|
|
|
class="ml-auto p-1 bg-zinc-700 hover:bg-zinc-600 rounded text-zinc-200 transition-colors"
|
|
|
|
|
class="p-0.5 bg-zinc-700 hover:bg-zinc-600 rounded-sm text-zinc-200 transition-colors"
|
|
|
|
|
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">
|
|
|
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" 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>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Input area -->
|
|
|
|
|
<div class="flex-shrink-0 border-t border-zinc-800 bg-zinc-900 p-2 safe-bottom">
|
|
|
|
|
<div class="flex items-center gap-2 bg-black rounded border border-zinc-700 px-3 py-2">
|
|
|
|
|
<span class="text-cyan-400 font-mono text-sm">$</span>
|
|
|
|
|
<input
|
|
|
|
|
bind:this={terminalInput}
|
|
|
|
|
type="text"
|
|
|
|
|
on:keydown={handleKeydown}
|
|
|
|
|
class="flex-1 bg-transparent border-none outline-none font-mono text-sm text-green-400 placeholder-zinc-600"
|
|
|
|
|
placeholder="Keys sent immediately..."
|
|
|
|
|
disabled={!isAlive}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<!-- Hidden input for keyboard capture (invisible but functional for mobile) -->
|
|
|
|
|
<input
|
|
|
|
|
bind:this={terminalInput}
|
|
|
|
|
type="text"
|
|
|
|
|
on:keydown={handleKeydown}
|
|
|
|
|
class="sr-only"
|
|
|
|
|
disabled={!isAlive}
|
|
|
|
|
aria-label="Terminal input"
|
|
|
|
|
/>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|