mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-09 11:55:53 +00:00
- 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>
989 lines
34 KiB
TypeScript
989 lines
34 KiB
TypeScript
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>
|
||
`;
|
||
}
|
||
}
|