Implement black theme, mobile improvements, and fit-to-width toggle

- Apply consistent black theme across all components with colored borders
- Add animated VibeTunnel logo with rainbow scrolling gradient
- Implement comprehensive mobile input controls with Ctrl+Alpha overlay
- Add fit-to-width toggle button in session view header with scroll preservation
- Enhance mobile experience with proper viewport handling and keyboard positioning
- Update button styling to use black backgrounds with colored borders throughout
- Resize scroll-to-bottom button for better mobile accessibility

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Mario Zechner 2025-06-18 10:15:27 +02:00
parent 75203f79ab
commit 7c92eb5bdb
10 changed files with 918 additions and 138 deletions

View file

@ -2,10 +2,16 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no">
<title>VibeTunnel - Terminal Multiplexer</title>
<meta name="description" content="Interactive terminal sessions in your browser with real-time streaming and mobile support">
<!-- PWA and mobile optimizations -->
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="theme-color" content="#1e1e1e">
<!-- Favicon -->
<link rel="shortcut icon" href="/favicon.ico">
@ -13,6 +19,37 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css" />
<link href="bundle/output.css" rel="stylesheet">
<!-- Mobile viewport and address bar handling -->
<style>
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100vh;
height: calc(var(--vh, 1vh) * 100); /* Dynamic viewport height for mobile */
overscroll-behavior-y: none; /* Prevent pull-to-refresh */
-webkit-overflow-scrolling: touch;
}
/* Prevent pull-to-refresh only on specific elements */
body {
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
}
/* Only disable touch-action on terminal components */
vibe-terminal {
touch-action: none;
}
/* Ensure app takes full viewport */
vibetunnel-app {
display: block;
width: 100%;
min-height: 100%;
}
</style>
<!-- Import Maps -->
<script type="importmap">
{
@ -23,8 +60,36 @@
}
</script>
</head>
<body class="bg-vs-bg m-0 p-0">
<body class="m-0 p-0" style="background: black;">
<vibetunnel-app></vibetunnel-app>
<!-- Mobile viewport height fix -->
<script>
// Handle dynamic viewport height for mobile browsers
function setViewportHeight() {
const vh = window.innerHeight * 0.01;
document.documentElement.style.setProperty('--vh', `${vh}px`);
}
// Set initial height
setViewportHeight();
// Update on resize and orientation change
window.addEventListener('resize', setViewportHeight);
window.addEventListener('orientationchange', () => {
setTimeout(setViewportHeight, 100);
});
// Force full-screen behavior
window.addEventListener('load', () => {
// Scroll to top to hide address bar
setTimeout(() => {
window.scrollTo(0, 1);
setTimeout(() => window.scrollTo(0, 0), 10);
}, 10);
});
</script>
<script type="module" src="bundle/client-bundle.js"></script>
</body>

View file

@ -296,7 +296,7 @@ export class VibeTunnelApp extends LitElement {
`
)
: html`
<div class="max-w-4xl mx-auto">
<div class="max-w-4xl mx-auto" style="background: black;">
<app-header
.sessions=${this.sessions}
.hideExited=${this.hideExited}

View file

@ -1,6 +1,7 @@
import { LitElement, html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import type { Session } from './session-list.js';
import './vibe-logo.js';
@customElement('app-header')
export class AppHeader extends LitElement {
@ -40,11 +41,13 @@ export class AppHeader extends LitElement {
}
return html`
<div class="app-header p-4 border-b border-vs-border">
<div class="app-header p-4" style="background: black;">
<!-- Mobile layout -->
<div class="flex flex-col gap-3 sm:hidden">
<!-- Centered VibeTunnel title -->
<div class="text-vs-user font-mono text-sm text-center">-=[ VibeTunnel ]=-</div>
<div class="text-center">
<vibe-logo></vibe-logo>
</div>
<!-- Controls row: hide exited on left, buttons on right -->
<div class="flex items-center justify-between">
@ -89,16 +92,38 @@ export class AppHeader extends LitElement {
${runningSessions.length > 0 && !this.killingAll
? html`
<button
class="bg-vs-warning text-vs-bg hover:bg-vs-highlight font-mono px-2 py-1.5 border-none rounded transition-colors text-xs whitespace-nowrap"
class="font-mono px-2 py-1.5 rounded transition-colors text-xs whitespace-nowrap"
style="background: black; color: #d4d4d4; border: 1px solid #d19a66;"
@click=${this.handleKillAll}
@mouseover=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.background = '#d19a66';
btn.style.color = 'black';
}}
@mouseout=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.background = 'black';
btn.style.color = '#d4d4d4';
}}
>
KILL (${runningSessions.length})
</button>
`
: ''}
<button
class="bg-vs-user text-vs-text hover:bg-vs-accent font-mono px-2 py-1.5 border-none rounded transition-colors text-xs whitespace-nowrap"
class="font-mono px-2 py-1.5 rounded transition-colors text-xs whitespace-nowrap"
style="background: black; color: #d4d4d4; border: 1px solid #569cd6;"
@click=${this.handleCreateSession}
@mouseover=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.background = '#569cd6';
btn.style.color = 'black';
}}
@mouseout=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.background = 'black';
btn.style.color = '#d4d4d4';
}}
>
CREATE
</button>
@ -108,7 +133,7 @@ export class AppHeader extends LitElement {
<!-- Desktop layout: single row -->
<div class="hidden sm:flex sm:items-center sm:justify-between">
<div class="text-vs-user font-mono text-sm">-=[ VibeTunnel ]=-</div>
<vibe-logo></vibe-logo>
<div class="flex items-center gap-3">
<label
class="flex items-center gap-2 text-vs-text text-sm cursor-pointer hover:text-vs-accent transition-colors"
@ -150,16 +175,38 @@ export class AppHeader extends LitElement {
${runningSessions.length > 0 && !this.killingAll
? html`
<button
class="bg-vs-warning text-vs-bg hover:bg-vs-highlight font-mono px-3 sm:px-4 py-2 border-none rounded transition-colors text-sm whitespace-nowrap"
class="font-mono px-3 sm:px-4 py-2 rounded transition-colors text-sm whitespace-nowrap"
style="background: black; color: #d4d4d4; border: 1px solid #d19a66;"
@click=${this.handleKillAll}
@mouseover=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.background = '#d19a66';
btn.style.color = 'black';
}}
@mouseout=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.background = 'black';
btn.style.color = '#d4d4d4';
}}
>
KILL ALL (${runningSessions.length})
</button>
`
: ''}
<button
class="bg-vs-user text-vs-text hover:bg-vs-accent font-mono px-3 sm:px-4 py-2 border-none rounded transition-colors text-sm whitespace-nowrap"
class="font-mono px-3 sm:px-4 py-2 rounded transition-colors text-sm whitespace-nowrap"
style="background: black; color: #d4d4d4; border: 1px solid #569cd6;"
@click=${this.handleCreateSession}
@mouseover=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.background = '#569cd6';
btn.style.color = 'black';
}}
@mouseout=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.background = 'black';
btn.style.color = '#d4d4d4';
}}
>
CREATE SESSION
</button>

View file

@ -150,16 +150,28 @@ export class FileBrowser extends LitElement {
style="z-index: 9999;"
>
<div
class="bg-vs-bg-secondary border border-vs-border font-mono text-sm w-96 h-96 flex flex-col"
class="font-mono text-sm w-96 h-96 flex flex-col"
style="background: black; border: 1px solid #569cd6; border-radius: 4px;"
>
<div class="p-4 border-b border-vs-border flex-shrink-0">
<div class="p-4 flex-shrink-0" style="border-bottom: 1px solid #444;">
<div class="flex justify-between items-center mb-2">
<div class="text-vs-assistant text-sm">Select Directory</div>
<div class="text-vs-user text-sm">Select Directory</div>
<button
class="bg-vs-user text-vs-bg hover:bg-vs-accent font-mono px-2 py-1 text-xs border-none rounded"
class="font-mono px-2 py-1 text-xs rounded transition-colors"
style="background: black; color: #d4d4d4; border: 1px solid #569cd6; border-radius: 4px;"
@click=${this.handleCreateFolder}
?disabled=${this.loading}
title="Create new folder"
@mouseover=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.background = '#569cd6';
btn.style.color = 'black';
}}
@mouseout=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.background = 'black';
btn.style.color = '#d4d4d4';
}}
>
+ folder
</button>
@ -216,7 +228,8 @@ export class FileBrowser extends LitElement {
<div class="flex gap-2">
<input
type="text"
class="flex-1 bg-vs-bg border border-vs-border text-vs-text px-2 py-1 text-sm font-mono"
class="flex-1 outline-none font-mono px-2 py-1 text-sm"
style="background: rgba(0, 0, 0, 0.8); color: #d4d4d4; border: 1px solid #444; border-radius: 4px;"
placeholder="Folder name"
.value=${this.newFolderName}
@input=${this.handleFolderNameInput}
@ -224,16 +237,46 @@ export class FileBrowser extends LitElement {
?disabled=${this.creating}
/>
<button
class="bg-vs-user text-vs-bg hover:bg-vs-accent font-mono px-2 py-1 text-xs border-none"
class="font-mono px-2 py-1 text-xs transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
style="background: black; color: #d4d4d4; border: 1px solid #569cd6; border-radius: 4px;"
@click=${this.createFolder}
?disabled=${this.creating || !this.newFolderName.trim()}
@mouseover=${(e: Event) => {
const btn = e.target as HTMLElement;
if (!btn.hasAttribute('disabled')) {
btn.style.background = '#569cd6';
btn.style.color = 'black';
}
}}
@mouseout=${(e: Event) => {
const btn = e.target as HTMLElement;
if (!btn.hasAttribute('disabled')) {
btn.style.background = 'black';
btn.style.color = '#d4d4d4';
}
}}
>
${this.creating ? '...' : 'create'}
</button>
<button
class="bg-vs-muted text-vs-bg hover:bg-vs-text font-mono px-2 py-1 text-xs border-none"
class="font-mono px-2 py-1 text-xs transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
style="background: black; color: #d4d4d4; border: 1px solid #888; border-radius: 4px;"
@click=${this.handleCancelCreateFolder}
?disabled=${this.creating}
@mouseover=${(e: Event) => {
const btn = e.target as HTMLElement;
if (!btn.hasAttribute('disabled')) {
btn.style.background = '#888';
btn.style.color = 'black';
}
}}
@mouseout=${(e: Event) => {
const btn = e.target as HTMLElement;
if (!btn.hasAttribute('disabled')) {
btn.style.background = 'black';
btn.style.color = '#d4d4d4';
}
}}
>
cancel
</button>
@ -244,14 +287,36 @@ export class FileBrowser extends LitElement {
<div class="p-4 border-t border-vs-border flex gap-4 justify-end flex-shrink-0">
<button
class="bg-vs-muted text-vs-bg hover:bg-vs-text font-mono px-4 py-2 border-none"
class="font-mono px-4 py-2 transition-colors"
style="background: black; color: #d4d4d4; border: 1px solid #888; border-radius: 4px;"
@click=${this.handleCancel}
@mouseover=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.background = '#888';
btn.style.color = 'black';
}}
@mouseout=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.background = 'black';
btn.style.color = '#d4d4d4';
}}
>
cancel
</button>
<button
class="bg-vs-user text-vs-bg hover:bg-vs-accent font-mono px-4 py-2 border-none"
class="font-mono px-4 py-2 transition-colors"
style="background: black; color: #d4d4d4; border: 1px solid #569cd6; border-radius: 4px;"
@click=${this.handleSelect}
@mouseover=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.background = '#569cd6';
btn.style.color = 'black';
}}
@mouseout=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.background = 'black';
btn.style.color = '#d4d4d4';
}}
>
select
</button>

View file

@ -209,8 +209,11 @@ export class SessionCard extends LitElement {
@click=${this.handleCardClick}
>
<!-- Compact Header -->
<div class="flex justify-between items-center px-3 py-2 border-b border-vs-border">
<div class="text-vs-text text-xs font-mono pr-2 flex-1 min-w-0">
<div
class="flex justify-between items-center px-3 py-2 border-b border-vs-border"
style="background: black;"
>
<div class="text-xs font-mono pr-2 flex-1 min-w-0" style="color: #569cd6;">
<div class="truncate" title="${this.session.name || this.session.command}">
${this.session.name || this.session.command}
</div>
@ -218,9 +221,24 @@ export class SessionCard extends LitElement {
${this.session.status === 'running'
? html`
<button
class="bg-vs-warning text-vs-bg hover:bg-vs-highlight font-mono px-2 py-0.5 border-none text-xs disabled:opacity-50 flex-shrink-0 rounded"
class="font-mono px-2 py-0.5 text-xs disabled:opacity-50 flex-shrink-0 rounded transition-colors"
style="background: black; color: #d4d4d4; border: 1px solid #d19a66;"
@click=${this.handleKillClick}
?disabled=${this.killing}
@mouseover=${(e: Event) => {
const btn = e.target as HTMLElement;
if (!this.killing) {
btn.style.background = '#d19a66';
btn.style.color = 'black';
}
}}
@mouseout=${(e: Event) => {
const btn = e.target as HTMLElement;
if (!this.killing) {
btn.style.background = 'black';
btn.style.color = '#d4d4d4';
}
}}
>
${this.killing ? 'killing...' : 'kill'}
</button>
@ -253,7 +271,10 @@ export class SessionCard extends LitElement {
</div>
<!-- Compact Footer -->
<div class="px-3 py-2 text-vs-muted text-xs border-t border-vs-border">
<div
class="px-3 py-2 text-vs-muted text-xs border-t border-vs-border"
style="background: black;"
>
<div class="flex justify-between items-center min-w-0">
<span class="${this.getStatusColor()} text-xs flex items-center gap-1 flex-shrink-0">
<div class="w-2 h-2 rounded-full ${this.getStatusDotColor()}"></div>

View file

@ -227,16 +227,11 @@ export class SessionCreateForm extends LitElement {
style="z-index: 9999;"
>
<div
class="bg-vs-bg-secondary border border-vs-border font-mono text-sm w-96 max-w-full mx-4"
class="font-mono text-sm w-96 max-w-full mx-4"
style="background: black; border: 1px solid #569cd6; border-radius: 4px;"
>
<div class="p-4 border-b border-vs-border flex justify-between items-center">
<div class="text-vs-assistant text-sm">Create New Session</div>
<button
class="text-vs-muted hover:text-vs-text text-lg leading-none border-none bg-transparent cursor-pointer"
@click=${this.handleCancel}
>
×
</button>
<div class="p-4" style="border-bottom: 1px solid #444;">
<div class="text-vs-user text-sm">Create New Session</div>
</div>
<div class="p-4">
@ -244,7 +239,8 @@ export class SessionCreateForm extends LitElement {
<div class="text-vs-text mb-2">Session Name (optional):</div>
<input
type="text"
class="w-full bg-vs-bg text-vs-text border border-vs-border outline-none font-mono px-4 py-2"
class="w-full outline-none font-mono px-4 py-2"
style="background: rgba(0, 0, 0, 0.8); color: #d4d4d4; border: 1px solid #444; border-radius: 4px;"
.value=${this.sessionName}
@input=${this.handleSessionNameChange}
placeholder="My Session"
@ -257,16 +253,28 @@ export class SessionCreateForm extends LitElement {
<div class="flex gap-4">
<input
type="text"
class="flex-1 bg-vs-bg text-vs-text border border-vs-border outline-none font-mono px-4 py-2"
class="flex-1 outline-none font-mono px-4 py-2"
style="background: rgba(0, 0, 0, 0.8); color: #d4d4d4; border: 1px solid #444; border-radius: 4px;"
.value=${this.workingDir}
@input=${this.handleWorkingDirChange}
placeholder="~/"
?disabled=${this.disabled || this.isCreating}
/>
<button
class="bg-vs-function text-vs-bg hover:bg-vs-highlight font-mono px-4 py-2 border-none"
class="font-mono px-4 py-2 transition-colors"
style="background: black; color: #d4d4d4; border: 1px solid #569cd6; border-radius: 4px;"
@click=${this.handleBrowse}
?disabled=${this.disabled || this.isCreating}
@mouseover=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.background = '#569cd6';
btn.style.color = 'black';
}}
@mouseout=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.background = 'black';
btn.style.color = '#d4d4d4';
}}
>
browse
</button>
@ -277,7 +285,8 @@ export class SessionCreateForm extends LitElement {
<div class="text-vs-text mb-2">Command:</div>
<input
type="text"
class="w-full bg-vs-bg text-vs-text border border-vs-border outline-none font-mono px-4 py-2"
class="w-full outline-none font-mono px-4 py-2"
style="background: rgba(0, 0, 0, 0.8); color: #d4d4d4; border: 1px solid #444; border-radius: 4px;"
.value=${this.command}
@input=${this.handleCommandChange}
@keydown=${(e: KeyboardEvent) => e.key === 'Enter' && this.handleCreate()}
@ -288,19 +297,45 @@ export class SessionCreateForm extends LitElement {
<div class="flex gap-4 justify-end">
<button
class="bg-vs-muted text-vs-bg hover:bg-vs-text font-mono px-4 py-2 border-none"
class="font-mono px-4 py-2 transition-colors"
style="background: black; color: #d4d4d4; border: 1px solid #888; border-radius: 4px;"
@click=${this.handleCancel}
?disabled=${this.isCreating}
@mouseover=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.background = '#888';
btn.style.color = 'black';
}}
@mouseout=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.background = 'black';
btn.style.color = '#d4d4d4';
}}
>
cancel
</button>
<button
class="bg-vs-user text-vs-text hover:bg-vs-accent font-mono px-4 py-2 border-none disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-vs-user"
class="font-mono px-4 py-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
style="background: black; color: #d4d4d4; border: 1px solid #569cd6; border-radius: 4px;"
@click=${this.handleCreate}
?disabled=${this.disabled ||
this.isCreating ||
!this.workingDir.trim() ||
!this.command.trim()}
@mouseover=${(e: Event) => {
const btn = e.target as HTMLElement;
if (!btn.hasAttribute('disabled')) {
btn.style.background = '#569cd6';
btn.style.color = 'black';
}
}}
@mouseout=${(e: Event) => {
const btn = e.target as HTMLElement;
if (!btn.hasAttribute('disabled')) {
btn.style.background = 'black';
btn.style.color = '#d4d4d4';
}
}}
>
${this.isCreating ? 'creating...' : 'create'}
</button>

View file

@ -74,15 +74,30 @@ export class SessionList extends LitElement {
: this.sessions;
return html`
<div class="font-mono text-sm p-4">
<div class="font-mono text-sm p-4" style="background: black;">
<!-- Controls -->
${!this.hideExited && this.sessions.filter((s) => s.status === 'exited').length > 0
? html`
<div class="mb-4">
<button
class="bg-vs-warning text-vs-bg hover:bg-vs-highlight font-mono px-4 py-2 border-none rounded transition-colors disabled:opacity-50"
class="font-mono px-4 py-2 rounded transition-colors disabled:opacity-50"
style="background: black; color: #d4d4d4; border: 1px solid #d19a66;"
@click=${this.handleCleanupExited}
?disabled=${this.cleaningExited}
@mouseover=${(e: Event) => {
const btn = e.target as HTMLElement;
if (!this.cleaningExited) {
btn.style.background = '#d19a66';
btn.style.color = 'black';
}
}}
@mouseout=${(e: Event) => {
const btn = e.target as HTMLElement;
if (!this.cleaningExited) {
btn.style.background = 'black';
btn.style.color = '#d4d4d4';
}
}}
>
${this.cleaningExited ? '[~] CLEANING...' : 'CLEAN EXITED'}
</button>

View file

@ -26,6 +26,8 @@ export class SessionView extends LitElement {
@state() private loadingFrame = 0;
@state() private terminalCols = 0;
@state() private terminalRows = 0;
@state() private showCtrlAlpha = false;
@state() private terminalFitHorizontally = false;
private loadingInterval: number | null = null;
private keyboardListenerAdded = false;
@ -88,6 +90,11 @@ export class SessionView extends LitElement {
navigator.userAgent
);
// Hide mobile address bar when entering session view
if (this.isMobile) {
this.hideAddressBar();
}
// Only add listeners if not already added
if (!this.isMobile && !this.keyboardListenerAdded) {
document.addEventListener('keydown', this.keyboardHandler);
@ -154,6 +161,11 @@ export class SessionView extends LitElement {
const terminalElement = this.querySelector('vibe-terminal') as Terminal;
if (terminalElement) {
this.initializeTerminal();
// Hide address bar again after terminal is ready
if (this.isMobile) {
setTimeout(() => this.hideAddressBar(), 200);
}
}
}
@ -344,6 +356,24 @@ export class SessionView extends LitElement {
}
}
private hideAddressBar() {
// Trigger address bar hiding on mobile
if (window.innerHeight !== window.outerHeight) {
// Multiple attempts with different timing to ensure it works
setTimeout(() => {
window.scrollTo(0, 1);
setTimeout(() => {
window.scrollTo(0, 0);
// Force another attempt after a brief delay
setTimeout(() => {
window.scrollTo(0, 1);
setTimeout(() => window.scrollTo(0, 0), 50);
}, 100);
}, 50);
}, 100);
}
}
private handleBack() {
window.location.search = '';
}
@ -558,7 +588,8 @@ export class SessionView extends LitElement {
try {
// Add enter key at the end to execute the command
await this.sendInputText(textToSend + '\n');
await this.sendInputText(textToSend);
await this.sendInputText('enter');
// Clear both the reactive property and textarea
this.mobileInputText = '';
@ -581,6 +612,33 @@ export class SessionView extends LitElement {
await this.sendInputText(key);
}
private handleCtrlAlphaToggle() {
this.showCtrlAlpha = !this.showCtrlAlpha;
}
private async handleCtrlKey(letter: string) {
// Convert letter to control character (A=1, B=2, ..., Z=26)
const controlCode = String.fromCharCode(letter.charCodeAt(0) - 64);
await this.sendInputText(controlCode);
this.showCtrlAlpha = false; // Close overlay after sending
}
private handleCtrlAlphaBackdrop(e: Event) {
if (e.target === e.currentTarget) {
this.showCtrlAlpha = false;
}
}
private handleTerminalFitToggle() {
this.terminalFitHorizontally = !this.terminalFitHorizontally;
// Find the terminal component and call its handleFitToggle method
const terminal = this.querySelector('vibe-terminal') as any;
if (terminal && terminal.handleFitToggle) {
// Use the terminal's own toggle method which handles scroll position correctly
terminal.handleFitToggle();
}
}
private handlePasteEvent = async (e: ClipboardEvent) => {
e.preventDefault();
e.stopPropagation();
@ -736,6 +794,30 @@ export class SessionView extends LitElement {
return frames[this.loadingFrame % frames.length];
}
private getStatusText(): string {
if (!this.session) return '';
if ('waiting' in this.session && this.session.waiting) {
return 'waiting';
}
return this.session.status;
}
private getStatusColor(): string {
if (!this.session) return 'text-vs-muted';
if ('waiting' in this.session && this.session.waiting) {
return 'text-vs-muted';
}
return this.session.status === 'running' ? 'text-vs-user' : 'text-vs-warning';
}
private getStatusDotColor(): string {
if (!this.session) return 'bg-gray-500';
if ('waiting' in this.session && this.session.waiting) {
return 'bg-gray-500';
}
return this.session.status === 'running' ? 'bg-green-500' : 'bg-orange-500';
}
render() {
if (!this.session) {
return html` <div class="p-4 text-vs-muted">No session selected</div> `;
@ -760,12 +842,24 @@ export class SessionView extends LitElement {
>
<!-- 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"
class="flex items-center justify-between px-3 py-2 border-b border-vs-border text-sm min-w-0"
style="background: black;"
>
<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"
class="font-mono px-2 py-1 rounded transition-colors text-xs flex-shrink-0"
style="background: black; color: #d4d4d4; border: 1px solid #569cd6;"
@click=${this.handleBack}
@mouseover=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.background = '#569cd6';
btn.style.color = 'black';
}}
@mouseout=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.background = 'black';
btn.style.color = '#d4d4d4';
}}
>
BACK
</button>
@ -776,28 +870,43 @@ export class SessionView extends LitElement {
>
${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 class="flex items-center gap-2 text-xs flex-shrink-0 ml-2">
<div class="flex flex-col items-end gap-0">
<span class="${this.getStatusColor()} text-xs flex items-center gap-1">
<div class="w-2 h-2 rounded-full ${this.getStatusDotColor()}"></div>
${this.getStatusText().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>
<button
class="font-mono text-lg transition-colors flex-shrink-0"
style="background: transparent; color: ${this.terminalFitHorizontally ? '#569cd6' : '#d4d4d4'}; border: none; padding: 4px;"
@click=${this.handleTerminalFitToggle}
title="Toggle fit to width"
@mouseover=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.color = '#569cd6';
}}
@mouseout=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.color = this.terminalFitHorizontally ? '#569cd6' : '#d4d4d4';
}}
>
${this.terminalFitHorizontally
? html`<span>←</span>&nbsp;<span>→</span>`
: html`<span>→</span>&nbsp;<span>←</span>`}
</button>
</div>
</div>
@ -834,30 +943,74 @@ export class SessionView extends LitElement {
<!-- Mobile Input Controls -->
${this.isMobile && !this.showMobileInput
? html`
<div class="flex-shrink-0 p-4 bg-vs-bg">
<div class="flex-shrink-0 p-4" style="background: black;">
<!-- 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"
class="flex-1 font-mono px-3 py-2 text-sm transition-all cursor-pointer"
@click=${() => this.handleSpecialKey('arrow_up')}
style="background: rgba(0, 0, 0, 0.8); color: #d4d4d4; border: 1px solid #444; border-radius: 4px;"
@mouseover=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.background = 'rgba(0, 0, 0, 0.9)';
btn.style.borderColor = '#666';
}}
@mouseout=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.background = 'rgba(0, 0, 0, 0.8)';
btn.style.borderColor = '#444';
}}
>
<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"
class="flex-1 font-mono px-3 py-2 text-sm transition-all cursor-pointer"
@click=${() => this.handleSpecialKey('arrow_down')}
style="background: rgba(0, 0, 0, 0.8); color: #d4d4d4; border: 1px solid #444; border-radius: 4px;"
@mouseover=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.background = 'rgba(0, 0, 0, 0.9)';
btn.style.borderColor = '#666';
}}
@mouseout=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.background = 'rgba(0, 0, 0, 0.8)';
btn.style.borderColor = '#444';
}}
>
<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"
class="flex-1 font-mono px-3 py-2 text-sm transition-all cursor-pointer"
@click=${() => this.handleSpecialKey('arrow_left')}
style="background: rgba(0, 0, 0, 0.8); color: #d4d4d4; border: 1px solid #444; border-radius: 4px;"
@mouseover=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.background = 'rgba(0, 0, 0, 0.9)';
btn.style.borderColor = '#666';
}}
@mouseout=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.background = 'rgba(0, 0, 0, 0.8)';
btn.style.borderColor = '#444';
}}
>
<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"
class="flex-1 font-mono px-3 py-2 text-sm transition-all cursor-pointer"
@click=${() => this.handleSpecialKey('arrow_right')}
style="background: rgba(0, 0, 0, 0.8); color: #d4d4d4; border: 1px solid #444; border-radius: 4px;"
@mouseover=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.background = 'rgba(0, 0, 0, 0.9)';
btn.style.borderColor = '#666';
}}
@mouseout=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.background = 'rgba(0, 0, 0, 0.8)';
btn.style.borderColor = '#444';
}}
>
<span class="text-xl"></span>
</button>
@ -866,34 +1019,89 @@ export class SessionView extends LitElement {
<!-- 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"
class="font-mono text-sm transition-all cursor-pointer w-16"
@click=${() => this.handleSpecialKey('\t')}
style="background: rgba(0, 0, 0, 0.8); color: #d4d4d4; border: 1px solid #444; border-radius: 4px; padding: 8px 4px;"
@mouseover=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.background = 'rgba(0, 0, 0, 0.9)';
btn.style.borderColor = '#666';
}}
@mouseout=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.background = 'rgba(0, 0, 0, 0.8)';
btn.style.borderColor = '#444';
}}
>
<span class="text-xl"></span>
TAB
</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"
class="font-mono text-sm transition-all cursor-pointer w-16"
@click=${() => this.handleSpecialKey('enter')}
style="background: rgba(0, 0, 0, 0.8); color: #d4d4d4; border: 1px solid #444; border-radius: 4px; padding: 8px 4px;"
@mouseover=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.background = 'rgba(0, 0, 0, 0.9)';
btn.style.borderColor = '#666';
}}
@mouseout=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.background = 'rgba(0, 0, 0, 0.8)';
btn.style.borderColor = '#444';
}}
>
<span class="text-xl"></span>
ENTER
</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"
class="font-mono text-sm transition-all cursor-pointer w-16"
@click=${() => this.handleSpecialKey('escape')}
style="background: rgba(0, 0, 0, 0.8); color: #d4d4d4; border: 1px solid #444; border-radius: 4px; padding: 8px 4px;"
@mouseover=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.background = 'rgba(0, 0, 0, 0.9)';
btn.style.borderColor = '#666';
}}
@mouseout=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.background = 'rgba(0, 0, 0, 0.8)';
btn.style.borderColor = '#444';
}}
>
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')}
class="font-mono text-sm transition-all cursor-pointer w-16"
@click=${this.handleCtrlAlphaToggle}
style="background: rgba(0, 0, 0, 0.8); color: #d4d4d4; border: 1px solid #444; border-radius: 4px; padding: 8px 4px;"
@mouseover=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.background = 'rgba(0, 0, 0, 0.9)';
btn.style.borderColor = '#666';
}}
@mouseout=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.background = 'rgba(0, 0, 0, 0.8)';
btn.style.borderColor = '#444';
}}
>
^C
CTRL
</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"
class="flex-1 font-mono px-3 py-2 text-sm transition-all cursor-pointer"
@click=${this.handleMobileInputToggle}
style="background: rgba(0, 0, 0, 0.8); color: #d4d4d4; border: 1px solid #444; border-radius: 4px;"
@mouseover=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.background = 'rgba(0, 0, 0, 0.9)';
btn.style.borderColor = '#666';
}}
@mouseout=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.background = 'rgba(0, 0, 0, 0.8)';
btn.style.borderColor = '#444';
}}
>
TYPE
ABC123
</button>
</div>
</div>
@ -904,80 +1112,211 @@ export class SessionView extends LitElement {
${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;"
class="fixed inset-0 z-50 flex flex-col"
style="background: rgba(0, 0, 0, 0.8);"
@click=${(e: Event) => {
if (e.target === e.currentTarget) {
this.showMobileInput = false;
}
}}
@touchstart=${this.touchStartHandler}
@touchend=${this.touchEndHandler}
>
<!-- Input Header -->
<!-- Spacer to push content up above keyboard -->
<div class="flex-1"></div>
<div
class="flex items-center justify-between p-4 border-b border-vs-border flex-shrink-0"
class="font-mono text-sm mx-4 mb-4 flex flex-col"
style="background: black; border: 1px solid #569cd6; border-radius: 8px; transform: translateY(-120px);"
@click=${(e: Event) => e.stopPropagation()}
>
<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.
<!-- Input Area -->
<div class="p-4 flex flex-col">
<textarea
id="mobile-input-textarea"
class="w-full font-mono text-sm resize-none outline-none"
placeholder="Type your command here..."
.value=${this.mobileInputText}
@input=${this.handleMobileInputChange}
@click=${(e: Event) => {
const textarea = e.target as HTMLTextAreaElement;
setTimeout(() => {
textarea.focus();
}, 10);
}}
@keydown=${(e: KeyboardEvent) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.handleMobileInputSend();
} else if (e.key === 'Escape') {
e.preventDefault();
this.showMobileInput = false;
}
}}
style="height: 120px; background: black; color: #d4d4d4; border: none; padding: 12px;"
></textarea>
</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">
<!-- Controls -->
<div class="p-4 flex gap-2" style="border-top: 1px solid #444;">
<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"
class="font-mono px-3 py-2 text-xs transition-colors"
@click=${() => (this.showMobileInput = false)}
style="background: black; color: #d4d4d4; border: 1px solid #888; border-radius: 4px;"
@mouseover=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.background = '#888';
btn.style.color = 'black';
}}
@mouseout=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.background = 'black';
btn.style.color = '#d4d4d4';
}}
>
CANCEL
</button>
<button
class="flex-1 font-mono px-3 py-2 text-xs transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
@click=${this.handleMobileInputSendOnly}
?disabled=${!this.mobileInputText.trim()}
style="background: black; color: #d4d4d4; border: 1px solid #888; border-radius: 4px;"
@mouseover=${(e: Event) => {
const btn = e.target as HTMLElement;
if (!btn.hasAttribute('disabled')) {
btn.style.background = '#888';
btn.style.color = 'black';
}
}}
@mouseout=${(e: Event) => {
const btn = e.target as HTMLElement;
if (!btn.hasAttribute('disabled')) {
btn.style.background = 'black';
btn.style.color = '#d4d4d4';
}
}}
>
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"
class="flex-1 font-mono px-3 py-2 text-xs transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
@click=${this.handleMobileInputSend}
?disabled=${!this.mobileInputText.trim()}
style="background: black; color: #d4d4d4; border: 1px solid #569cd6; border-radius: 4px;"
@mouseover=${(e: Event) => {
const btn = e.target as HTMLElement;
if (!btn.hasAttribute('disabled')) {
btn.style.background = '#569cd6';
btn.style.color = 'black';
}
}}
@mouseout=${(e: Event) => {
const btn = e.target as HTMLElement;
if (!btn.hasAttribute('disabled')) {
btn.style.background = 'black';
btn.style.color = '#d4d4d4';
}
}}
>
SEND + ENTER
SEND +
</button>
</div>
</div>
</div>
`
: ''}
<div class="text-vs-muted text-xs text-center">
SEND: text only SEND + ENTER: text with enter key
<!-- Ctrl+Alpha Overlay -->
${this.isMobile && this.showCtrlAlpha
? html`
<div
class="fixed inset-0 z-50 flex items-center justify-center"
style="background: rgba(0, 0, 0, 0.8);"
@click=${this.handleCtrlAlphaBackdrop}
>
<div
class="font-mono text-sm m-4 max-w-sm w-full"
style="background: black; border: 1px solid #569cd6; border-radius: 8px; padding: 20px;"
@click=${(e: Event) => e.stopPropagation()}
>
<div class="text-vs-user text-center mb-4 font-bold">Ctrl + Key</div>
<!-- Grid of A-Z buttons -->
<div class="grid grid-cols-6 gap-2 mb-4">
${[
'A',
'B',
'C',
'D',
'E',
'F',
'G',
'H',
'I',
'J',
'K',
'L',
'M',
'N',
'O',
'P',
'Q',
'R',
'S',
'T',
'U',
'V',
'W',
'X',
'Y',
'Z',
].map(
(letter) => html`
<button
class="font-mono text-xs transition-all cursor-pointer aspect-square flex items-center justify-center"
style="background: rgba(0, 0, 0, 0.8); color: #d4d4d4; border: 1px solid #444; border-radius: 4px;"
@click=${() => this.handleCtrlKey(letter)}
@mouseover=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.background = '#569cd6';
btn.style.color = 'black';
}}
@mouseout=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.background = 'rgba(0, 0, 0, 0.8)';
btn.style.color = '#d4d4d4';
}}
>
${letter}
</button>
`
)}
</div>
<!-- Common shortcuts info -->
<div class="text-xs text-vs-muted text-center mb-4">
<div>Common: C=interrupt, X=exit, O=save, W=search</div>
</div>
<!-- Close button -->
<div class="flex justify-center">
<button
class="font-mono px-4 py-2 text-sm transition-all cursor-pointer"
style="background: black; color: #d4d4d4; border: 1px solid #888; border-radius: 4px;"
@click=${() => (this.showCtrlAlpha = false)}
@mouseover=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.background = '#888';
btn.style.color = 'black';
}}
@mouseout=${(e: Event) => {
const btn = e.target as HTMLElement;
btn.style.background = 'black';
btn.style.color = '#d4d4d4';
}}
>
CLOSE
</button>
</div>
</div>
</div>

View file

@ -1033,6 +1033,53 @@ export class Terminal extends LitElement {
this.requestUpdate();
};
/**
* Handle fit to width toggle
*/
public handleFitToggle = () => {
if (!this.terminal || !this.container) {
this.fitHorizontally = !this.fitHorizontally;
this.requestUpdate();
return;
}
// Store current logical scroll position before toggling
const buffer = this.terminal.buffer.active;
const currentLineHeight = this.fontSize * 1.2;
const currentScrollLines = currentLineHeight > 0 ? this.viewportY / currentLineHeight : 0;
const wasAtBottom = this.isScrolledToBottom();
// Store original font size when entering fit mode
if (!this.fitHorizontally) {
this.originalFontSize = this.fontSize;
}
// Toggle the mode
this.fitHorizontally = !this.fitHorizontally;
// Restore original font size when exiting fit mode
if (!this.fitHorizontally) {
this.fontSize = this.originalFontSize;
}
// Recalculate fit
this.fitTerminal();
// Restore scroll position - prioritize staying at bottom if we were there
if (wasAtBottom) {
// Force scroll to bottom with new dimensions
this.scrollToBottom();
} else {
// Restore logical scroll position for non-bottom positions
const newLineHeight = this.fontSize * 1.2;
const maxScrollPixels = Math.max(0, (buffer.length - this.actualRows) * newLineHeight);
const newViewportY = currentScrollLines * newLineHeight;
this.viewportY = Math.max(0, Math.min(maxScrollPixels, newViewportY));
}
this.requestUpdate();
};
render() {
return html`
<style>
@ -1135,17 +1182,17 @@ export class Terminal extends LitElement {
position: absolute;
bottom: 12px;
left: 12px;
width: 32px;
height: 32px;
width: 48px;
height: 48px;
background: rgba(0, 0, 0, 0.8);
border: 1px solid #444;
border-radius: 4px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #d4d4d4;
font-size: 16px;
font-size: 24px;
transition: all 0.2s ease;
user-select: none;
z-index: 10;
@ -1191,6 +1238,41 @@ export class Terminal extends LitElement {
font-weight: bold;
margin-left: 8px;
}
.fit-toggle {
position: absolute;
top: 12px;
right: 12px;
width: 48px;
height: 48px;
background: rgba(0, 0, 0, 0.8);
border: 1px solid #444;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #d4d4d4;
font-size: 20px;
transition: all 0.2s ease;
user-select: none;
z-index: 10;
}
.fit-toggle:hover {
background: rgba(0, 0, 0, 0.9);
border-color: #666;
transform: translateY(-1px);
}
.fit-toggle:active {
transform: translateY(0px);
}
.fit-toggle.active {
border-color: #569cd6;
color: #569cd6;
}
</style>
<div style="position: relative; width: 100%; height: 100%;">
<div id="terminal-container" class="terminal-container w-full h-full overflow-hidden"></div>

View file

@ -0,0 +1,111 @@
import { LitElement, html } from 'lit';
import { customElement, state } from 'lit/decorators.js';
@customElement('vibe-logo')
export class VibeLogo extends LitElement {
// Disable shadow DOM to use Tailwind
createRenderRoot() {
return this;
}
@state() private frame = 0;
private animationInterval: number | null = null;
connectedCallback() {
super.connectedCallback();
this.startAnimation();
}
disconnectedCallback() {
super.disconnectedCallback();
this.stopAnimation();
}
private startAnimation() {
this.animationInterval = window.setInterval(() => {
this.frame = (this.frame + 1) % 12; // 12 frames for smooth animation
this.requestUpdate();
}, 200); // Change frame every 200ms
}
private stopAnimation() {
if (this.animationInterval) {
clearInterval(this.animationInterval);
this.animationInterval = null;
}
}
private getLogoFrame(): string {
const frames = [
'░░░▒▒▓▓█ VibeTunnel █▓▓▒▒░░░',
'░░▒▒▓▓█░ VibeTunnel ░█▓▓▒▒░░',
'░▒▒▓▓█░░ VibeTunnel ░░█▓▓▒▒░',
'▒▒▓▓█░░░ VibeTunnel ░░░█▓▓▒▒',
'▒▓▓█░░░░ VibeTunnel ░░░░█▓▓▒',
'▓▓█░░░░░ VibeTunnel ░░░░░█▓▓',
'▓█░░░░░░ VibeTunnel ░░░░░░█▓',
'█░░░░░░░ VibeTunnel ░░░░░░░█',
'░░░░░░░█ VibeTunnel █░░░░░░░',
'░░░░░░█▓ VibeTunnel ▓█░░░░░░',
'░░░░░█▓▓ VibeTunnel ▓▓█░░░░░',
'░░░░█▓▓▒ VibeTunnel ▒▓▓█░░░░',
];
return frames[this.frame];
}
private getRainbowColors() {
return [
'#ff0000',
'#ff4500',
'#ff8c00',
'#ffd700',
'#9acd32',
'#00ff00',
'#00ffff',
'#0080ff',
'#8000ff',
'#ff00ff',
'#ff1493',
'#ff69b4',
'#ffc0cb',
'#ffb6c1',
'#ffa0b4',
'#ff8fa3',
];
}
render() {
const frame = this.getLogoFrame();
const colors = this.getRainbowColors();
// Parse the frame to apply rainbow colors
const parts = frame.split(' VibeTunnel ');
const leftPart = parts[0];
const rightPart = parts[1];
const coloredLeft = leftPart
.split('')
.map((char, i) =>
char === ' ' ? ' ' : html`<span style="color: ${colors[i % colors.length]};">${char}</span>`
);
const coloredRight = rightPart
.split('')
.map((char, i) =>
char === ' '
? ' '
: html`<span style="color: ${colors[(leftPart.length - 1 - i) % colors.length]};"
>${char}</span
>`
);
return html`
<div class="font-mono text-sm select-none leading-tight text-center">
<pre
class="whitespace-pre"
>${coloredLeft} <span class="text-vs-user">VibeTunnel</span> ${coloredRight}</pre>
</div>
`;
}
}