Fix session card UI and file browser issues

- Update asciinema player CSS to use proper aspect ratios
- Fix file browser z-index and layout issues
- Remove old unused app.ts and server.ts files
- Keep working app-new components structure

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Mario Zechner 2025-06-16 03:59:18 +02:00
parent 0bee6f13aa
commit 7f63c9e168
15 changed files with 739 additions and 1034 deletions

View file

@ -0,0 +1,3 @@
// Entry point for the new app
import './app-new.js';
//# sourceMappingURL=app-new-entry.js.map

View file

@ -0,0 +1 @@
{"version":3,"file":"app-new-entry.js","sourceRoot":"","sources":["../src/client/app-new-entry.ts"],"names":[],"mappings":"AAAA,8BAA8B;AAC9B,OAAO,cAAc,CAAC","sourcesContent":["// Entry point for the new app\nimport './app-new.js';"]}

149
web/public/app-new.js Normal file
View file

@ -0,0 +1,149 @@
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
import { LitElement, html } from 'lit';
import { customElement, state } from 'lit/decorators.js';
// Import components
import './components/app-header.js';
import './components/session-create-form.js';
import './components/session-list.js';
let VibeTunnelAppNew = class VibeTunnelAppNew extends LitElement {
constructor() {
super(...arguments);
this.errorMessage = '';
this.sessions = [];
this.loading = false;
this.hotReloadWs = null;
}
// Disable shadow DOM to use Tailwind
createRenderRoot() {
return this;
}
connectedCallback() {
super.connectedCallback();
this.setupHotReload();
this.loadSessions();
this.startAutoRefresh();
}
disconnectedCallback() {
super.disconnectedCallback();
if (this.hotReloadWs) {
this.hotReloadWs.close();
}
}
showError(message) {
this.errorMessage = message;
// Clear error after 5 seconds
setTimeout(() => {
this.errorMessage = '';
}, 5000);
}
clearError() {
this.errorMessage = '';
}
async loadSessions() {
this.loading = true;
try {
const response = await fetch('/api/sessions');
if (response.ok) {
const sessionsData = await response.json();
this.sessions = sessionsData.map((session) => ({
id: session.id,
command: session.command,
workingDir: session.workingDir,
status: session.status,
exitCode: session.exitCode,
startedAt: session.startedAt,
lastModified: session.lastModified,
pid: session.pid
}));
this.clearError();
}
else {
this.showError('Failed to load sessions');
}
}
catch (error) {
console.error('Error loading sessions:', error);
this.showError('Failed to load sessions');
}
finally {
this.loading = false;
}
}
startAutoRefresh() {
// Refresh sessions every 3 seconds
setInterval(() => {
this.loadSessions();
}, 3000);
}
handleSessionCreated(e) {
console.log('Session created:', e.detail);
this.showError('Session created successfully!');
this.loadSessions(); // Refresh the list
}
handleSessionSelect(e) {
const session = e.detail;
console.log('Session selected:', session);
this.showError(`Terminal view not implemented yet for session: ${session.id}`);
}
handleSessionKilled(e) {
console.log('Session killed:', e.detail);
this.loadSessions(); // Refresh the list
}
handleRefresh() {
this.loadSessions();
}
handleError(e) {
this.showError(e.detail);
}
setupHotReload() {
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}?hotReload=true`;
this.hotReloadWs = new WebSocket(wsUrl);
this.hotReloadWs.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.type === 'reload') {
window.location.reload();
}
};
}
}
render() {
return html `
<div class="max-w-4xl mx-auto">
<app-header></app-header>
<session-create-form
@session-created=${this.handleSessionCreated}
@error=${this.handleError}
></session-create-form>
<session-list
.sessions=${this.sessions}
.loading=${this.loading}
@session-select=${this.handleSessionSelect}
@session-killed=${this.handleSessionKilled}
@refresh=${this.handleRefresh}
@error=${this.handleError}
></session-list>
</div>
`;
}
};
__decorate([
state()
], VibeTunnelAppNew.prototype, "errorMessage", void 0);
__decorate([
state()
], VibeTunnelAppNew.prototype, "sessions", void 0);
__decorate([
state()
], VibeTunnelAppNew.prototype, "loading", void 0);
VibeTunnelAppNew = __decorate([
customElement('vibetunnel-app-new')
], VibeTunnelAppNew);
export { VibeTunnelAppNew };
//# sourceMappingURL=app-new.js.map

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,25 @@
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
import { LitElement, html } from 'lit';
import { customElement } from 'lit/decorators.js';
let AppHeader = class AppHeader extends LitElement {
createRenderRoot() {
return this;
}
render() {
return html `
<div class="p-4">
<h1 class="text-vs-user font-mono text-sm m-0">VibeTunnel</h1>
</div>
`;
}
};
AppHeader = __decorate([
customElement('app-header')
], AppHeader);
export { AppHeader };
//# sourceMappingURL=app-header.js.map

View file

@ -0,0 +1 @@
{"version":3,"file":"app-header.js","sourceRoot":"","sources":["../../src/client/components/app-header.ts"],"names":[],"mappings":";;;;;;AAAA,OAAO,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,KAAK,CAAC;AACvC,OAAO,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAG3C,IAAM,SAAS,GAAf,MAAM,SAAU,SAAQ,UAAU;IACvC,gBAAgB;QACd,OAAO,IAAI,CAAC;IACd,CAAC;IAED,MAAM;QACJ,OAAO,IAAI,CAAA;;;;KAIV,CAAC;IACJ,CAAC;CACF,CAAA;AAZY,SAAS;IADrB,aAAa,CAAC,YAAY,CAAC;GACf,SAAS,CAYrB","sourcesContent":["import { LitElement, html } from 'lit';\nimport { customElement } from 'lit/decorators.js';\n\n@customElement('app-header')\nexport class AppHeader extends LitElement {\n createRenderRoot() {\n return this;\n }\n\n render() {\n return html`\n <div class=\"p-4\">\n <h1 class=\"text-vs-user font-mono text-sm m-0\">VibeTunnel</h1>\n </div>\n `;\n }\n}"]}

View file

@ -0,0 +1,148 @@
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
import { LitElement, html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
let FileBrowser = class FileBrowser extends LitElement {
constructor() {
super(...arguments);
this.currentPath = '~';
this.visible = false;
this.files = [];
this.loading = false;
}
// Disable shadow DOM to use Tailwind
createRenderRoot() {
return this;
}
async connectedCallback() {
super.connectedCallback();
if (this.visible) {
await this.loadDirectory(this.currentPath);
}
}
async updated(changedProperties) {
if (changedProperties.has('visible') && this.visible) {
await this.loadDirectory(this.currentPath);
}
}
async loadDirectory(dirPath) {
this.loading = true;
try {
const response = await fetch(`/api/fs/browse?path=${encodeURIComponent(dirPath)}`);
if (response.ok) {
const data = await response.json();
this.currentPath = data.absolutePath;
this.files = data.files;
}
else {
console.error('Failed to load directory');
}
}
catch (error) {
console.error('Error loading directory:', error);
}
finally {
this.loading = false;
}
}
handleDirectoryClick(dirName) {
const newPath = this.currentPath + '/' + dirName;
this.loadDirectory(newPath);
}
handleParentClick() {
const parentPath = this.currentPath.split('/').slice(0, -1).join('/') || '/';
this.loadDirectory(parentPath);
}
handleSelect() {
this.dispatchEvent(new CustomEvent('directory-selected', {
detail: this.currentPath
}));
}
handleCancel() {
this.dispatchEvent(new CustomEvent('browser-cancel'));
}
render() {
if (!this.visible) {
return html ``;
}
return html `
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center" 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">
<div class="p-4 border-b border-vs-border">
<div class="text-vs-assistant text-sm mb-2">Select Directory</div>
<div class="text-vs-muted text-sm break-all">${this.currentPath}</div>
</div>
<div class="p-4 h-64 overflow-y-auto">
${this.loading ? html `
<div class="text-vs-muted">Loading...</div>
` : html `
${this.currentPath !== '/' ? html `
<div
class="flex items-center gap-2 p-2 hover:bg-vs-nav-hover cursor-pointer text-vs-accent"
@click=${this.handleParentClick}
>
<span>📁</span>
<span>.. (parent directory)</span>
</div>
` : ''}
${this.files.filter(f => f.isDir).map(file => html `
<div
class="flex items-center gap-2 p-2 hover:bg-vs-nav-hover cursor-pointer text-vs-accent"
@click=${() => this.handleDirectoryClick(file.name)}
>
<span>📁</span>
<span>${file.name}</span>
</div>
`)}
${this.files.filter(f => !f.isDir).map(file => html `
<div class="flex items-center gap-2 p-2 text-vs-muted">
<span>📄</span>
<span>${file.name}</span>
</div>
`)}
`}
</div>
<div class="p-4 border-t border-vs-border 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"
@click=${this.handleCancel}
>
cancel
</button>
<button
class="bg-vs-user text-vs-bg hover:bg-vs-accent font-mono px-4 py-2 border-none"
@click=${this.handleSelect}
>
select
</button>
</div>
</div>
</div>
`;
}
};
__decorate([
property({ type: String })
], FileBrowser.prototype, "currentPath", void 0);
__decorate([
property({ type: Boolean })
], FileBrowser.prototype, "visible", void 0);
__decorate([
state()
], FileBrowser.prototype, "files", void 0);
__decorate([
state()
], FileBrowser.prototype, "loading", void 0);
FileBrowser = __decorate([
customElement('file-browser')
], FileBrowser);
export { FileBrowser };
//# sourceMappingURL=file-browser.js.map

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,193 @@
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
import { LitElement, html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import './file-browser.js';
let SessionCreateForm = class SessionCreateForm extends LitElement {
constructor() {
super(...arguments);
this.workingDir = '~/';
this.command = '';
this.disabled = false;
this.isCreating = false;
this.showFileBrowser = false;
}
// Disable shadow DOM to use Tailwind
createRenderRoot() {
return this;
}
handleWorkingDirChange(e) {
const input = e.target;
this.workingDir = input.value;
this.dispatchEvent(new CustomEvent('working-dir-change', {
detail: this.workingDir
}));
}
handleCommandChange(e) {
const input = e.target;
this.command = input.value;
}
handleBrowse() {
this.showFileBrowser = true;
}
handleDirectorySelected(e) {
this.workingDir = e.detail;
this.showFileBrowser = false;
}
handleBrowserCancel() {
this.showFileBrowser = false;
}
async handleCreate() {
if (!this.workingDir.trim() || !this.command.trim()) {
this.dispatchEvent(new CustomEvent('error', {
detail: 'Please fill in both working directory and command'
}));
return;
}
this.isCreating = true;
const sessionData = {
command: this.parseCommand(this.command.trim()),
workingDir: this.workingDir.trim()
};
try {
const response = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(sessionData)
});
if (response.ok) {
const result = await response.json();
this.command = ''; // Clear command on success
this.dispatchEvent(new CustomEvent('session-created', {
detail: result
}));
}
else {
const error = await response.json();
this.dispatchEvent(new CustomEvent('error', {
detail: `Failed to create session: ${error.error}`
}));
}
}
catch (error) {
console.error('Error creating session:', error);
this.dispatchEvent(new CustomEvent('error', {
detail: 'Failed to create session'
}));
}
finally {
this.isCreating = false;
}
}
parseCommand(commandStr) {
// Simple command parsing - split by spaces but respect quotes
const args = [];
let current = '';
let inQuotes = false;
let quoteChar = '';
for (let i = 0; i < commandStr.length; i++) {
const char = commandStr[i];
if ((char === '"' || char === "'") && !inQuotes) {
inQuotes = true;
quoteChar = char;
}
else if (char === quoteChar && inQuotes) {
inQuotes = false;
quoteChar = '';
}
else if (char === ' ' && !inQuotes) {
if (current) {
args.push(current);
current = '';
}
}
else {
current += char;
}
}
if (current) {
args.push(current);
}
return args;
}
render() {
return html `
<div class="border border-vs-accent font-mono text-sm p-4 m-4 rounded">
<div class="text-vs-assistant text-sm mb-4">Create New Session</div>
<div class="mb-4">
<div class="text-vs-muted mb-2">Working Directory:</div>
<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"
.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"
@click=${this.handleBrowse}
?disabled=${this.disabled || this.isCreating}
>
browse
</button>
</div>
</div>
<div class="mb-4">
<div class="text-vs-muted 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"
.value=${this.command}
@input=${this.handleCommandChange}
@keydown=${(e) => e.key === 'Enter' && this.handleCreate()}
placeholder="zsh"
?disabled=${this.disabled || this.isCreating}
/>
</div>
<button
class="bg-vs-user text-vs-text hover:bg-vs-accent font-mono px-4 py-2 border-none"
@click=${this.handleCreate}
?disabled=${this.disabled || this.isCreating || !this.workingDir.trim() || !this.command.trim()}
>
${this.isCreating ? 'creating...' : 'create'}
</button>
</div>
<file-browser
.visible=${this.showFileBrowser}
.currentPath=${this.workingDir}
@directory-selected=${this.handleDirectorySelected}
@browser-cancel=${this.handleBrowserCancel}
></file-browser>
`;
}
};
__decorate([
property({ type: String })
], SessionCreateForm.prototype, "workingDir", void 0);
__decorate([
property({ type: String })
], SessionCreateForm.prototype, "command", void 0);
__decorate([
property({ type: Boolean })
], SessionCreateForm.prototype, "disabled", void 0);
__decorate([
state()
], SessionCreateForm.prototype, "isCreating", void 0);
__decorate([
state()
], SessionCreateForm.prototype, "showFileBrowser", void 0);
SessionCreateForm = __decorate([
customElement('session-create-form')
], SessionCreateForm);
export { SessionCreateForm };
//# sourceMappingURL=session-create-form.js.map

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,211 @@
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
import { LitElement, html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
let SessionList = class SessionList extends LitElement {
constructor() {
super(...arguments);
this.sessions = [];
this.loading = false;
this.killingSessionIds = new Set();
this.loadedSnapshots = new Map();
this.loadingSnapshots = new Set();
}
// Disable shadow DOM to use Tailwind
createRenderRoot() {
return this;
}
handleRefresh() {
this.dispatchEvent(new CustomEvent('refresh'));
}
async loadSnapshot(sessionId) {
if (this.loadedSnapshots.has(sessionId) || this.loadingSnapshots.has(sessionId)) {
return;
}
this.loadingSnapshots.add(sessionId);
this.requestUpdate();
try {
// Just mark as loaded and create the player with the endpoint URL
this.loadedSnapshots.set(sessionId, sessionId);
this.requestUpdate();
// Create asciinema player after the element is rendered
setTimeout(() => this.createPlayer(sessionId), 10);
}
catch (error) {
console.error('Error loading snapshot:', error);
}
finally {
this.loadingSnapshots.delete(sessionId);
this.requestUpdate();
}
}
loadAllSnapshots() {
this.sessions.forEach(session => {
this.loadSnapshot(session.id);
});
}
updated(changedProperties) {
super.updated(changedProperties);
if (changedProperties.has('sessions')) {
// Auto-load all snapshots when sessions change
setTimeout(() => this.loadAllSnapshots(), 100);
}
}
createPlayer(sessionId) {
const playerElement = this.querySelector(`#player-${sessionId}`);
if (playerElement && window.AsciinemaPlayer) {
try {
const snapshotUrl = `/api/sessions/${sessionId}/snapshot`;
window.AsciinemaPlayer.create(snapshotUrl, playerElement, {
autoPlay: true,
loop: false,
controls: false,
fit: 'both',
terminalFontSize: '8px',
idleTimeLimit: 0.5,
preload: true,
poster: 'npt:999999'
});
}
catch (error) {
console.error('Error creating asciinema player:', error);
}
}
}
handleSessionClick(session) {
this.dispatchEvent(new CustomEvent('session-select', {
detail: session
}));
}
async handleKillSession(e, sessionId) {
e.stopPropagation(); // Prevent session selection
if (!confirm('Are you sure you want to kill this session?')) {
return;
}
this.killingSessionIds.add(sessionId);
this.requestUpdate();
try {
const response = await fetch(`/api/sessions/${sessionId}`, {
method: 'DELETE'
});
if (response.ok) {
this.dispatchEvent(new CustomEvent('session-killed', {
detail: { sessionId }
}));
// Refresh the list after a short delay
setTimeout(() => {
this.handleRefresh();
}, 1000);
}
else {
const error = await response.json();
this.dispatchEvent(new CustomEvent('error', {
detail: `Failed to kill session: ${error.error}`
}));
}
}
catch (error) {
console.error('Error killing session:', error);
this.dispatchEvent(new CustomEvent('error', {
detail: 'Failed to kill session'
}));
}
finally {
this.killingSessionIds.delete(sessionId);
this.requestUpdate();
}
}
formatTime(timestamp) {
try {
const date = new Date(timestamp);
return date.toLocaleTimeString();
}
catch {
return 'Unknown';
}
}
truncateId(id) {
return id.length > 8 ? `${id.substring(0, 8)}...` : id;
}
render() {
return html `
<div class="font-mono text-sm p-4">
${this.sessions.length === 0 ? html `
<div class="text-vs-muted text-center py-8">
${this.loading ? 'Loading sessions...' : 'No sessions found'}
</div>
` : html `
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
${this.sessions.map(session => html `
<div
class="bg-vs-bg border border-vs-border rounded shadow cursor-pointer overflow-hidden"
@click=${() => this.handleSessionClick(session)}
>
<!-- 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 truncate pr-2 flex-1">${session.command}</div>
<button
class="bg-vs-warning text-vs-bg hover:bg-vs-highlight font-mono px-1 py-0.5 border-none text-xs disabled:opacity-50 flex-shrink-0 rounded"
@click=${(e) => this.handleKillSession(e, session.id)}
?disabled=${this.killingSessionIds.has(session.id)}
>
${this.killingSessionIds.has(session.id) ? '...' : 'x'}
</button>
</div>
<!-- Asciinema player (main content) -->
<div class="session-preview">
${this.loadedSnapshots.has(session.id) ? html `
<div id="player-${session.id}" class="bg-black" style="height: 120px; overflow: hidden;"></div>
` : html `
<div
class="bg-black flex items-center justify-center text-vs-muted text-xs"
style="height: 120px;"
>
${this.loadingSnapshots.has(session.id) ? 'Loading...' : 'Loading...'}
</div>
`}
</div>
<!-- Compact Footer -->
<div class="px-3 py-2 text-vs-muted text-xs border-t border-vs-border">
<div class="flex justify-between items-center">
<span class="${session.status === 'running' ? 'text-vs-user' : 'text-vs-warning'} text-xs">
${session.status}
</span>
<span class="truncate">${this.truncateId(session.id)}</span>
</div>
<div class="truncate text-xs opacity-75" title="${session.workingDir}">${session.workingDir}</div>
</div>
</div>
`)}
</div>
`}
</div>
`;
}
};
__decorate([
property({ type: Array })
], SessionList.prototype, "sessions", void 0);
__decorate([
property({ type: Boolean })
], SessionList.prototype, "loading", void 0);
__decorate([
state()
], SessionList.prototype, "killingSessionIds", void 0);
__decorate([
state()
], SessionList.prototype, "loadedSnapshots", void 0);
__decorate([
state()
], SessionList.prototype, "loadingSnapshots", void 0);
SessionList = __decorate([
customElement('session-list')
], SessionList);
export { SessionList };
//# sourceMappingURL=session-list.js.map

File diff suppressed because one or more lines are too long

View file

@ -1,527 +0,0 @@
import { LitElement, html, TemplateResult } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { choose } from 'lit/directives/choose.js';
// Type definitions for asciinema-player
declare var AsciinemaPlayer: {
create(
cast: string | { driver: string; url: string; } | { data: any[]; version: number; width: number; height: number; timestamp: number; env: any },
element: HTMLElement,
options?: { theme?: string; loop?: boolean; autoPlay?: boolean; controls?: boolean; fit?: string; logger?: any }
): any;
};
interface ProcessMetadata {
processId: string;
command: string;
workingDir: string;
startDate: string;
lastModified: string;
exitCode?: number;
error?: string;
}
interface FileInfo {
name: string;
created: string;
lastModified: string;
size: number;
isDir: boolean;
}
interface DirectoryListing {
absolutePath: string;
files: FileInfo[];
}
type Route = 'processes' | 'terminal';
@customElement('vibetunnel-app')
export class VibeTunnelApp extends LitElement {
// Override createRenderRoot to disable shadow DOM and enable Tailwind
createRenderRoot() {
return this;
}
@state() private currentRoute: Route = 'processes';
@state() private currentProcess: ProcessMetadata | null = null;
@state() private processes: ProcessMetadata[] = [];
@state() private workingDir: string = '~/';
@state() private command: string = '';
@state() private showDirBrowser: boolean = false;
@state() private currentDirPath: string = '~/';
@state() private dirFiles: FileInfo[] = [];
@state() private keyboardCaptured: boolean = false;
private player: any = null;
private eventSource: EventSource | null = null;
private hotReloadWs: WebSocket | null = null;
private processRefreshInterval: number | null = null;
connectedCallback(): void {
super.connectedCallback();
this.setupHotReload();
this.handleRouting();
this.startProcessRefresh();
this.setupGlobalKeyCapture();
window.addEventListener('popstate', () => this.handleRouting());
}
disconnectedCallback(): void {
super.disconnectedCallback();
this.disconnectStreaming();
if (this.processRefreshInterval) {
clearInterval(this.processRefreshInterval);
}
if (this.hotReloadWs) {
this.hotReloadWs.close();
}
window.removeEventListener('popstate', () => this.handleRouting());
}
private handleRouting(): void {
const hash = window.location.hash;
if (hash.startsWith('#terminal/')) {
const processId = hash.substring(10);
this.navigateToTerminal(processId);
} else {
this.navigateToProcesses();
}
}
private navigateToProcesses(): void {
this.currentRoute = 'processes';
this.currentProcess = null; // Clear current process when leaving terminal
this.disconnectStreaming();
this.keyboardCaptured = false;
this.loadProcesses();
window.history.pushState({}, '', '#');
}
private async navigateToTerminal(processId: string): Promise<void> {
this.currentRoute = 'terminal';
if (this.processes.length === 0) {
await this.loadProcesses();
}
const process = this.processes.find(p => p.processId === processId);
if (process) {
this.selectProcess(process);
} else {
console.error(`Process ${processId} not found`);
this.navigateToProcesses();
return;
}
window.history.pushState({}, '', `#terminal/${processId}`);
}
private async loadProcesses(): Promise<void> {
try {
const response = await fetch('/api/sessions');
const sessions = await response.json();
this.processes = sessions.map((session: any) => ({
processId: session.id,
command: session.metadata?.cmdline?.join(' ') || 'Unknown',
workingDir: session.metadata?.cwd || 'Unknown',
startDate: new Date().toISOString(),
lastModified: session.lastModified || new Date().toISOString(),
exitCode: session.status === 'running' ? undefined : 1,
error: session.status !== 'running' ? 'Not running' : undefined
}));
} catch (error) {
console.error('Failed to load processes:', error);
}
}
private selectProcess(process: ProcessMetadata): void {
this.disconnectStreaming();
this.currentProcess = process;
this.keyboardCaptured = true;
// Create asciinema player with SSE
this.requestUpdate();
this.updateComplete.then(() => {
this.createPlayerWithSSE(process.processId);
});
}
private createPlayerWithSSE(processId: string): void {
const playerEl = this.querySelector('.terminal-player') as HTMLElement;
if (!playerEl) return;
const sseUrl = `/api/stream/${processId}`;
try {
playerEl.innerHTML = '';
this.player = AsciinemaPlayer.create({
driver: 'eventsource',
url: sseUrl
}, playerEl, {
theme: 'asciinema',
autoPlay: true,
controls: false,
fit: 'both'
});
} catch (error) {
console.error('Error creating asciinema player:', error);
playerEl.innerHTML = '<div style="text-align: center; padding: 2em; color: #ff5555;">Error loading terminal</div>';
}
}
private disconnectStreaming(): void {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
if (this.player) {
this.player = null;
}
}
private setupGlobalKeyCapture(): void {
document.addEventListener('keydown', (e: KeyboardEvent) => {
if (this.currentRoute !== 'terminal' || !this.currentProcess || !this.keyboardCaptured) {
return;
}
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
return;
}
const terminalKey = this.convertKeyToTerminalInput(e);
if (terminalKey) {
this.sendKeyToTerminal(terminalKey);
e.preventDefault();
e.stopPropagation();
}
});
}
private convertKeyToTerminalInput(e: KeyboardEvent): { type: 'key' | 'text', value: string } | null {
const ttyFwdKeys: { [key: string]: string } = {
'ArrowUp': 'arrow_up', 'ArrowDown': 'arrow_down',
'ArrowLeft': 'arrow_left', 'ArrowRight': 'arrow_right',
'Escape': 'escape', 'Enter': 'enter'
};
const specialKeys: { [key: string]: string } = {
'Backspace': '\x7f', 'Tab': '\t',
'Home': '\x1b[H', 'End': '\x1b[F',
'PageUp': '\x1b[5~', 'PageDown': '\x1b[6~',
'Insert': '\x1b[2~', 'Delete': '\x1b[3~'
};
if (ttyFwdKeys[e.key]) {
return { type: 'key', value: ttyFwdKeys[e.key] };
}
if (e.ctrlKey && e.key.length === 1) {
const code = e.key.toLowerCase().charCodeAt(0);
if (code >= 97 && code <= 122) {
return { type: 'text', value: String.fromCharCode(code - 96) };
}
}
if (e.altKey && e.key.length === 1) {
return { type: 'text', value: '\x1b' + e.key };
}
if (specialKeys[e.key]) {
return { type: 'text', value: specialKeys[e.key] };
}
if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey) {
return { type: 'text', value: e.key };
}
return null;
}
private async sendKeyToTerminal(keyData: { type: 'key' | 'text', value: string }): Promise<void> {
// Only send input if we're actually in terminal mode with a valid process
if (this.currentRoute !== 'terminal' || !this.currentProcess || !this.keyboardCaptured) {
return;
}
try {
// Send as text to match old working version
const response = await fetch(`/api/input/${this.currentProcess.processId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: keyData.value })
});
if (!response.ok) {
console.error('Failed to send key to terminal:', response.status, response.statusText);
}
} catch (error) {
console.error('Error sending key to terminal:', error);
}
}
private async createProcess(): Promise<void> {
if (!this.workingDir.trim() || !this.command.trim()) {
alert('Please fill in both working directory and command');
return;
}
try {
const response = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workingDir: this.workingDir,
command: [this.command]
})
});
if (response.ok) {
this.command = '';
await this.loadProcesses();
} else {
const error = await response.json();
alert(`Failed to create process: ${error.error}`);
}
} catch (error) {
console.error('Error creating process:', error);
alert('Failed to create process');
}
}
private async killSession(processId: string): Promise<void> {
if (!confirm('Are you sure you want to kill this session?')) return;
try {
const response = await fetch(`/api/sessions/${processId}`, { method: 'DELETE' });
if (response.ok) {
if (this.currentProcess && this.currentProcess.processId === processId) {
this.navigateToProcesses();
}
// Wait a moment for server cleanup to complete, then refresh
setTimeout(async () => {
await this.loadProcesses();
}, 1500);
// Also refresh immediately
await this.loadProcesses();
} else {
const error = await response.json();
alert(`Failed to kill session: ${error.error}`);
}
} catch (error) {
console.error('Error killing session:', error);
alert('Failed to kill session');
}
}
private async openDirectoryBrowser(): Promise<void> {
this.showDirBrowser = true;
await this.loadDirectoryContents(this.currentDirPath);
}
private async loadDirectoryContents(dirPath: string): Promise<void> {
try {
const response = await fetch(`/api/ls?dir=${encodeURIComponent(dirPath)}`);
const data: DirectoryListing = await response.json();
this.currentDirPath = data.absolutePath;
this.dirFiles = data.files;
} catch (error) {
console.error('Error loading directory:', error);
}
}
private selectDirectory(): void {
this.workingDir = this.currentDirPath;
this.showDirBrowser = false;
}
private startProcessRefresh(): void {
this.processRefreshInterval = window.setInterval(() => {
if (this.currentRoute === 'processes') {
this.loadProcesses();
}
}, 2000); // Refresh every 2 seconds instead of 5
}
private setupHotReload(): void {
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}?hotReload=true`;
this.hotReloadWs = new WebSocket(wsUrl);
this.hotReloadWs.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.type === 'reload') {
window.location.reload();
}
};
}
}
render(): TemplateResult {
return html`
<div style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: #1e1e1e; color: #cccccc; overflow: hidden;">
${choose(this.currentRoute, [
['processes', () => this.renderProcessList()],
['terminal', () => this.renderTerminal()]
])}
${this.showDirBrowser ? this.renderDirectoryBrowser() : ''}
</div>
`;
}
private renderProcessList(): TemplateResult {
return html`
<div style="padding: 1em; max-width: 100%; overflow-x: auto;">
<!-- Header -->
<div style="margin-bottom: 1.5em; text-align: center;">
<div style="color: #569cd6; font-size: 1.2em; font-weight: bold;">VibeTunnel</div>
<div style="color: #6a9955; font-size: 0.9em;">Terminal Multiplexer</div>
</div>
<!-- Create Process Form -->
<div style="border: 1px solid #3c3c3c; padding: 1em; margin-bottom: 1em; background: #252526;">
<div style="color: #4ec9b0; margin-bottom: 1em;">Create New Process</div>
<div style="margin-bottom: 1em;">
<div style="margin-bottom: 1em;">Working Directory:</div>
<div style="display: flex; flex-wrap: wrap; gap: 1em; align-items: stretch;">
<input style="flex: 1; background: #3c3c3c; color: #cccccc; border: 1px solid #464647; padding: 0.5em; height: 2em; outline: none; font-size: 1em;"
.value=${this.workingDir}
@input=${(e: Event) => this.workingDir = (e.target as HTMLInputElement).value}
placeholder="~/projects/my-app">
<button style="min-width: 5em; height: 3em; border: 1px solid #6272a4; background: #44475a; color: #cccccc; cursor: pointer; padding: 0.5em; font-size: 1em;"
@click=${this.openDirectoryBrowser}>Browse</button>
</div>
</div>
<div style="margin-bottom: 1em;">
<div style="margin-bottom: 1em;">Command:</div>
<input style="width: 100%; max-width: 40em; background: #3c3c3c; color: #cccccc; border: 1px solid #464647; padding: 0.5em; height: 2em; outline: none; font-size: 1em;"
.value=${this.command}
@input=${(e: Event) => this.command = (e.target as HTMLInputElement).value}
placeholder="bash">
</div>
<button style="min-width: 6em; height: 3em; background: #0e639c; color: #ffffff; border: 1px solid #6272a4; cursor: pointer; padding: 0.5em; font-size: 1em; font-weight: bold;"
@click=${this.createProcess}>Create</button>
</div>
<!-- Process List -->
<div style="border: 1px solid #6272a4; padding: 1em;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1em;">
<span style="color: #8be9fd;">Active Processes</span>
<button style="min-width: 6em; height: 3em; border: 1px solid #6272a4; background: #44475a; color: #cccccc; cursor: pointer; padding: 0.5em; font-size: 1em;"
@click=${this.loadProcesses}>Refresh</button>
</div>
${this.processes.length === 0 ?
html`<div style="color: #6a9955;">No processes found</div>` :
this.processes.map(process => html`
<div style="border: 1px solid #3c3c3c; padding: 1.5em; margin-bottom: 1em; cursor: pointer; min-height: 4em; background: #252526; transition: background-color 0.2s;"
@click=${() => this.navigateToTerminal(process.processId)}
@mouseover=${(e: Event) => (e.currentTarget as HTMLElement).style.background = '#2d2d30'}
@mouseout=${(e: Event) => (e.currentTarget as HTMLElement).style.background = '#252526'}>
<div style="display: flex; justify-content: space-between; align-items: center; width: 100%;">
<div style="flex: 1;">
<div style="color: #cccccc;">${process.command}</div>
<div style="color: ${process.exitCode === undefined ? '#4ec9b0' : '#f14c4c'};">
${process.exitCode === undefined ? 'running' : 'stopped'}
</div>
<div style="color: #6272a4;">ID: ${process.processId}</div>
<div style="color: #6272a4;">Dir: ${process.workingDir}</div>
</div>
<button style="min-width: 4em; height: 3em; background: #f14c4c; color: #ffffff; border: 1px solid #6272a4; cursor: pointer; padding: 0.5em; font-size: 1em; font-weight: bold;"
@click=${(e: Event) => { e.stopPropagation(); this.killSession(process.processId); }}>
Kill
</button>
</div>
</div>
`)
}
</div>
</div>
`;
}
private renderTerminal(): TemplateResult {
return html`
<div style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; display: flex; flex-direction: column;">
<!-- Header -->
<div style="padding: 1em; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #3c3c3c; background: #2d2d30;">
<button style="min-width: 5em; height: 3em; border: 1px solid #6272a4; background: #44475a; color: #cccccc; cursor: pointer; padding: 0.5em; font-size: 1em;"
@click=${this.navigateToProcesses}> Back</button>
<span style="color: #dcdcaa;">${this.currentProcess?.command} (${this.currentProcess?.processId})</span>
<button style="min-width: 4em; height: 3em; background: #f14c4c; color: #ffffff; border: 1px solid #6272a4; cursor: pointer; padding: 0.5em; font-size: 1em; font-weight: bold;"
@click=${() => this.currentProcess && this.killSession(this.currentProcess.processId)}>
Kill
</button>
</div>
<!-- Terminal Player -->
<div class="terminal-player" style="flex: 1; background: #1e1e1e; overflow: hidden;"></div>
</div>
`;
}
private renderDirectoryBrowser(): TemplateResult {
return html`
<div style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.8); display: flex; align-items: center; justify-content: center; z-index: 1000;"
@click=${(e: Event) => e.target === e.currentTarget && (this.showDirBrowser = false)}>
<div style="background: #252526; border: 1px solid #3c3c3c; width: 90vw; max-width: 60em; height: 80vh; max-height: 30em; margin: 1em; display: flex; flex-direction: column;">
<div style="padding: 1em; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #3c3c3c;">
<span style="color: #4ec9b0;">Browse Directory</span>
<button style="min-width: 3em; height: 3em; border: 1px solid #6272a4; background: #44475a; color: #cccccc; cursor: pointer; padding: 0.5em; font-size: 1em;"
@click=${() => this.showDirBrowser = false}></button>
</div>
<div style="padding: 1em; border-bottom: 1px solid #3c3c3c;">
<span style="color: #6a9955;">Current: </span>
<span style="color: #4ec9b0;">${this.currentDirPath}</span>
</div>
<div style="flex: 1; overflow: auto; padding: 1em; min-height: 0;">
${this.currentDirPath !== '/' ? html`
<div style="cursor: pointer; color: #569cd6; padding: 0; margin-bottom: 1em;"
@click=${() => this.loadDirectoryContents(this.currentDirPath.split('/').slice(0, -1).join('/') || '/')}
@mouseover=${(e: Event) => (e.target as HTMLElement).style.background = '#2d2d30'}
@mouseout=${(e: Event) => (e.target as HTMLElement).style.background = '#252526'}>
.. (parent directory)
</div>
` : ''}
${this.dirFiles.filter(f => f.isDir).map(file => html`
<div style="cursor: pointer; padding: 0; margin-bottom: 1em; display: flex;"
@click=${() => this.loadDirectoryContents(this.currentDirPath + '/' + file.name)}
@mouseover=${(e: Event) => (e.target as HTMLElement).style.background = '#2d2d30'}
@mouseout=${(e: Event) => (e.target as HTMLElement).style.background = '#252526'}>
<span style="color: #569cd6; width: 2em;">📁</span>
<span>${file.name}</span>
</div>
`)}
${this.dirFiles.filter(f => !f.isDir).map(file => html`
<div style="padding: 0; margin-bottom: 1em; color: #6a9955; display: flex;">
<span style="width: 2em;">📄</span>
<span>${file.name}</span>
</div>
`)}
</div>
<div style="padding: 1em; display: flex; gap: 1em; border-top: 1px solid #3c3c3c;">
<button style="min-width: 6em; height: 3em; border: 1px solid #6272a4; background: #44475a; color: #cccccc; cursor: pointer; padding: 0.5em; font-size: 1em;"
@click=${() => this.showDirBrowser = false}>Cancel</button>
<button style="min-width: 6em; height: 3em; background: #0e639c; color: #ffffff; border: 1px solid #6272a4; cursor: pointer; padding: 0.5em; font-size: 1em; font-weight: bold;"
@click=${this.selectDirectory}>Select</button>
</div>
</div>
</div>
`;
}
}

View file

@ -85,13 +85,13 @@ export class FileBrowser extends LitElement {
return html`
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center" style="z-index: 9999;">
<div class="bg-vs-bg-secondary border border-vs-border font-mono text-sm w-96 h-96">
<div class="p-4 border-b border-vs-border">
<div class="bg-vs-bg-secondary border border-vs-border font-mono text-sm w-96 h-96 flex flex-col">
<div class="p-4 border-b border-vs-border flex-shrink-0">
<div class="text-vs-assistant text-sm mb-2">Select Directory</div>
<div class="text-vs-muted text-sm break-all">${this.currentPath}</div>
</div>
<div class="p-4 h-64 overflow-y-auto">
<div class="p-4 flex-1 overflow-y-auto">
${this.loading ? html`
<div class="text-vs-muted">Loading...</div>
` : html`
@ -124,7 +124,7 @@ export class FileBrowser extends LitElement {
`}
</div>
<div class="p-4 border-t border-vs-border flex gap-4 justify-end">
<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"
@click=${this.handleCancel}

View file

@ -1,503 +0,0 @@
import express from 'express';
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
import path from 'path';
import fs from 'fs';
import os from 'os';
import { spawn } from 'child_process';
import * as pty from 'node-pty';
const app = express();
const server = createServer(app);
const wss = new WebSocketServer({ server });
const PORT = process.env.PORT || 3000;
// tty-fwd binary path - check multiple possible locations
const possibleTtyFwdPaths = [
path.resolve(__dirname, '..', '..', 'tty-fwd', 'target', 'release', 'tty-fwd'),
path.resolve(__dirname, '..', '..', '..', 'tty-fwd', 'target', 'release', 'tty-fwd'),
'tty-fwd' // System PATH
];
let TTY_FWD_PATH = '';
for (const pathToCheck of possibleTtyFwdPaths) {
if (fs.existsSync(pathToCheck)) {
TTY_FWD_PATH = pathToCheck;
break;
}
}
if (!TTY_FWD_PATH) {
// Try to find in PATH
try {
const result = spawn('which', ['tty-fwd'], { stdio: 'pipe' });
result.stdout.on('data', (data) => {
TTY_FWD_PATH = data.toString().trim();
});
} catch (e) {
console.error('tty-fwd binary not found. Please ensure it is built and available.');
process.exit(1);
}
}
const TTY_FWD_CONTROL_DIR = path.join(os.homedir(), '.vibetunnel');
// Ensure control directory exists
if (!fs.existsSync(TTY_FWD_CONTROL_DIR)) {
fs.mkdirSync(TTY_FWD_CONTROL_DIR, { recursive: true });
console.log(`Created control directory: ${TTY_FWD_CONTROL_DIR}`);
}
console.log(`Using tty-fwd at: ${TTY_FWD_PATH}`);
console.log(`Control directory: ${TTY_FWD_CONTROL_DIR}`);
// Parse JSON bodies
app.use(express.json());
// Hot reload functionality for development
const hotReloadClients = new Set<any>();
// Serve static files
app.use(express.static(path.join(__dirname, '..', 'public')));
// Thin wrapper: Get sessions by calling tty-fwd --list-sessions
app.get('/api/sessions', async (req, res) => {
try {
const child = spawn(TTY_FWD_PATH, ['--control-path', TTY_FWD_CONTROL_DIR, '--list-sessions']);
let output = '';
child.stdout.on('data', (data) => {
output += data.toString();
});
child.on('close', (code) => {
if (code === 0) {
try {
const sessions = JSON.parse(output || '{}');
const sessionData = [];
for (const [sessionId, sessionInfo] of Object.entries(sessions as Record<string, any>)) {
const sessionPath = path.join(TTY_FWD_CONTROL_DIR, sessionId, 'session.json');
const streamOutPath = path.join(TTY_FWD_CONTROL_DIR, sessionId, 'stream-out');
let metadata = null;
let lastModified = new Date().toISOString();
if (fs.existsSync(sessionPath)) {
metadata = JSON.parse(fs.readFileSync(sessionPath, 'utf8'));
}
if (fs.existsSync(streamOutPath)) {
const stats = fs.statSync(streamOutPath);
lastModified = stats.mtime.toISOString();
}
sessionData.push({
id: sessionId,
status: sessionInfo.status,
metadata: metadata,
lastModified: lastModified
});
}
sessionData.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
res.json(sessionData);
} catch (error) {
res.json([]);
}
} else {
res.status(500).json({ error: 'Failed to list sessions' });
}
});
child.on('error', (error) => {
res.status(500).json({ error: 'Failed to execute tty-fwd' });
});
} catch (error) {
res.status(500).json({ error: 'Internal server error' });
}
});
// Thin wrapper: Create session by calling tty-fwd
app.post('/api/sessions', async (req, res) => {
try {
const { command, workingDir } = req.body;
if (!command || !Array.isArray(command)) {
return res.status(400).json({ error: 'Command array is required' });
}
const sessionName = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const cwd = workingDir ? (workingDir.startsWith('~') ?
path.join(os.homedir(), workingDir.slice(2)) :
path.resolve(workingDir)) : process.cwd();
const fullCommand = command.join(' ');
const commandLine = `${TTY_FWD_PATH} --control-path "${TTY_FWD_CONTROL_DIR}" --session-name "${sessionName}" -- ${fullCommand}`;
console.log(`Creating session: ${commandLine}`);
const ptyProcess = pty.spawn('bash', ['-c', commandLine], {
name: 'xterm-color',
cols: 80,
rows: 24,
cwd: cwd,
env: process.env
});
// Detach after short delay
setTimeout(() => {
ptyProcess.kill();
}, 1000);
setTimeout(() => {
res.json({ sessionName });
}, 500);
} catch (error) {
console.error('Error creating session:', error);
res.status(500).json({ error: 'Failed to create session' });
}
});
// Server-Sent Events for streaming - preserving the existing streaming functionality
app.get('/api/stream/:sessionId', (req, res) => {
const sessionId = req.params.sessionId;
const streamOutPath = path.join(TTY_FWD_CONTROL_DIR, sessionId, 'stream-out');
if (!fs.existsSync(streamOutPath)) {
return res.status(404).json({ error: 'Session not found' });
}
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Cache-Control'
});
const startTime = Date.now() / 1000;
let headerSent = false;
// Send existing content
try {
const content = fs.readFileSync(streamOutPath, 'utf8');
const lines = content.trim().split('\n');
if (lines.length > 0) {
try {
const header = JSON.parse(lines[0]);
if (header.version && header.width && header.height) {
res.write(`data: ${lines[0]}\n\n`);
headerSent = true;
// Send existing events with instant timestamp
for (let i = 1; i < lines.length; i++) {
if (lines[i].trim()) {
try {
const event = JSON.parse(lines[i]);
if (Array.isArray(event) && event.length >= 3) {
const instantEvent = [0, event[1], event[2]];
res.write(`data: ${JSON.stringify(instantEvent)}\n\n`);
}
} catch (e) {
// Skip invalid lines
}
}
}
}
} catch (e) {
// Skip invalid header
}
}
} catch (error) {
console.error('Error reading existing content:', error);
}
// Send default header if none found
if (!headerSent) {
const defaultHeader = {
version: 2,
width: 80,
height: 24,
timestamp: Math.floor(startTime),
env: { TERM: "xterm-256color" }
};
res.write(`data: ${JSON.stringify(defaultHeader)}\n\n`);
headerSent = true;
}
// Use tail -f to follow new content
const tailProcess = spawn('tail', ['-f', streamOutPath], {
stdio: ['ignore', 'pipe', 'pipe']
});
let buffer = '';
tailProcess.stdout.on('data', (chunk) => {
buffer += chunk.toString();
const lines = buffer.split('\n');
buffer = lines.pop() || '';
lines.forEach(line => {
if (line.trim()) {
try {
const parsed = JSON.parse(line);
if (parsed.version && parsed.width && parsed.height) {
return; // Skip duplicate headers
}
if (Array.isArray(parsed) && parsed.length >= 3) {
const currentTime = Date.now() / 1000;
const relativeTime = currentTime - startTime;
const realTimeEvent = [relativeTime, parsed[1], parsed[2]];
res.write(`data: ${JSON.stringify(realTimeEvent)}\n\n`);
}
} catch (e) {
// Handle non-JSON lines as raw output
const currentTime = Date.now() / 1000;
const relativeTime = currentTime - startTime;
const castEvent = [relativeTime, "o", line];
res.write(`data: ${JSON.stringify(castEvent)}\n\n`);
}
}
});
});
// Cleanup on disconnect
req.on('close', () => {
tailProcess.kill('SIGTERM');
});
req.on('aborted', () => {
tailProcess.kill('SIGTERM');
});
});
// Input endpoint for sending text/keys to sessions (like old working version)
app.post('/api/input/:sessionId', (req, res) => {
const sessionId = req.params.sessionId;
const { type, value, text } = req.body;
const inputValue = value || text;
if (inputValue === undefined || inputValue === null) {
return res.status(400).json({ error: 'Input value is required' });
}
console.log(`Sending input to session ${sessionId}:`, JSON.stringify(inputValue));
// Write directly to stdin file like the old working version
const stdinPath = path.join(TTY_FWD_CONTROL_DIR, sessionId, 'stdin');
if (!fs.existsSync(stdinPath)) {
return res.status(404).json({ error: 'Session stdin not found' });
}
try {
// Write the text directly to the stdin named pipe
fs.writeFileSync(stdinPath, inputValue, { encoding: 'binary' });
console.log(`Successfully wrote ${inputValue.length} bytes to stdin for session ${sessionId}`);
res.json({ success: true });
} catch (error) {
console.error('Error writing to stdin:', error);
// Fallback: try using tty-fwd commands based on input type
console.log('Falling back to tty-fwd commands');
// Check if this is a special key that should use --send-key
const specialKeys = ['arrow_up', 'arrow_down', 'arrow_left', 'arrow_right', 'escape', 'enter'];
const isSpecialKey = specialKeys.includes(inputValue);
let sendCommand;
if (isSpecialKey) {
// Use --send-key for special keys
sendCommand = `${TTY_FWD_PATH} --control-path "${TTY_FWD_CONTROL_DIR}" --session "${sessionId}" --send-key "${inputValue}"`;
console.log(`Using --send-key for: ${inputValue}`);
} else if (typeof inputValue === 'string' && inputValue.length <= 10 && inputValue.charCodeAt(0) < 127) {
// Use --send-text for regular text
const escapedText = inputValue.replace(/'/g, "'\"'\"'");
sendCommand = `${TTY_FWD_PATH} --control-path "${TTY_FWD_CONTROL_DIR}" --session "${sessionId}" --send-text '${escapedText}'`;
console.log(`Using --send-text for: ${inputValue}`);
} else {
console.error('Input value not supported by tty-fwd fallback:', inputValue);
return res.status(500).json({ error: 'Input type not supported' });
}
const inputChild = spawn('bash', ['-c', sendCommand], {
stdio: 'ignore'
});
inputChild.on('error', (error) => {
console.error('Error sending input via tty-fwd:', error);
return res.status(500).json({ error: 'Failed to send input' });
});
inputChild.on('close', (code) => {
if (code === 0) {
console.log(`Successfully sent input via tty-fwd: ${inputValue}`);
res.json({ success: true });
} else {
console.error(`tty-fwd failed with code: ${code}`);
res.status(500).json({ error: 'Failed to send input' });
}
});
}
});
// Thin wrapper: Kill session by calling tty-fwd
app.delete('/api/sessions/:sessionId', (req, res) => {
const sessionId = req.params.sessionId;
// Get session info first
const sessionsChild = spawn(TTY_FWD_PATH, ['--control-path', TTY_FWD_CONTROL_DIR, '--list-sessions']);
let output = '';
sessionsChild.stdout.on('data', (data) => {
output += data.toString();
});
sessionsChild.on('close', (code) => {
if (code === 0) {
try {
const sessions = JSON.parse(output || '{}');
const session = sessions[sessionId];
if (session && session.pid) {
try {
process.kill(session.pid, 'SIGTERM');
setTimeout(() => {
try {
process.kill(session.pid, 0);
process.kill(session.pid, 'SIGKILL');
} catch (e) {
// Process already dead
}
}, 1000);
} catch (error) {
// Process already dead
}
}
// Cleanup using tty-fwd
setTimeout(() => {
const cleanupChild = spawn(TTY_FWD_PATH, [
'--control-path', TTY_FWD_CONTROL_DIR,
'--session', sessionId,
'--cleanup'
]);
cleanupChild.on('close', (cleanupCode) => {
if (cleanupCode !== 0) {
// Force remove if cleanup failed
const sessionDir = path.join(TTY_FWD_CONTROL_DIR, sessionId);
try {
if (fs.existsSync(sessionDir)) {
fs.rmSync(sessionDir, { recursive: true, force: true });
}
} catch (error) {
console.error('Error force removing session directory:', error);
}
}
});
}, 1000);
res.json({ success: true, message: 'Session killed' });
} catch (error) {
console.error('Error parsing sessions:', error);
res.status(500).json({ error: 'Failed to get session info' });
}
} else {
res.status(500).json({ error: 'Failed to list sessions' });
}
});
});
// Directory listing endpoint (needed by frontend)
app.get('/api/ls', (req, res) => {
const dirPath = req.query.dir as string || '~';
try {
const expandedPath = dirPath.startsWith('~') ?
path.join(os.homedir(), dirPath.slice(1)) :
path.resolve(dirPath);
if (!fs.existsSync(expandedPath)) {
return res.status(404).json({ error: 'Directory not found' });
}
const stats = fs.statSync(expandedPath);
if (!stats.isDirectory()) {
return res.status(400).json({ error: 'Path is not a directory' });
}
const files = fs.readdirSync(expandedPath).map(name => {
const filePath = path.join(expandedPath, name);
const fileStats = fs.statSync(filePath);
return {
name,
created: fileStats.birthtime.toISOString(),
lastModified: fileStats.mtime.toISOString(),
size: fileStats.size,
isDir: fileStats.isDirectory()
};
});
res.json({
absolutePath: expandedPath,
files: files.sort((a, b) => {
// Directories first, then files
if (a.isDir && !b.isDir) return -1;
if (!a.isDir && b.isDir) return 1;
return a.name.localeCompare(b.name);
})
});
} catch (error) {
console.error('Error listing directory:', error);
res.status(500).json({ error: 'Failed to list directory' });
}
});
// WebSocket for hot reload
wss.on('connection', (ws, req) => {
const url = new URL(req.url!, `http://${req.headers.host}`);
const isHotReload = url.searchParams.get('hotReload') === 'true';
if (isHotReload) {
hotReloadClients.add(ws);
ws.on('close', () => {
hotReloadClients.delete(ws);
});
return;
}
ws.close(1008, 'Only hot reload connections supported');
});
// Hot reload file watching in development
if (process.env.NODE_ENV !== 'production') {
const chokidar = require('chokidar');
const watcher = chokidar.watch(['public/**/*', 'src/**/*'], {
ignored: /node_modules/,
persistent: true
});
watcher.on('change', (path: string) => {
console.log(`File changed: ${path}`);
hotReloadClients.forEach((ws: any) => {
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify({ type: 'reload' }));
}
});
});
}
server.listen(PORT, () => {
console.log(`VibeTunnel server running on http://localhost:${PORT}`);
console.log(`Using tty-fwd: ${TTY_FWD_PATH}`);
});