vibetunnel/web/src/client/components/session-view.ts
Mario Zechner 8ec8ca47d4 Replace old renderer with new vibe-terminal in session view
- Replaced Renderer class with new vibe-terminal component for interactive sessions
- Integrated CastConverter.connectToStream() for real-time SSE streaming
- Added proper stream connection management with cleanup on disconnect
- Implemented automatic snapshot loading when sessions exit
- Updated copy functionality to work with DOM-based text selection
- Enhanced terminal initialization lifecycle with proper event handling
- Maintained all mobile input controls and keyboard functionality
- Fixed TypeScript type errors for viewport cleanup functionality
- Improved loading states and error handling for network issues

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-18 05:48:38 +02:00

989 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { LitElement, PropertyValues, html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import type { Session } from './session-list.js';
import './terminal.js';
import type { Terminal } from './terminal.js';
import { CastConverter } from '../utils/cast-converter.js';
@customElement('session-view')
export class SessionView extends LitElement {
// Disable shadow DOM to use Tailwind
createRenderRoot() {
return this;
}
@property({ type: Object }) session: Session | null = null;
@state() private connected = false;
@state() private terminal: Terminal | null = null;
@state() private streamConnection: { eventSource: EventSource; disconnect: () => void } | null =
null;
@state() private showMobileInput = false;
@state() private mobileInputText = '';
@state() private isMobile = false;
@state() private touchStartX = 0;
@state() private touchStartY = 0;
@state() private loading = false;
@state() private loadingFrame = 0;
@state() private terminalCols = 0;
@state() private terminalRows = 0;
private loadingInterval: number | null = null;
private keyboardListenerAdded = false;
private touchListenersAdded = false;
private keyboardHandler = (e: KeyboardEvent) => {
if (!this.session) return;
e.preventDefault();
e.stopPropagation();
this.handleKeyboardInput(e);
};
private touchStartHandler = (e: TouchEvent) => {
if (!this.isMobile) return;
const touch = e.touches[0];
this.touchStartX = touch.clientX;
this.touchStartY = touch.clientY;
};
private touchEndHandler = (e: TouchEvent) => {
if (!this.isMobile) return;
const touch = e.changedTouches[0];
const touchEndX = touch.clientX;
const touchEndY = touch.clientY;
const deltaX = touchEndX - this.touchStartX;
const deltaY = touchEndY - this.touchStartY;
// Check for horizontal swipe from left edge (back gesture)
const isSwipeRight = deltaX > 100;
const isVerticallyStable = Math.abs(deltaY) < 100;
const startedFromLeftEdge = this.touchStartX < 50;
if (isSwipeRight && isVerticallyStable && startedFromLeftEdge) {
// Trigger back navigation
this.handleBack();
}
};
connectedCallback() {
super.connectedCallback();
this.connected = true;
// Make session-view focusable for copy/paste without interfering with XTerm cursor
this.tabIndex = 0;
this.addEventListener('paste', this.handlePasteEvent);
this.addEventListener('click', () => this.focus());
// Show loading animation if no session yet
if (!this.session) {
this.startLoading();
}
// Detect mobile device
this.isMobile =
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
window.innerWidth <= 768;
// Only add listeners if not already added
if (!this.isMobile && !this.keyboardListenerAdded) {
document.addEventListener('keydown', this.keyboardHandler);
this.keyboardListenerAdded = true;
} else if (this.isMobile && !this.touchListenersAdded) {
// Add touch event listeners for mobile swipe gestures
document.addEventListener('touchstart', this.touchStartHandler, { passive: true });
document.addEventListener('touchend', this.touchEndHandler, { passive: true });
this.touchListenersAdded = true;
}
}
disconnectedCallback() {
super.disconnectedCallback();
this.connected = false;
// Remove paste event listener and click handler
this.removeEventListener('paste', this.handlePasteEvent);
this.removeEventListener('click', () => this.focus());
// Remove global keyboard event listener
if (!this.isMobile && this.keyboardListenerAdded) {
document.removeEventListener('keydown', this.keyboardHandler);
this.keyboardListenerAdded = false;
} else if (this.isMobile && this.touchListenersAdded) {
// Remove touch event listeners
document.removeEventListener('touchstart', this.touchStartHandler);
document.removeEventListener('touchend', this.touchEndHandler);
this.touchListenersAdded = false;
}
// Stop loading animation
this.stopLoading();
// Cleanup stream connection if it exists
if (this.streamConnection) {
this.streamConnection.disconnect();
this.streamConnection = null;
}
// Terminal cleanup is handled by the component itself
this.terminal = null;
}
firstUpdated(changedProperties: PropertyValues) {
super.firstUpdated(changedProperties);
if (this.session) {
this.stopLoading();
this.setupTerminal();
}
}
updated(changedProperties: Map<string, unknown>) {
super.updated(changedProperties);
// Stop loading and create terminal when session becomes available
if (changedProperties.has('session') && this.session && this.loading) {
this.stopLoading();
this.setupTerminal();
}
// Initialize terminal after first render when terminal element exists
if (!this.terminal && this.session && !this.loading) {
const terminalElement = this.querySelector('vibe-terminal') as Terminal;
if (terminalElement) {
this.initializeTerminal();
}
}
// Adjust terminal height for mobile buttons after render
if (changedProperties.has('showMobileInput') || changedProperties.has('isMobile')) {
requestAnimationFrame(() => {
this.adjustTerminalForMobileButtons();
});
}
}
private setupTerminal() {
// Terminal element will be created in render()
// We'll initialize it in updated() after first render
}
private async initializeTerminal() {
const terminalElement = this.querySelector('vibe-terminal') as Terminal;
if (!terminalElement || !this.session) return;
this.terminal = terminalElement;
// Configure terminal for interactive session
this.terminal.cols = 80;
this.terminal.rows = 24;
this.terminal.fontSize = 14;
this.terminal.fitHorizontally = false; // Allow natural terminal sizing
// Listen for session exit events
this.terminal.addEventListener(
'session-exit',
this.handleSessionExit.bind(this) as EventListener
);
// Listen for terminal resize events to capture dimensions
this.terminal.addEventListener(
'terminal-resize',
this.handleTerminalResize.bind(this) as EventListener
);
// Wait a moment for freshly created sessions before connecting
const sessionAge = Date.now() - new Date(this.session.startedAt).getTime();
const delay = sessionAge < 5000 ? 2000 : 0; // 2 second delay if session is less than 5 seconds old
if (delay > 0) {
// Show loading animation during delay for fresh sessions
this.startLoading();
}
setTimeout(() => {
if (this.terminal && this.session) {
this.stopLoading(); // Stop loading before connecting
this.connectToStream();
}
}, delay);
}
private connectToStream() {
if (!this.terminal || !this.session) return;
// Clean up existing connection
if (this.streamConnection) {
this.streamConnection.disconnect();
this.streamConnection = null;
}
const streamUrl = `/api/sessions/${this.session.id}/stream`;
// Use CastConverter to connect terminal to stream
this.streamConnection = CastConverter.connectToStream(this.terminal, streamUrl);
}
private async handleKeyboardInput(e: KeyboardEvent) {
if (!this.session) return;
// Don't send input to exited sessions
if (this.session.status === 'exited') {
console.log('Ignoring keyboard input - session has exited');
return;
}
// Handle clipboard shortcuts: Cmd+C/V on macOS, Shift+Ctrl+C/V on Linux/Windows
const isMacOS = navigator.platform.toLowerCase().includes('mac');
const isPasteShortcut =
(isMacOS && e.metaKey && e.key === 'v' && !e.ctrlKey && !e.shiftKey) ||
(!isMacOS && e.ctrlKey && e.shiftKey && e.key === 'V');
const isCopyShortcut =
(isMacOS && e.metaKey && e.key === 'c' && !e.ctrlKey && !e.shiftKey) ||
(!isMacOS && e.ctrlKey && e.shiftKey && e.key === 'C');
if (isPasteShortcut) {
await this.handlePaste();
return;
}
if (isCopyShortcut) {
await this.handleCopy();
return;
}
let inputText = '';
// Handle special keys
switch (e.key) {
case 'Enter':
if (e.ctrlKey) {
// Ctrl+Enter - send to tty-fwd for proper handling
inputText = 'ctrl_enter';
} else if (e.shiftKey) {
// Shift+Enter - send to tty-fwd for proper handling
inputText = 'shift_enter';
} else {
// Regular Enter
inputText = 'enter';
}
break;
case 'Escape':
inputText = 'escape';
break;
case 'ArrowUp':
inputText = 'arrow_up';
break;
case 'ArrowDown':
inputText = 'arrow_down';
break;
case 'ArrowLeft':
inputText = 'arrow_left';
break;
case 'ArrowRight':
inputText = 'arrow_right';
break;
case 'Tab':
inputText = '\t';
break;
case 'Backspace':
inputText = '\b';
break;
case 'Delete':
inputText = '\x7f';
break;
case ' ':
inputText = ' ';
break;
default:
// Handle regular printable characters
if (e.key.length === 1) {
inputText = e.key;
} else {
// Ignore other special keys
return;
}
break;
}
// Handle Ctrl combinations (but not if we already handled Ctrl+Enter above)
if (e.ctrlKey && e.key.length === 1 && e.key !== 'Enter') {
const charCode = e.key.toLowerCase().charCodeAt(0);
if (charCode >= 97 && charCode <= 122) {
// a-z
inputText = String.fromCharCode(charCode - 96); // Ctrl+A = \x01, etc.
}
}
// Send the input to the session
try {
const response = await fetch(`/api/sessions/${this.session.id}/input`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ text: inputText }),
});
if (!response.ok) {
if (response.status === 400) {
console.log('Session no longer accepting input (likely exited)');
// Update session status to exited if we get 400 error
if (this.session && (this.session.status as string) !== 'exited') {
this.session = { ...this.session, status: 'exited' };
this.requestUpdate();
}
} else {
console.error('Failed to send input to session:', response.status);
}
}
} catch (error) {
console.error('Error sending input:', error);
}
}
private handleBack() {
window.location.search = '';
}
private handleSessionExit(e: Event) {
const customEvent = e as CustomEvent;
console.log('Session exit event received:', customEvent.detail);
if (this.session && customEvent.detail.sessionId === this.session.id) {
// Update session status to exited
this.session = { ...this.session, status: 'exited' };
this.requestUpdate();
// Switch to snapshot mode - disconnect stream and load final snapshot
if (this.streamConnection) {
this.streamConnection.disconnect();
this.streamConnection = null;
}
// Load final snapshot of the session
requestAnimationFrame(() => {
this.loadSessionSnapshot();
});
}
}
private async loadSessionSnapshot() {
if (!this.terminal || !this.session) return;
try {
const url = `/api/sessions/${this.session.id}/snapshot`;
const response = await fetch(url);
if (!response.ok) throw new Error(`Failed to fetch snapshot: ${response.status}`);
const castContent = await response.text();
// Clear terminal and load snapshot
this.terminal.clear();
await CastConverter.dumpToTerminal(this.terminal, castContent);
// Scroll to bottom after loading
this.terminal.queueCallback(() => {
if (this.terminal) {
this.terminal.scrollToBottom();
}
});
} catch (error) {
console.error('Failed to load session snapshot:', error);
}
}
private handleTerminalResize(event: CustomEvent) {
// Update terminal dimensions for display
const { cols, rows } = event.detail;
this.terminalCols = cols;
this.terminalRows = rows;
this.requestUpdate();
}
// Mobile input methods
private handleMobileInputToggle() {
this.showMobileInput = !this.showMobileInput;
if (this.showMobileInput) {
// Focus the textarea after ensuring it's rendered and visible
setTimeout(() => {
const textarea = this.querySelector('#mobile-input-textarea') as HTMLTextAreaElement;
if (textarea) {
// Ensure textarea is visible and focusable
textarea.style.visibility = 'visible';
textarea.removeAttribute('readonly');
textarea.focus();
// Trigger click to ensure keyboard shows
textarea.click();
this.adjustTextareaForKeyboard();
}
}, 100);
} else {
// Clean up viewport listener when closing overlay
const textarea = this.querySelector('#mobile-input-textarea') as HTMLTextAreaElement;
if (textarea) {
const textareaWithCleanup = textarea as HTMLTextAreaElement & {
_viewportCleanup?: () => void;
};
if (textareaWithCleanup._viewportCleanup) {
textareaWithCleanup._viewportCleanup();
}
}
}
}
private adjustTextareaForKeyboard() {
// Adjust the layout when virtual keyboard appears
const textarea = this.querySelector('#mobile-input-textarea') as HTMLTextAreaElement;
const controls = this.querySelector('#mobile-controls') as HTMLElement;
if (!textarea || !controls) return;
const adjustLayout = () => {
const viewportHeight = window.visualViewport?.height || window.innerHeight;
const windowHeight = window.innerHeight;
const keyboardHeight = windowHeight - viewportHeight;
// If keyboard is visible (viewport height is significantly smaller)
if (keyboardHeight > 100) {
// Move controls above the keyboard
controls.style.transform = `translateY(-${keyboardHeight}px)`;
controls.style.transition = 'transform 0.3s ease';
// Calculate available space to match closed keyboard layout
const header = this.querySelector(
'.flex.items-center.justify-between.p-4.border-b'
) as HTMLElement;
const headerHeight = header?.offsetHeight || 60;
const controlsHeight = controls?.offsetHeight || 120;
// Calculate exact space to maintain same gap as when keyboard is closed
const availableHeight = viewportHeight - headerHeight - controlsHeight;
const inputArea = textarea.parentElement as HTMLElement;
if (inputArea && availableHeight > 0) {
// Set the input area to exactly fill the space, maintaining natural flex behavior
inputArea.style.height = `${availableHeight}px`;
inputArea.style.maxHeight = `${availableHeight}px`;
inputArea.style.overflow = 'hidden';
inputArea.style.display = 'flex';
inputArea.style.flexDirection = 'column';
inputArea.style.paddingBottom = '0px'; // Remove any extra padding
// Let textarea use flex-1 behavior but constrain the container
textarea.style.height = 'auto'; // Let it grow naturally
textarea.style.maxHeight = 'none'; // Remove height constraints
textarea.style.marginBottom = '8px'; // Keep consistent margin
textarea.style.flex = '1'; // Fill available space
}
} else {
// Reset position when keyboard is hidden
controls.style.transform = 'translateY(0px)';
controls.style.transition = 'transform 0.3s ease';
// Reset textarea height and constraints to original flex behavior
const inputArea = textarea.parentElement as HTMLElement;
if (inputArea) {
inputArea.style.height = '';
inputArea.style.maxHeight = '';
inputArea.style.overflow = '';
inputArea.style.display = '';
inputArea.style.flexDirection = '';
inputArea.style.paddingBottom = '';
textarea.style.height = '';
textarea.style.maxHeight = '';
textarea.style.flex = '';
}
}
};
// Listen for viewport changes (keyboard show/hide)
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', adjustLayout);
// Clean up listener when overlay is closed
const cleanup = () => {
if (window.visualViewport) {
window.visualViewport.removeEventListener('resize', adjustLayout);
}
};
// Store cleanup function for later use
(textarea as HTMLTextAreaElement & { _viewportCleanup?: () => void })._viewportCleanup =
cleanup;
}
// Initial adjustment
requestAnimationFrame(adjustLayout);
}
private handleMobileInputChange(e: Event) {
const textarea = e.target as HTMLTextAreaElement;
this.mobileInputText = textarea.value;
}
private async handleMobileInputSendOnly() {
// Get the current value from the textarea directly
const textarea = this.querySelector('#mobile-input-textarea') as HTMLTextAreaElement;
const textToSend = textarea?.value?.trim() || this.mobileInputText.trim();
if (!textToSend) return;
try {
// Send text without enter key
await this.sendInputText(textToSend);
// Clear both the reactive property and textarea
this.mobileInputText = '';
if (textarea) {
textarea.value = '';
}
// Trigger re-render to update button state
this.requestUpdate();
// Hide the input overlay after sending
this.showMobileInput = false;
} catch (error) {
console.error('Error sending mobile input:', error);
// Don't hide the overlay if there was an error
}
}
private async handleMobileInputSend() {
// Get the current value from the textarea directly
const textarea = this.querySelector('#mobile-input-textarea') as HTMLTextAreaElement;
const textToSend = textarea?.value?.trim() || this.mobileInputText.trim();
if (!textToSend) return;
try {
// Add enter key at the end to execute the command
await this.sendInputText(textToSend + '\n');
// Clear both the reactive property and textarea
this.mobileInputText = '';
if (textarea) {
textarea.value = '';
}
// Trigger re-render to update button state
this.requestUpdate();
// Hide the input overlay after sending
this.showMobileInput = false;
} catch (error) {
console.error('Error sending mobile input:', error);
// Don't hide the overlay if there was an error
}
}
private async handleSpecialKey(key: string) {
await this.sendInputText(key);
}
private handlePasteEvent = async (e: ClipboardEvent) => {
e.preventDefault();
e.stopPropagation();
if (!this.session) return;
try {
const clipboardText = e.clipboardData?.getData('text/plain');
if (clipboardText) {
await this.sendInputText(clipboardText);
}
} catch (error) {
console.error('Failed to handle paste event:', error);
}
};
private async handlePaste() {
if (!this.session) return;
try {
// Try clipboard API first (requires user activation)
const clipboardText = await navigator.clipboard.readText();
if (clipboardText) {
// Send the clipboard text to the terminal
await this.sendInputText(clipboardText);
}
} catch (error) {
console.error('Failed to read from clipboard:', error);
// Show user a message about using Ctrl+V instead
console.log('Tip: Try using Ctrl+V (Cmd+V on Mac) to paste instead');
// Fallback: try to use the older document.execCommand method
try {
const textArea = document.createElement('textarea');
textArea.style.position = 'fixed';
textArea.style.opacity = '0';
textArea.style.left = '-9999px';
textArea.style.top = '-9999px';
document.body.appendChild(textArea);
textArea.focus();
if (document.execCommand('paste')) {
const pastedText = textArea.value;
if (pastedText) {
await this.sendInputText(pastedText);
}
}
document.body.removeChild(textArea);
} catch (fallbackError) {
console.error('Fallback paste method also failed:', fallbackError);
console.log('Please focus the terminal and use Ctrl+V (Cmd+V on Mac) to paste');
}
}
}
private async handleCopy() {
if (!this.terminal) return;
try {
// Get selected text from terminal by querying the DOM
const terminalElement = this.querySelector('vibe-terminal');
if (!terminalElement) return;
const selection = window.getSelection();
const selectedText = selection?.toString() || '';
if (selectedText) {
// Write the selected text to clipboard
await navigator.clipboard.writeText(selectedText);
console.log(
'Text copied to clipboard:',
selectedText.substring(0, 50) + (selectedText.length > 50 ? '...' : '')
);
} else {
console.log('No text selected for copying');
}
} catch (error) {
console.error('Failed to copy to clipboard:', error);
// Fallback: try to use the older document.execCommand method
try {
const selection = window.getSelection();
const selectedText = selection?.toString() || '';
if (selectedText) {
const textArea = document.createElement('textarea');
textArea.value = selectedText;
textArea.style.position = 'fixed';
textArea.style.opacity = '0';
document.body.appendChild(textArea);
textArea.select();
if (document.execCommand('copy')) {
console.log(
'Text copied to clipboard (fallback):',
selectedText.substring(0, 50) + (selectedText.length > 50 ? '...' : '')
);
}
document.body.removeChild(textArea);
}
} catch (fallbackError) {
console.error('Fallback copy method also failed:', fallbackError);
}
}
}
private async sendInputText(text: string) {
if (!this.session) return;
try {
const response = await fetch(`/api/sessions/${this.session.id}/input`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ text }),
});
if (!response.ok) {
console.error('Failed to send input to session');
}
} catch (error) {
console.error('Error sending input:', error);
}
}
private adjustTerminalForMobileButtons() {
// Disabled for now to avoid viewport issues
// The mobile buttons will overlay the terminal
}
private startLoading() {
this.loading = true;
this.loadingFrame = 0;
this.loadingInterval = window.setInterval(() => {
this.loadingFrame = (this.loadingFrame + 1) % 4;
this.requestUpdate();
}, 200); // Update every 200ms for smooth animation
}
private stopLoading() {
this.loading = false;
if (this.loadingInterval) {
clearInterval(this.loadingInterval);
this.loadingInterval = null;
}
}
private getLoadingText(): string {
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
return frames[this.loadingFrame % frames.length];
}
render() {
if (!this.session) {
return html` <div class="p-4 text-vs-muted">No session selected</div> `;
}
return html`
<style>
session-view *,
session-view *:focus,
session-view *:focus-visible {
outline: none !important;
box-shadow: none !important;
}
session-view:focus {
outline: 2px solid #007acc !important;
outline-offset: -2px;
}
</style>
<div
class="flex flex-col bg-vs-bg font-mono"
style="height: 100vh; height: 100dvh; outline: none !important; box-shadow: none !important;"
>
<!-- Compact Header -->
<div
class="flex items-center justify-between px-3 py-2 border-b border-vs-border bg-vs-bg-secondary text-sm min-w-0"
>
<div class="flex items-center gap-3 min-w-0 flex-1">
<button
class="bg-vs-user text-vs-text hover:bg-vs-accent font-mono px-2 py-1 border-none rounded transition-colors text-xs flex-shrink-0"
@click=${this.handleBack}
>
BACK
</button>
<div class="text-vs-text min-w-0 flex-1 overflow-hidden">
<div
class="text-vs-accent text-xs sm:text-sm overflow-x-auto scrollbar-thin scrollbar-thumb-vs-border scrollbar-track-transparent whitespace-nowrap"
title="${this.session.name || this.session.command}"
>
${this.session.name || this.session.command}
</div>
<div
class="text-vs-muted text-xs overflow-x-auto scrollbar-thin scrollbar-thumb-vs-border scrollbar-track-transparent whitespace-nowrap"
title="${this.session.workingDir}"
>
${this.session.workingDir}
</div>
</div>
</div>
<div class="flex flex-col items-end gap-0 text-xs flex-shrink-0 ml-2">
<span class="${this.session.status === 'running' ? 'text-vs-user' : 'text-vs-warning'}">
${this.session.status.toUpperCase()}
</span>
${this.terminalCols > 0 && this.terminalRows > 0
? html`
<span
class="text-vs-muted text-xs opacity-60"
style="font-size: 10px; line-height: 1;"
>
${this.terminalCols}×${this.terminalRows}
</span>
`
: ''}
</div>
</div>
<!-- Terminal Container -->
<div
class="flex-1 bg-black overflow-hidden min-h-0 relative"
id="terminal-container"
style="max-width: 100vw; height: 100%;"
>
${this.loading
? html`
<!-- Loading overlay -->
<div
class="absolute inset-0 bg-black bg-opacity-80 flex items-center justify-center z-10"
>
<div class="text-vs-text font-mono text-center">
<div class="text-2xl mb-2">${this.getLoadingText()}</div>
<div class="text-sm text-vs-muted">Connecting to session...</div>
</div>
</div>
`
: ''}
<!-- Terminal Component -->
<vibe-terminal
.sessionId=${this.session?.id || ''}
.cols=${80}
.rows=${24}
.fontSize=${14}
.fitHorizontally=${false}
class="w-full h-full"
></vibe-terminal>
</div>
<!-- Mobile Input Controls -->
${this.isMobile && !this.showMobileInput
? html`
<div class="flex-shrink-0 p-4 bg-vs-bg">
<!-- First row: Arrow keys -->
<div class="flex gap-2 mb-2">
<button
class="flex-1 bg-vs-muted text-vs-bg hover:bg-vs-accent font-mono px-3 py-2 border-none rounded transition-colors text-sm"
@click=${() => this.handleSpecialKey('arrow_up')}
>
<span class="text-xl">↑</span>
</button>
<button
class="flex-1 bg-vs-muted text-vs-bg hover:bg-vs-accent font-mono px-3 py-2 border-none rounded transition-colors text-sm"
@click=${() => this.handleSpecialKey('arrow_down')}
>
<span class="text-xl">↓</span>
</button>
<button
class="flex-1 bg-vs-muted text-vs-bg hover:bg-vs-accent font-mono px-3 py-2 border-none rounded transition-colors text-sm"
@click=${() => this.handleSpecialKey('arrow_left')}
>
<span class="text-xl">←</span>
</button>
<button
class="flex-1 bg-vs-muted text-vs-bg hover:bg-vs-accent font-mono px-3 py-2 border-none rounded transition-colors text-sm"
@click=${() => this.handleSpecialKey('arrow_right')}
>
<span class="text-xl">→</span>
</button>
</div>
<!-- Second row: Special keys -->
<div class="flex gap-2">
<button
class="bg-vs-user text-vs-text hover:bg-vs-accent font-mono px-3 py-2 border-none rounded transition-colors text-sm"
@click=${() => this.handleSpecialKey('\t')}
>
<span class="text-xl">⇥</span>
</button>
<button
class="bg-vs-function text-vs-bg hover:bg-vs-highlight font-mono px-3 py-2 border-none rounded transition-colors text-sm"
@click=${() => this.handleSpecialKey('enter')}
>
<span class="text-xl">⏎</span>
</button>
<button
class="bg-vs-warning text-vs-bg hover:bg-vs-highlight font-mono px-3 py-2 border-none rounded transition-colors text-sm"
@click=${() => this.handleSpecialKey('escape')}
>
ESC
</button>
<button
class="bg-vs-error text-vs-text hover:bg-vs-highlight font-mono px-3 py-2 border-none rounded transition-colors text-sm"
@click=${() => this.handleSpecialKey('\x03')}
>
^C
</button>
<button
class="flex-1 bg-vs-function text-vs-bg hover:bg-vs-highlight font-mono px-3 py-2 border-none rounded transition-colors text-sm"
@click=${this.handleMobileInputToggle}
>
TYPE
</button>
</div>
</div>
`
: ''}
<!-- Full-Screen Input Overlay (only when opened) -->
${this.isMobile && this.showMobileInput
? html`
<div
class="fixed inset-0 bg-vs-bg-secondary bg-opacity-95 z-50 flex flex-col"
style="height: 100vh; height: 100dvh;"
>
<!-- Input Header -->
<div
class="flex items-center justify-between p-4 border-b border-vs-border flex-shrink-0"
>
<div class="text-vs-text font-mono text-sm">Terminal Input</div>
<button
class="text-vs-muted hover:text-vs-text text-lg leading-none border-none bg-transparent cursor-pointer"
@click=${this.handleMobileInputToggle}
>
×
</button>
</div>
<!-- Input Area with dynamic height -->
<div class="flex-1 p-4 flex flex-col min-h-0">
<div class="text-vs-muted text-sm mb-2 flex-shrink-0">
Type your command(s) below. Supports multiline input.
</div>
<textarea
id="mobile-input-textarea"
class="flex-1 bg-vs-bg text-vs-text border border-vs-border font-mono text-sm p-4 resize-none outline-none"
placeholder="Enter your command here..."
.value=${this.mobileInputText}
@input=${this.handleMobileInputChange}
@click=${(e: Event) => {
const textarea = e.target as HTMLTextAreaElement;
// Ensure keyboard shows when clicking the textarea
setTimeout(() => {
textarea.focus();
}, 10);
}}
@focus=${() => {
// Ensure keyboard adjustment when textarea gains focus
this.adjustTextareaForKeyboard();
}}
@keydown=${(e: KeyboardEvent) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.handleMobileInputSend();
}
}}
style="min-height: 120px; margin-bottom: 8px;"
></textarea>
</div>
<!-- Controls - Fixed above keyboard -->
<div
id="mobile-controls"
class="fixed bottom-0 left-0 right-0 p-4 border-t border-vs-border bg-vs-bg-secondary z-60"
style="padding-bottom: max(1rem, env(safe-area-inset-bottom)); transform: translateY(0px);"
>
<!-- Send Buttons Row -->
<div class="flex gap-2 mb-3">
<button
class="flex-1 bg-vs-user text-vs-text hover:bg-vs-accent font-mono px-4 py-3 border-none rounded transition-colors text-sm font-bold"
@click=${this.handleMobileInputSendOnly}
?disabled=${!this.mobileInputText.trim()}
>
SEND
</button>
<button
class="flex-1 bg-vs-function text-vs-bg hover:bg-vs-highlight font-mono px-4 py-3 border-none rounded transition-colors text-sm font-bold"
@click=${this.handleMobileInputSend}
?disabled=${!this.mobileInputText.trim()}
>
SEND + ENTER
</button>
</div>
<div class="text-vs-muted text-xs text-center">
SEND: text only • SEND + ENTER: text with enter key
</div>
</div>
</div>
`
: ''}
</div>
`;
}
}