This commit is contained in:
Peter Steinberger 2025-06-17 01:03:37 +02:00
parent 12cef6f5c8
commit bb20c3a833
17 changed files with 2730 additions and 1184 deletions

8
web/.prettierignore Normal file
View file

@ -0,0 +1,8 @@
dist/
public/
node_modules/
coverage/
.next/
build/
*.min.js
*.min.css

11
web/.prettierrc.json Normal file
View file

@ -0,0 +1,11 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "lf"
}

41
web/eslint.config.js Normal file
View file

@ -0,0 +1,41 @@
const eslint = require('@eslint/js');
const tseslint = require('typescript-eslint');
const prettierConfig = require('eslint-config-prettier');
const prettierPlugin = require('eslint-plugin-prettier');
module.exports = tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommended,
prettierConfig,
{
ignores: ['dist/**', 'public/**', 'node_modules/**', '*.js', '!eslint.config.js'],
},
{
files: ['src/**/*.ts', 'src/**/*.tsx'],
languageOptions: {
parserOptions: {
project: ['./tsconfig.json', './tsconfig.client.json'],
tsconfigRootDir: __dirname,
},
},
plugins: {
prettier: prettierPlugin,
},
rules: {
'prettier/prettier': 'error',
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-non-null-assertion': 'warn',
'@typescript-eslint/no-require-imports': 'off',
'no-empty': ['error', { allowEmptyCatch: true }],
},
}
);

1318
web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -18,7 +18,11 @@
"bundle:watch": "concurrently \"npm run bundle:client -- --watch\" \"npm run bundle:renderer -- --watch\"",
"start": "node dist/server.js",
"test": "jest",
"test:watch": "jest --watch"
"test:watch": "jest --watch",
"lint": "eslint 'src/**/*.{ts,tsx}'",
"lint:fix": "eslint 'src/**/*.{ts,tsx}' --fix",
"format": "prettier --write 'src/**/*.{ts,tsx,js,jsx,json,css,md}'",
"format:check": "prettier --check 'src/**/*.{ts,tsx,js,jsx,json,css,md}'"
},
"dependencies": {
"@xterm/addon-fit": "^0.10.0",
@ -31,23 +35,31 @@
"ws": "^8.14.2"
},
"devDependencies": {
"@eslint/js": "^9.29.0",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.12",
"@types/node": "^20.10.5",
"@types/supertest": "^6.0.2",
"@types/ws": "^8.5.10",
"@typescript-eslint/eslint-plugin": "^8.34.1",
"@typescript-eslint/parser": "^8.34.1",
"autoprefixer": "^10.4.21",
"chokidar": "^3.5.3",
"concurrently": "^8.2.2",
"esbuild": "^0.25.5",
"eslint": "^9.29.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-prettier": "^5.4.1",
"jest": "^29.7.0",
"postcss": "^8.5.5",
"prettier": "^3.5.3",
"puppeteer": "^21.0.0",
"supertest": "^6.3.4",
"tailwindcss": "^3.4.17",
"ts-jest": "^29.1.2",
"tsx": "^4.6.2",
"typescript": "^5.3.3"
"typescript": "^5.3.3",
"typescript-eslint": "^8.34.1"
},
"keywords": [
"terminal",

View file

@ -71,7 +71,7 @@ export class VibeTunnelApp extends LitElement {
exitCode: session.exitCode,
startedAt: session.startedAt,
lastModified: session.lastModified,
pid: session.pid
pid: session.pid,
}));
this.clearError();
} else {
@ -116,13 +116,13 @@ export class VibeTunnelApp extends LitElement {
await this.loadSessions();
// Try to find by exact ID match first
let session = this.sessions.find(s => s.id === sessionId);
let session = this.sessions.find((s) => s.id === sessionId);
// If not found by ID, find the most recently created session
// This works around tty-fwd potentially using different IDs internally
if (!session && this.sessions.length > 0) {
const sortedSessions = [...this.sessions].sort((a, b) =>
new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()
const sortedSessions = [...this.sessions].sort(
(a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()
);
session = sortedSessions[0];
}
@ -134,7 +134,7 @@ export class VibeTunnelApp extends LitElement {
}
// Wait before next attempt
await new Promise(resolve => setTimeout(resolve, delay));
await new Promise((resolve) => setTimeout(resolve, delay));
}
// If we get here, session creation might have failed
@ -142,8 +142,6 @@ export class VibeTunnelApp extends LitElement {
this.showError('Session created but could not be found. Please refresh.');
}
private handleSessionKilled(e: CustomEvent) {
console.log('Session killed:', e.detail);
this.loadSessions(); // Refresh the list
@ -198,10 +196,10 @@ export class VibeTunnelApp extends LitElement {
this.parseUrlAndSetState();
}
private handlePopState = (event: PopStateEvent) => {
private handlePopState = (_event: PopStateEvent) => {
// Handle browser back/forward navigation
this.parseUrlAndSetState();
}
};
private parseUrlAndSetState() {
const url = new URL(window.location.href);
@ -251,22 +249,30 @@ export class VibeTunnelApp extends LitElement {
render() {
return html`
<!-- Error notification overlay -->
${this.errorMessage ? html`
${this.errorMessage
? html`
<div class="fixed top-4 right-4 z-50">
<div class="bg-vs-warning text-vs-bg px-4 py-2 rounded shadow-lg font-mono text-sm">
${this.errorMessage}
<button @click=${this.clearError} class="ml-2 text-vs-bg hover:text-vs-muted"></button>
<button @click=${this.clearError} class="ml-2 text-vs-bg hover:text-vs-muted">
</button>
</div>
</div>
` : ''}
`
: ''}
<!-- Main content -->
${this.currentView === 'session' && this.selectedSessionId ?
keyed(this.selectedSessionId, html`
${this.currentView === 'session' && this.selectedSessionId
? keyed(
this.selectedSessionId,
html`
<session-view
.session=${this.sessions.find(s => s.id === this.selectedSessionId)}
.session=${this.sessions.find((s) => s.id === this.selectedSessionId)}
></session-view>
`) : html`
`
)
: html`
<div class="max-w-4xl mx-auto">
<app-header
.sessions=${this.sessions}

View file

@ -32,7 +32,7 @@ export class AppHeader extends LitElement {
}
render() {
const runningSessions = this.sessions.filter(session => session.status === 'running');
const runningSessions = this.sessions.filter((session) => session.status === 'running');
// Reset killing state if no more running sessions
if (this.killingAll && runningSessions.length === 0) {
@ -44,34 +44,52 @@ export class AppHeader extends LitElement {
<div class="flex items-center justify-between">
<div class="text-vs-user font-mono text-sm">VibeTunnel</div>
<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">
<label
class="flex items-center gap-2 text-vs-text text-sm cursor-pointer hover:text-vs-accent transition-colors"
>
<div class="relative">
<input
type="checkbox"
class="sr-only"
.checked=${this.hideExited}
@change=${(e: Event) => this.dispatchEvent(new CustomEvent('hide-exited-change', { detail: (e.target as HTMLInputElement).checked }))}
@change=${(e: Event) =>
this.dispatchEvent(
new CustomEvent('hide-exited-change', {
detail: (e.target as HTMLInputElement).checked,
})
)}
/>
<div
class="w-4 h-4 border border-vs-border rounded bg-vs-bg-secondary flex items-center justify-center transition-all ${this
.hideExited
? 'bg-vs-user border-vs-user'
: 'hover:border-vs-accent'}"
>
<div class="w-4 h-4 border border-vs-border rounded bg-vs-bg-secondary flex items-center justify-center transition-all ${
this.hideExited ? 'bg-vs-user border-vs-user' : 'hover:border-vs-accent'
}">
${this.hideExited ? html`
${this.hideExited
? html`
<svg class="w-3 h-3 text-vs-bg" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
></path>
</svg>
` : ''}
`
: ''}
</div>
</div>
hide exited
</label>
${runningSessions.length > 0 && !this.killingAll ? html`
${runningSessions.length > 0 && !this.killingAll
? html`
<button
class="bg-vs-warning text-vs-bg hover:bg-vs-highlight font-mono px-4 py-2 border-none rounded transition-colors text-sm"
@click=${this.handleKillAll}
>
KILL ALL (${runningSessions.length})
</button>
` : ''}
`
: ''}
<button
class="bg-vs-user text-vs-text hover:bg-vs-accent font-mono px-4 py-2 border-none rounded transition-colors text-sm"
@click=${this.handleCreateSession}

View file

@ -72,9 +72,11 @@ export class FileBrowser extends LitElement {
}
private handleSelect() {
this.dispatchEvent(new CustomEvent('directory-selected', {
detail: this.currentPath
}));
this.dispatchEvent(
new CustomEvent('directory-selected', {
detail: this.currentPath,
})
);
}
private handleCancel() {
@ -113,12 +115,12 @@ export class FileBrowser extends LitElement {
const response = await fetch('/api/mkdir', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
'Content-Type': 'application/json',
},
body: JSON.stringify({
path: this.currentPath,
name: this.newFolderName.trim()
})
name: this.newFolderName.trim(),
}),
});
if (response.ok) {
@ -143,8 +145,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 flex flex-col">
<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 flex-shrink-0">
<div class="flex justify-between items-center mb-2">
<div class="text-vs-assistant text-sm">Select Directory</div>
@ -161,10 +168,11 @@ export class FileBrowser extends LitElement {
</div>
<div class="p-4 flex-1 overflow-y-auto">
${this.loading ? html`
<div class="text-vs-muted">Loading...</div>
` : html`
${this.currentPath !== '/' ? html`
${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}
@ -172,9 +180,12 @@ export class FileBrowser extends LitElement {
<span>📁</span>
<span>.. (parent directory)</span>
</div>
` : ''}
${this.files.filter(f => f.isDir).map(file => html`
`
: ''}
${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)}
@ -182,19 +193,24 @@ export class FileBrowser extends LitElement {
<span>📁</span>
<span>${file.name}</span>
</div>
`)}
${this.files.filter(f => !f.isDir).map(file => html`
`
)}
${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>
<!-- Create folder dialog -->
${this.showCreateFolder ? html`
${this.showCreateFolder
? html`
<div class="p-4 border-t border-vs-border flex-shrink-0">
<div class="text-vs-assistant text-sm mb-2">Create New Folder</div>
<div class="flex gap-2">
@ -223,7 +239,8 @@ export class FileBrowser extends LitElement {
</button>
</div>
</div>
` : ''}
`
: ''}
<div class="p-4 border-t border-vs-border flex gap-4 justify-end flex-shrink-0">
<button

View file

@ -1,4 +1,4 @@
import { LitElement, html, css, PropertyValues } from 'lit';
import { LitElement, html, PropertyValues } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { Renderer } from '../renderer.js';
@ -35,7 +35,6 @@ export class SessionCard extends LitElement {
this.startRefresh();
}
disconnectedCallback() {
super.disconnectedCallback();
if (this.refreshInterval) {
@ -97,11 +96,13 @@ export class SessionCard extends LitElement {
}
private handleCardClick() {
this.dispatchEvent(new CustomEvent('session-select', {
this.dispatchEvent(
new CustomEvent('session-select', {
detail: this.session,
bubbles: true,
composed: true
}));
composed: true,
})
);
}
private async handleKillClick(e: Event) {
@ -119,7 +120,7 @@ export class SessionCard extends LitElement {
// Send kill request
try {
const response = await fetch(`/api/sessions/${this.session.id}`, {
method: 'DELETE'
method: 'DELETE',
});
if (!response.ok) {
@ -180,15 +181,23 @@ export class SessionCard extends LitElement {
}
render() {
const isRunning = this.session.status === 'running';
const _isRunning = this.session.status === 'running';
return html`
<div class="bg-vs-bg border border-vs-border rounded shadow cursor-pointer overflow-hidden ${this.killing ? 'opacity-60' : ''}"
@click=${this.handleCardClick}>
<div
class="bg-vs-bg border border-vs-border rounded shadow cursor-pointer overflow-hidden ${this
.killing
? 'opacity-60'
: ''}"
@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 truncate pr-2 flex-1">${this.session.command}</div>
${this.session.status === 'running' ? html`
<div class="text-vs-text text-xs font-mono truncate pr-2 flex-1">
${this.session.command}
</div>
${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"
@click=${this.handleKillClick}
@ -196,21 +205,22 @@ export class SessionCard extends LitElement {
>
${this.killing ? 'killing...' : 'kill'}
</button>
` : ''}
`
: ''}
</div>
<!-- XTerm renderer (main content) -->
<div class="session-preview bg-black overflow-hidden" style="aspect-ratio: 640/480;">
${this.killing ? html`
${this.killing
? html`
<div class="w-full h-full flex items-center justify-center text-vs-warning">
<div class="text-center font-mono">
<div class="text-4xl mb-2">${this.getKillingText()}</div>
<div class="text-sm">Killing session...</div>
</div>
</div>
` : html`
<div id="player" class="w-full h-full"></div>
`}
`
: html` <div id="player" class="w-full h-full"></div> `}
</div>
<!-- Compact Footer -->
@ -220,7 +230,8 @@ export class SessionCard extends LitElement {
<div class="w-2 h-2 rounded-full ${this.getStatusDotColor()}"></div>
${this.getStatusText()}
</span>
${this.session.pid ? html`
${this.session.pid
? html`
<span
class="cursor-pointer hover:text-vs-accent transition-colors"
@click=${this.handlePidClick}
@ -228,9 +239,12 @@ export class SessionCard extends LitElement {
>
PID: ${this.session.pid} <span class="opacity-50">(click to copy)</span>
</span>
` : ''}
`
: ''}
</div>
<div class="truncate text-xs opacity-75" title="${this.session.workingDir}">
${this.session.workingDir}
</div>
<div class="truncate text-xs opacity-75" title="${this.session.workingDir}">${this.session.workingDir}</div>
</div>
</div>
`;
@ -256,5 +270,4 @@ export class SessionCard extends LitElement {
}
return this.session.status === 'running' ? 'bg-green-500' : 'bg-orange-500';
}
}

View file

@ -83,9 +83,11 @@ export class SessionCreateForm extends LitElement {
private handleWorkingDirChange(e: Event) {
const input = e.target as HTMLInputElement;
this.workingDir = input.value;
this.dispatchEvent(new CustomEvent('working-dir-change', {
detail: this.workingDir
}));
this.dispatchEvent(
new CustomEvent('working-dir-change', {
detail: this.workingDir,
})
);
}
private handleCommandChange(e: Event) {
@ -108,9 +110,11 @@ export class SessionCreateForm extends LitElement {
private async handleCreate() {
if (!this.workingDir.trim() || !this.command.trim()) {
this.dispatchEvent(new CustomEvent('error', {
detail: 'Please fill in both working directory and command'
}));
this.dispatchEvent(
new CustomEvent('error', {
detail: 'Please fill in both working directory and command',
})
);
return;
}
@ -118,14 +122,14 @@ export class SessionCreateForm extends LitElement {
const sessionData: SessionCreateData = {
command: this.parseCommand(this.command.trim()),
workingDir: this.workingDir.trim()
workingDir: this.workingDir.trim(),
};
try {
const response = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(sessionData)
body: JSON.stringify(sessionData),
});
if (response.ok) {
@ -135,20 +139,26 @@ export class SessionCreateForm extends LitElement {
this.saveToLocalStorage();
this.command = ''; // Clear command on success
this.dispatchEvent(new CustomEvent('session-created', {
detail: result
}));
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}`
}));
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'
}));
this.dispatchEvent(
new CustomEvent('error', {
detail: 'Failed to create session',
})
);
} finally {
this.isCreating = false;
}
@ -197,18 +207,24 @@ export class SessionCreateForm 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 max-w-full mx-4">
<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 max-w-full mx-4"
>
<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>
>
×
</button>
</div>
<div class="p-4">
<div class="mb-4">
<div class="text-vs-text mb-2">Working Directory:</div>
<div class="flex gap-4">
@ -254,7 +270,10 @@ export class SessionCreateForm extends LitElement {
<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"
@click=${this.handleCreate}
?disabled=${this.disabled || this.isCreating || !this.workingDir.trim() || !this.command.trim()}
?disabled=${this.disabled ||
this.isCreating ||
!this.workingDir.trim() ||
!this.command.trim()}
>
${this.isCreating ? 'creating...' : 'create'}
</button>

View file

@ -39,7 +39,6 @@ export class SessionList extends LitElement {
window.location.search = `?session=${session.id}`;
}
private async handleCleanupExited() {
if (this.cleaningExited) return;
@ -48,13 +47,15 @@ export class SessionList extends LitElement {
try {
const response = await fetch('/api/cleanup-exited', {
method: 'POST'
method: 'POST',
});
if (response.ok) {
this.dispatchEvent(new CustomEvent('refresh'));
} else {
this.dispatchEvent(new CustomEvent('error', { detail: 'Failed to cleanup exited sessions' }));
this.dispatchEvent(
new CustomEvent('error', { detail: 'Failed to cleanup exited sessions' })
);
}
} catch (error) {
console.error('Error cleaning up exited sessions:', error);
@ -65,16 +66,16 @@ export class SessionList extends LitElement {
}
}
render() {
const filteredSessions = this.hideExited
? this.sessions.filter(session => session.status !== 'exited')
? this.sessions.filter((session) => session.status !== 'exited')
: this.sessions;
return html`
<div class="font-mono text-sm p-4">
<!-- Controls -->
${!this.hideExited && this.sessions.filter(s => s.status === 'exited').length > 0 ? html`
${!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"
@ -84,27 +85,38 @@ export class SessionList extends LitElement {
${this.cleaningExited ? '[~] CLEANING...' : 'CLEAN EXITED'}
</button>
</div>
` : ''}
${filteredSessions.length === 0 ? html`
`
: ''}
${filteredSessions.length === 0
? html`
<div class="text-vs-muted text-center py-8">
${this.loading ? 'Loading sessions...' : (this.hideExited && this.sessions.length > 0 ? 'No running sessions' : 'No sessions found')}
${this.loading
? 'Loading sessions...'
: this.hideExited && this.sessions.length > 0
? 'No running sessions'
: 'No sessions found'}
</div>
` : html`
`
: html`
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
${repeat(filteredSessions, (session) => session.id, (session) => html`
<session-card
.session=${session}
@session-select=${this.handleSessionSelect}>
${repeat(
filteredSessions,
(session) => session.id,
(session) => html`
<session-card .session=${session} @session-select=${this.handleSessionSelect}>
</session-card>
`)}
`
)}
</div>
`}
<session-create-form
.visible=${this.showCreateModal}
@session-created=${(e: CustomEvent) => this.dispatchEvent(new CustomEvent('session-created', { detail: e.detail }))}
@session-created=${(e: CustomEvent) =>
this.dispatchEvent(new CustomEvent('session-created', { detail: e.detail }))}
@cancel=${() => this.dispatchEvent(new CustomEvent('create-modal-close'))}
@error=${(e: CustomEvent) => this.dispatchEvent(new CustomEvent('error', { detail: e.detail }))}
@error=${(e: CustomEvent) =>
this.dispatchEvent(new CustomEvent('error', { detail: e.detail }))}
></session-create-form>
</div>
`;

View file

@ -78,7 +78,8 @@ export class SessionView extends LitElement {
}
// Detect mobile device
this.isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
this.isMobile =
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
window.innerWidth <= 768;
// Only add listeners if not already added
@ -91,7 +92,6 @@ export class SessionView extends LitElement {
document.addEventListener('touchend', this.touchEndHandler, { passive: true });
this.touchListenersAdded = true;
}
}
disconnectedCallback() {
@ -113,7 +113,6 @@ export class SessionView extends LitElement {
this.touchListenersAdded = false;
}
// Stop loading animation
this.stopLoading();
@ -186,7 +185,10 @@ export class SessionView extends LitElement {
}, delay);
// Listen for session exit events
terminalElement.addEventListener('session-exit', this.handleSessionExit.bind(this) as EventListener);
terminalElement.addEventListener(
'session-exit',
this.handleSessionExit.bind(this) as EventListener
);
}
private async handleKeyboardInput(e: KeyboardEvent) {
@ -274,7 +276,8 @@ export class SessionView extends LitElement {
// Handle Ctrl combinations (but not if we already handled Ctrl+Enter above)
if (e.ctrlKey && e.key.length === 1 && e.key !== 'Enter') {
const charCode = e.key.toLowerCase().charCodeAt(0);
if (charCode >= 97 && charCode <= 122) { // a-z
if (charCode >= 97 && charCode <= 122) {
// a-z
inputText = String.fromCharCode(charCode - 96); // Ctrl+A = \x01, etc.
}
}
@ -284,9 +287,9 @@ export class SessionView extends LitElement {
const response = await fetch(`/api/sessions/${this.session.id}/input`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
'Content-Type': 'application/json',
},
body: JSON.stringify({ text: inputText })
body: JSON.stringify({ text: inputText }),
});
if (!response.ok) {
@ -319,7 +322,6 @@ export class SessionView extends LitElement {
this.session = { ...this.session, status: 'exited' };
this.requestUpdate();
// Switch to snapshot mode
requestAnimationFrame(() => {
this.createInteractiveTerminal();
@ -366,7 +368,9 @@ export class SessionView extends LitElement {
controls.style.transition = 'transform 0.3s ease';
// Calculate available space for textarea
const header = this.querySelector('.flex.items-center.justify-between.p-4.border-b') as HTMLElement;
const header = this.querySelector(
'.flex.items-center.justify-between.p-4.border-b'
) as HTMLElement;
const headerHeight = header?.offsetHeight || 60;
const controlsHeight = controls?.offsetHeight || 120;
const padding = 48; // Additional padding for spacing
@ -501,7 +505,6 @@ export class SessionView extends LitElement {
}
};
private async handlePaste() {
if (!this.session) return;
@ -556,7 +559,10 @@ export class SessionView extends LitElement {
if (selectedText) {
// Write the selected text to clipboard
await navigator.clipboard.writeText(selectedText);
console.log('Text copied to clipboard:', selectedText.substring(0, 50) + (selectedText.length > 50 ? '...' : ''));
console.log(
'Text copied to clipboard:',
selectedText.substring(0, 50) + (selectedText.length > 50 ? '...' : '')
);
} else {
console.log('No text selected for copying');
}
@ -577,7 +583,10 @@ export class SessionView extends LitElement {
textArea.select();
if (document.execCommand('copy')) {
console.log('Text copied to clipboard (fallback):', selectedText.substring(0, 50) + (selectedText.length > 50 ? '...' : ''));
console.log(
'Text copied to clipboard (fallback):',
selectedText.substring(0, 50) + (selectedText.length > 50 ? '...' : '')
);
}
document.body.removeChild(textArea);
@ -596,9 +605,9 @@ export class SessionView extends LitElement {
const response = await fetch(`/api/sessions/${this.session.id}/input`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
'Content-Type': 'application/json',
},
body: JSON.stringify({ text })
body: JSON.stringify({ text }),
});
if (!response.ok) {
@ -636,30 +645,32 @@ export class SessionView extends LitElement {
return frames[this.loadingFrame % frames.length];
}
render() {
if (!this.session) {
return html`
<div class="p-4 text-vs-muted">
No session selected
</div>
`;
return html` <div class="p-4 text-vs-muted">No session selected</div> `;
}
return html`
<style>
session-view *, session-view *:focus, session-view *:focus-visible {
session-view *,
session-view *:focus,
session-view *:focus-visible {
outline: none !important;
box-shadow: none !important;
}
session-view:focus {
outline: 2px solid #007ACC !important;
outline: 2px solid #007acc !important;
outline-offset: -2px;
}
</style>
<div class="flex flex-col bg-vs-bg font-mono" style="height: 100vh; height: 100dvh; outline: none !important; box-shadow: none !important;">
<div
class="flex flex-col bg-vs-bg font-mono"
style="height: 100vh; height: 100dvh; outline: none !important; box-shadow: none !important;"
>
<!-- Compact Header -->
<div class="flex items-center justify-between px-3 py-2 border-b border-vs-border bg-vs-bg-secondary text-sm">
<div
class="flex items-center justify-between px-3 py-2 border-b border-vs-border bg-vs-bg-secondary text-sm"
>
<div class="flex items-center gap-3">
<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"
@ -680,20 +691,29 @@ export class SessionView extends LitElement {
</div>
<!-- Terminal Container -->
<div class="flex-1 bg-black overflow-hidden min-h-0 relative" id="terminal-container" style="max-width: 100vw; height: 100%;">
${this.loading ? html`
<div
class="flex-1 bg-black overflow-hidden min-h-0 relative"
id="terminal-container"
style="max-width: 100vw; height: 100%;"
>
${this.loading
? html`
<!-- Loading overlay -->
<div class="absolute inset-0 bg-black bg-opacity-80 flex items-center justify-center">
<div
class="absolute inset-0 bg-black bg-opacity-80 flex items-center justify-center"
>
<div class="text-vs-text font-mono text-center">
<div class="text-2xl mb-2">${this.getLoadingText()}</div>
<div class="text-sm text-vs-muted">Connecting to session...</div>
</div>
</div>
` : ''}
`
: ''}
</div>
<!-- Mobile Input Controls -->
${this.isMobile && !this.showMobileInput ? html`
${this.isMobile && !this.showMobileInput
? html`
<div class="flex-shrink-0 p-4 bg-vs-bg">
<!-- First row: Arrow keys -->
<div class="flex gap-2 mb-2">
@ -757,13 +777,20 @@ export class SessionView extends LitElement {
</button>
</div>
</div>
` : ''}
`
: ''}
<!-- Full-Screen Input Overlay (only when opened) -->
${this.isMobile && this.showMobileInput ? html`
<div class="fixed inset-0 bg-vs-bg-secondary bg-opacity-95 z-50 flex flex-col" style="height: 100vh; height: 100dvh;">
${this.isMobile && this.showMobileInput
? html`
<div
class="fixed inset-0 bg-vs-bg-secondary bg-opacity-95 z-50 flex flex-col"
style="height: 100vh; height: 100dvh;"
>
<!-- Input Header -->
<div class="flex items-center justify-between p-4 border-b border-vs-border flex-shrink-0">
<div
class="flex items-center justify-between p-4 border-b border-vs-border flex-shrink-0"
>
<div class="text-vs-text font-mono text-sm">Terminal Input</div>
<button
class="text-vs-muted hover:text-vs-text text-lg leading-none border-none bg-transparent cursor-pointer"
@ -795,7 +822,11 @@ export class SessionView extends LitElement {
</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);">
<div
id="mobile-controls"
class="fixed bottom-0 left-0 right-0 p-4 border-t border-vs-border bg-vs-bg-secondary z-60"
style="padding-bottom: max(1rem, env(safe-area-inset-bottom)); transform: translateY(0px);"
>
<!-- Send Buttons Row -->
<div class="flex gap-2 mb-3">
<button
@ -819,7 +850,8 @@ export class SessionView extends LitElement {
</div>
</div>
</div>
` : ''}
`
: ''}
</div>
`;
}

View file

@ -27,7 +27,13 @@ export class Renderer {
private scaleFitAddon: ScaleFitAddon;
private webLinksAddon: WebLinksAddon;
constructor(container: HTMLElement, width: number = 80, height: number = 20, scrollback: number = 1000000, fontSize: number = 14) {
constructor(
container: HTMLElement,
width: number = 80,
height: number = 20,
scrollback: number = 1000000,
fontSize: number = 14
) {
Renderer.activeCount++;
console.log(`Renderer constructor called (active: ${Renderer.activeCount})`);
this.container = container;
@ -62,7 +68,7 @@ export class Renderer {
brightBlue: '#3b8eea',
brightMagenta: '#d670d6',
brightCyan: '#29b8db',
brightWhite: '#ffffff'
brightWhite: '#ffffff',
},
allowProposedApi: true,
scrollback: scrollback, // Configurable scrollback buffer
@ -73,7 +79,7 @@ export class Renderer {
cursorBlink: true,
cursorStyle: 'block',
cursorWidth: 1,
cursorInactiveStyle: "block"
cursorInactiveStyle: 'block',
});
// Add addons
@ -116,7 +122,9 @@ export class Renderer {
// Ensure cursor is visible without focus by forcing it to stay active
requestAnimationFrame(() => {
if (this.terminal.element) {
const helperTextarea = this.terminal.element.querySelector('.xterm-helper-textarea') as HTMLTextAreaElement;
const helperTextarea = this.terminal.element.querySelector(
'.xterm-helper-textarea'
) as HTMLTextAreaElement;
if (helperTextarea) {
// Prevent the helper textarea from stealing focus but allow cursor rendering
helperTextarea.addEventListener('focus', (e) => {
@ -142,7 +150,6 @@ export class Renderer {
resizeObserver.observe(this.container);
}
// Public API methods - maintain compatibility with custom renderer
async loadCastFile(url: string): Promise<void> {
@ -166,14 +173,14 @@ export class Renderer {
if (parsed.version && parsed.width && parsed.height) {
// Header
header = parsed;
// header = parsed;
this.resize(parsed.width, parsed.height);
} else if (Array.isArray(parsed) && parsed.length >= 3) {
// Event: [timestamp, type, data]
const event: CastEvent = {
timestamp: parsed[0],
type: parsed[1],
data: parsed[2]
data: parsed[2],
};
if (event.type === 'o') {
@ -182,7 +189,7 @@ export class Renderer {
this.processResize(event.data);
}
}
} catch (e) {
} catch (_e) {
console.warn('Failed to parse cast line:', line);
}
}
@ -259,7 +266,7 @@ export class Renderer {
// Dispatch custom event that session-view can listen to
const exitEvent = new CustomEvent('session-exit', {
detail: { sessionId, exitCode }
detail: { sessionId, exitCode },
});
this.container.dispatchEvent(exitEvent);
return;
@ -269,12 +276,12 @@ export class Renderer {
const castEvent: CastEvent = {
timestamp: data[0],
type: data[1],
data: data[2]
data: data[2],
};
// Process event without verbose logging
this.processEvent(castEvent);
}
} catch (e) {
} catch (_e) {
console.warn('Failed to parse stream event:', event.data);
}
};
@ -348,7 +355,7 @@ export class Renderer {
getDimensions(): { cols: number; rows: number } {
return {
cols: this.terminal.cols,
rows: this.terminal.rows
rows: this.terminal.rows,
};
}

View file

@ -17,8 +17,7 @@ const MAX_FONT_SIZE = 16;
export class ScaleFitAddon implements ITerminalAddon {
private _terminal: Terminal | undefined;
constructor() {
}
constructor() {}
public activate(terminal: Terminal): void {
this._terminal = terminal;
@ -61,7 +60,7 @@ export class ScaleFitAddon implements ITerminalAddon {
top: parseInt(containerStyle.getPropertyValue('padding-top')),
bottom: parseInt(containerStyle.getPropertyValue('padding-bottom')),
left: parseInt(containerStyle.getPropertyValue('padding-left')),
right: parseInt(containerStyle.getPropertyValue('padding-right'))
right: parseInt(containerStyle.getPropertyValue('padding-right')),
};
// Calculate exact available space using known padding
@ -102,16 +101,19 @@ export class ScaleFitAddon implements ITerminalAddon {
requestAnimationFrame(() => this.applyFontSize(clampedFontSize));
// Log all calculations for debugging
console.log(`ScaleFitAddon: ${availableWidth}×${availableHeight}px available, ${currentCols}×${this._terminal.rows} terminal, charWidth=${charWidth.toFixed(2)}px, lineHeight=${lineHeight.toFixed(2)}px, currentRenderedWidth=${currentRenderedWidth.toFixed(2)}px, scaleFactor=${scaleFactor.toFixed(3)}, actualFontScaling=${actualFontScaling.toFixed(3)}, fontSize ${currentFontSize}px→${clampedFontSize.toFixed(2)}px, lineHeight ${lineHeight.toFixed(2)}px→${newLineHeight.toFixed(2)}px, rows ${this._terminal.rows}${optimalRows}`);
console.log(
`ScaleFitAddon: ${availableWidth}×${availableHeight}px available, ${currentCols}×${this._terminal.rows} terminal, charWidth=${charWidth.toFixed(2)}px, lineHeight=${lineHeight.toFixed(2)}px, currentRenderedWidth=${currentRenderedWidth.toFixed(2)}px, scaleFactor=${scaleFactor.toFixed(3)}, actualFontScaling=${actualFontScaling.toFixed(3)}, fontSize ${currentFontSize}px→${clampedFontSize.toFixed(2)}px, lineHeight ${lineHeight.toFixed(2)}px→${newLineHeight.toFixed(2)}px, rows ${this._terminal.rows}${optimalRows}`
);
return {
cols: currentCols, // ALWAYS keep exact column count
rows: optimalRows // Maximize rows that fit
rows: optimalRows, // Maximize rows that fit
};
} else {
// Fallback: estimate font size and dimensions if measurements aren't available
const charWidthRatio = 0.63;
const calculatedFontSize = Math.floor((availableWidth / (currentCols * charWidthRatio)) * 10) / 10;
const calculatedFontSize =
Math.floor((availableWidth / (currentCols * charWidthRatio)) * 10) / 10;
const optimalFontSize = Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, calculatedFontSize));
// Apply the calculated font size
@ -122,7 +124,7 @@ export class ScaleFitAddon implements ITerminalAddon {
return {
cols: currentCols,
rows: optimalRows
rows: optimalRows,
};
}
}
@ -167,7 +169,7 @@ export class ScaleFitAddon implements ITerminalAddon {
const containerWidth = parseInt(containerStyle.getPropertyValue('width'));
const containerPadding = {
left: parseInt(containerStyle.getPropertyValue('padding-left')),
right: parseInt(containerStyle.getPropertyValue('padding-right'))
right: parseInt(containerStyle.getPropertyValue('padding-right')),
};
const availableWidth = containerWidth - containerPadding.left - containerPadding.right;
@ -176,7 +178,8 @@ export class ScaleFitAddon implements ITerminalAddon {
// Calculate font size to fit columns in available width
const charWidthRatio = 0.63;
// Calculate font size and round down for precision
const calculatedFontSize = Math.floor((availableWidth / (currentCols * charWidthRatio)) * 10) / 10;
const calculatedFontSize =
Math.floor((availableWidth / (currentCols * charWidthRatio)) * 10) / 10;
const optimalFontSize = Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, calculatedFontSize));
// Apply the font size without changing terminal dimensions
@ -196,7 +199,8 @@ export class ScaleFitAddon implements ITerminalAddon {
const parentWidth = parseInt(parentStyle.getPropertyValue('width'));
const elementStyle = window.getComputedStyle(this._terminal.element);
const paddingHor = parseInt(elementStyle.getPropertyValue('padding-left')) +
const paddingHor =
parseInt(elementStyle.getPropertyValue('padding-left')) +
parseInt(elementStyle.getPropertyValue('padding-right'));
const availableWidth = parentWidth - paddingHor;
@ -213,7 +217,9 @@ export class ScaleFitAddon implements ITerminalAddon {
if (!this._terminal?.element) return null;
// XTerm has a built-in character measurement system with multiple font styles
const measureContainer = this._terminal.element.querySelector('.xterm-width-cache-measure-container');
const measureContainer = this._terminal.element.querySelector(
'.xterm-width-cache-measure-container'
);
// Find the first measurement element (normal weight, usually 'm' characters)
// This is what XTerm uses for baseline character width calculations
@ -242,7 +248,7 @@ export class ScaleFitAddon implements ITerminalAddon {
return {
charWidth: actualCharWidth,
lineHeight: lineHeight
lineHeight: lineHeight,
};
}
}

View file

@ -16,7 +16,7 @@ const PORT = process.env.PORT || 3000;
const possibleTtyFwdPaths = [
path.resolve(__dirname, '..', '..', 'tty-fwd', 'target', 'release', 'tty-fwd'),
path.resolve(__dirname, '..', '..', '..', 'tty-fwd', 'target', 'release', 'tty-fwd'),
'tty-fwd' // System PATH
'tty-fwd', // System PATH
];
let TTY_FWD_PATH = '';
@ -32,7 +32,8 @@ if (!TTY_FWD_PATH) {
process.exit(1);
}
const TTY_FWD_CONTROL_DIR = process.env.TTY_FWD_CONTROL_DIR || path.join(os.homedir(), '.vibetunnel/control');
const TTY_FWD_CONTROL_DIR =
process.env.TTY_FWD_CONTROL_DIR || path.join(os.homedir(), '.vibetunnel/control');
// Ensure control directory exists and is clean
if (fs.existsSync(TTY_FWD_CONTROL_DIR)) {
@ -49,8 +50,8 @@ if (fs.existsSync(TTY_FWD_CONTROL_DIR)) {
}
}
console.log(`Cleaned control directory: ${TTY_FWD_CONTROL_DIR}`);
} catch (error) {
console.error('Error cleaning control directory:', error);
} catch (_error) {
console.error('Error cleaning control directory:', _error);
}
} else {
fs.mkdirSync(TTY_FWD_CONTROL_DIR, { recursive: true });
@ -68,9 +69,9 @@ interface TtyFwdSession {
name: string;
pid: number;
started_at: string;
status: "running" | "exited";
status: 'running' | 'exited';
stdin: string;
"stream-out": string;
'stream-out': string;
waiting: boolean;
}
@ -152,11 +153,11 @@ app.get('/api/sessions', async (req, res) => {
// Get actual last modified time from stream-out file
let lastModified = sessionInfo.started_at;
try {
if (fs.existsSync(sessionInfo["stream-out"])) {
const stats = fs.statSync(sessionInfo["stream-out"]);
if (fs.existsSync(sessionInfo['stream-out'])) {
const stats = fs.statSync(sessionInfo['stream-out']);
lastModified = stats.mtime.toISOString();
}
} catch (e) {
} catch (_e) {
// Use started_at as fallback
}
@ -169,15 +170,17 @@ app.get('/api/sessions', async (req, res) => {
startedAt: sessionInfo.started_at,
lastModified: lastModified,
pid: sessionInfo.pid,
waiting: sessionInfo.waiting
waiting: sessionInfo.waiting,
};
});
// Sort by lastModified, most recent first
sessionData.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
sessionData.sort(
(a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime()
);
res.json(sessionData);
} catch (error) {
console.error('Failed to list sessions:', error);
} catch (_error) {
console.error('Failed to list sessions:', _error);
res.status(500).json({ error: 'Failed to list sessions' });
}
});
@ -195,9 +198,11 @@ app.post('/api/sessions', async (req, res) => {
const cwd = resolvePath(workingDir, process.cwd());
const args = [
'--control-path', TTY_FWD_CONTROL_DIR,
'--session-name', sessionName,
'--'
'--control-path',
TTY_FWD_CONTROL_DIR,
'--session-name',
sessionName,
'--',
].concat(command);
console.log(`Creating session: ${TTY_FWD_PATH} ${args.join(' ')}`);
@ -205,7 +210,7 @@ app.post('/api/sessions', async (req, res) => {
const child = spawn(TTY_FWD_PATH, args, {
cwd: cwd,
detached: false,
stdio: 'pipe'
stdio: 'pipe',
});
// Capture session ID from stdout
@ -237,11 +242,11 @@ app.post('/api/sessions', async (req, res) => {
const exitEvent = JSON.stringify(['exit', code, sessionId]);
const eventData = `data: ${exitEvent}\n\n`;
streamInfo.clients.forEach(client => {
streamInfo.clients.forEach((client) => {
try {
client.write(eventData);
} catch (error) {
console.error('Error sending exit event to client:', error);
} catch (_error) {
console.error('Error sending exit event to client:', _error);
}
});
}
@ -266,9 +271,8 @@ app.post('/api/sessions', async (req, res) => {
const finalSessionId = await waitForSessionId;
res.json({ sessionId: finalSessionId });
} catch (error) {
console.error('Error creating session:', error);
} catch (_error) {
console.error('Error creating session:', _error);
res.status(500).json({ error: 'Failed to create session' });
}
});
@ -293,19 +297,18 @@ app.delete('/api/sessions/:sessionId', async (req, res) => {
try {
process.kill(session.pid, 0); // Check if still alive
process.kill(session.pid, 'SIGKILL'); // Force kill
} catch (e) {
} catch (_e) {
// Process already dead
}
}, 1000);
} catch (error) {
} catch (_error) {
// Process already dead
}
}
res.json({ success: true, message: 'Session killed' });
} catch (error) {
console.error('Error killing session:', error);
} catch (_error) {
console.error('Error killing session:', _error);
res.status(500).json({ error: 'Failed to kill session' });
}
});
@ -316,14 +319,15 @@ app.delete('/api/sessions/:sessionId/cleanup', async (req, res) => {
try {
await executeTtyFwd([
'--control-path', TTY_FWD_CONTROL_DIR,
'--session', sessionId,
'--cleanup'
'--control-path',
TTY_FWD_CONTROL_DIR,
'--session',
sessionId,
'--cleanup',
]);
res.json({ success: true, message: 'Session cleaned up' });
} catch (error) {
} catch (_error) {
// If tty-fwd cleanup fails, force remove directory
console.log('tty-fwd cleanup failed, force removing directory');
const sessionDir = path.join(TTY_FWD_CONTROL_DIR, sessionId);
@ -342,15 +346,11 @@ app.delete('/api/sessions/:sessionId/cleanup', async (req, res) => {
// Cleanup all exited sessions
app.post('/api/cleanup-exited', async (req, res) => {
try {
await executeTtyFwd([
'--control-path', TTY_FWD_CONTROL_DIR,
'--cleanup'
]);
await executeTtyFwd(['--control-path', TTY_FWD_CONTROL_DIR, '--cleanup']);
res.json({ success: true, message: 'All exited sessions cleaned up' });
} catch (error) {
console.error('Error cleaning up exited sessions:', error);
} catch (_error) {
console.error('Error cleaning up exited sessions:', _error);
res.status(500).json({ error: 'Failed to cleanup exited sessions' });
}
});
@ -358,11 +358,14 @@ app.post('/api/cleanup-exited', async (req, res) => {
// === TERMINAL I/O ===
// Track active streams per session to avoid multiple tail processes
const activeStreams = new Map<string, {
clients: Set<any>,
tailProcess: any,
lastPosition: number
}>();
const activeStreams = new Map<
string,
{
clients: Set<any>;
tailProcess: any;
lastPosition: number;
}
>();
// Live streaming cast file for XTerm renderer
app.get('/api/sessions/:sessionId/stream', async (req, res) => {
@ -373,14 +376,16 @@ app.get('/api/sessions/:sessionId/stream', async (req, res) => {
return res.status(404).json({ error: 'Session not found' });
}
console.log(`New SSE client connected to session ${sessionId} from ${req.get('User-Agent')?.substring(0, 50) || 'unknown'}`);
console.log(
`New SSE client connected to session ${sessionId} from ${req.get('User-Agent')?.substring(0, 50) || 'unknown'}`
);
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
Connection: 'keep-alive',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Cache-Control'
'Access-Control-Allow-Headers': 'Cache-Control',
});
const startTime = Date.now() / 1000;
@ -402,13 +407,13 @@ app.get('/api/sessions/:sessionId/stream', async (req, res) => {
const instantEvent = [0, parsed[1], parsed[2]];
res.write(`data: ${JSON.stringify(instantEvent)}\n\n`);
}
} catch (e) {
} catch (_e) {
// Skip invalid lines
}
}
}
} catch (error) {
console.error('Error reading existing content:', error);
} catch (_error) {
console.error('Error reading existing content:', _error);
}
// Send default header if none found
@ -418,7 +423,7 @@ app.get('/api/sessions/:sessionId/stream', async (req, res) => {
width: 80,
height: 24,
timestamp: Math.floor(startTime),
env: { TERM: "xterm-256color" }
env: { TERM: 'xterm-256color' },
};
res.write(`data: ${JSON.stringify(defaultHeader)}\n\n`);
}
@ -436,7 +441,7 @@ app.get('/api/sessions/:sessionId/stream', async (req, res) => {
streamInfo = {
clients: new Set(),
tailProcess,
lastPosition: 0
lastPosition: 0,
};
activeStreams.set(sessionId, streamInfo);
@ -460,23 +465,25 @@ app.get('/api/sessions/:sessionId/stream', async (req, res) => {
const realTimeEvent = [currentTime - startTime, parsed[1], parsed[2]];
eventData = `data: ${JSON.stringify(realTimeEvent)}\n\n`;
}
} catch (e) {
} catch (_e) {
// Handle non-JSON as raw output
const currentTime = Date.now() / 1000;
const castEvent = [currentTime - startTime, "o", line];
const castEvent = [currentTime - startTime, 'o', line];
eventData = `data: ${JSON.stringify(castEvent)}\n\n`;
}
if (eventData && streamInfo) {
// Broadcast to all connected clients
streamInfo.clients.forEach(client => {
streamInfo.clients.forEach((client) => {
try {
client.write(eventData);
} catch (error) {
console.error('Error writing to client:', error);
} catch (_error) {
console.error('Error writing to client:', _error);
if (streamInfo) {
streamInfo.clients.delete(client);
console.log(`Removed failed client from session ${sessionId}, remaining clients: ${streamInfo.clients.size}`);
console.log(
`Removed failed client from session ${sessionId}, remaining clients: ${streamInfo.clients.size}`
);
}
}
});
@ -490,8 +497,10 @@ app.get('/api/sessions/:sessionId/stream', async (req, res) => {
// Cleanup all clients
const currentStreamInfo = activeStreams.get(sessionId);
if (currentStreamInfo) {
currentStreamInfo.clients.forEach(client => {
try { client.end(); } catch (e) {}
currentStreamInfo.clients.forEach((client) => {
try {
client.end();
} catch (_e) {}
});
}
activeStreams.delete(sessionId);
@ -502,8 +511,10 @@ app.get('/api/sessions/:sessionId/stream', async (req, res) => {
// Cleanup all clients
const currentStreamInfo = activeStreams.get(sessionId);
if (currentStreamInfo) {
currentStreamInfo.clients.forEach(client => {
try { client.end(); } catch (e) {}
currentStreamInfo.clients.forEach((client) => {
try {
client.end();
} catch (_e) {}
});
}
activeStreams.delete(sessionId);
@ -518,14 +529,16 @@ app.get('/api/sessions/:sessionId/stream', async (req, res) => {
const cleanup = () => {
if (streamInfo && streamInfo.clients.has(res)) {
streamInfo.clients.delete(res);
console.log(`Removed client from session ${sessionId}, remaining clients: ${streamInfo.clients.size}`);
console.log(
`Removed client from session ${sessionId}, remaining clients: ${streamInfo.clients.size}`
);
// If no more clients, cleanup the tail process
if (streamInfo.clients.size === 0) {
console.log(`No more clients for session ${sessionId}, cleaning up tail process`);
try {
streamInfo.tailProcess.kill('SIGTERM');
} catch (e) {}
} catch (_e) {}
activeStreams.delete(sessionId);
}
}
@ -571,7 +584,7 @@ app.get('/api/sessions/:sessionId/snapshot', (req, res) => {
}
events.push([0, parsed[1], parsed[2]]);
}
} catch (e) {
} catch (_e) {
// Skip invalid lines
}
}
@ -584,25 +597,26 @@ app.get('/api/sessions/:sessionId/snapshot', (req, res) => {
if (header) {
cast.push(JSON.stringify(header));
} else {
cast.push(JSON.stringify({
cast.push(
JSON.stringify({
version: 2,
width: 80,
height: 24,
timestamp: Math.floor(Date.now() / 1000),
env: { TERM: "xterm-256color" }
}));
env: { TERM: 'xterm-256color' },
})
);
}
// Add all events
events.forEach(event => {
events.forEach((event) => {
cast.push(JSON.stringify(event));
});
res.setHeader('Content-Type', 'text/plain');
res.send(cast.join('\n'));
} catch (error) {
console.error('Error reading session snapshot:', error);
} catch (_error) {
console.error('Error reading session snapshot:', _error);
res.status(500).json({ error: 'Failed to read session snapshot' });
}
});
@ -638,14 +652,16 @@ app.post('/api/sessions/:sessionId/input', async (req, res) => {
if (session.pid) {
try {
process.kill(session.pid, 0); // Signal 0 just checks if process exists
} catch (error) {
} catch (_error) {
console.error(`Session ${sessionId} process ${session.pid} is dead, cleaning up`);
// Try to cleanup the stale session
try {
await executeTtyFwd([
'--control-path', TTY_FWD_CONTROL_DIR,
'--session', sessionId,
'--cleanup'
'--control-path',
TTY_FWD_CONTROL_DIR,
'--session',
sessionId,
'--cleanup',
]);
} catch (cleanupError) {
console.error('Failed to cleanup stale session:', cleanupError);
@ -655,32 +671,46 @@ app.post('/api/sessions/:sessionId/input', async (req, res) => {
}
// Check if this is a special key that should use --send-key
const specialKeys = ['arrow_up', 'arrow_down', 'arrow_left', 'arrow_right', 'escape', 'enter', 'ctrl_enter', 'shift_enter'];
const specialKeys = [
'arrow_up',
'arrow_down',
'arrow_left',
'arrow_right',
'escape',
'enter',
'ctrl_enter',
'shift_enter',
];
const isSpecialKey = specialKeys.includes(text);
const startTime = Date.now();
if (isSpecialKey) {
await executeTtyFwd([
'--control-path', TTY_FWD_CONTROL_DIR,
'--session', sessionId,
'--send-key', text
'--control-path',
TTY_FWD_CONTROL_DIR,
'--session',
sessionId,
'--send-key',
text,
]);
// Key sent successfully (removed verbose logging)
} else {
await executeTtyFwd([
'--control-path', TTY_FWD_CONTROL_DIR,
'--session', sessionId,
'--send-text', text
'--control-path',
TTY_FWD_CONTROL_DIR,
'--session',
sessionId,
'--send-text',
text,
]);
// Text sent successfully (removed verbose logging)
}
res.json({ success: true });
} catch (error) {
console.error('Error sending input via tty-fwd:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
} catch (_error) {
console.error('Error sending input via tty-fwd:', _error);
const errorMessage = _error instanceof Error ? _error.message : 'Unknown error';
res.status(500).json({ error: 'Failed to send input', details: errorMessage });
}
});
@ -699,8 +729,8 @@ app.get('/api/test-cast', (req, res) => {
} else {
res.status(404).json({ error: 'Test cast file not found' });
}
} catch (error) {
console.error('Error serving test cast file:', error);
} catch (_error) {
console.error('Error serving test cast file:', _error);
res.status(500).json({ error: 'Failed to serve test cast file' });
}
});
@ -709,7 +739,7 @@ app.get('/api/test-cast', (req, res) => {
// Directory listing for file browser
app.get('/api/fs/browse', (req, res) => {
const dirPath = req.query.path as string || '~';
const dirPath = (req.query.path as string) || '~';
try {
const expandedPath = resolvePath(dirPath, '~');
@ -723,7 +753,7 @@ app.get('/api/fs/browse', (req, res) => {
return res.status(400).json({ error: 'Path is not a directory' });
}
const files = fs.readdirSync(expandedPath).map(name => {
const files = fs.readdirSync(expandedPath).map((name) => {
const filePath = path.join(expandedPath, name);
const fileStats = fs.statSync(filePath);
@ -732,7 +762,7 @@ app.get('/api/fs/browse', (req, res) => {
created: fileStats.birthtime.toISOString(),
lastModified: fileStats.mtime.toISOString(),
size: fileStats.size,
isDir: fileStats.isDirectory()
isDir: fileStats.isDirectory(),
};
});
@ -743,11 +773,10 @@ app.get('/api/fs/browse', (req, res) => {
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);
} catch (_error) {
console.error('Error listing directory:', _error);
res.status(500).json({ error: 'Failed to list directory' });
}
});
@ -773,7 +802,7 @@ app.post('/api/mkdir', (req, res) => {
// Security check: ensure we're not trying to access outside allowed areas
const allowedBasePaths = [os.homedir(), process.cwd()];
const isAllowed = allowedBasePaths.some(basePath =>
const isAllowed = allowedBasePaths.some((basePath) =>
expandedPath.startsWith(path.resolve(basePath))
);
@ -804,11 +833,10 @@ app.post('/api/mkdir', (req, res) => {
res.json({
success: true,
path: newDirPath,
message: `Directory '${name}' created successfully`
message: `Directory '${name}' created successfully`,
});
} catch (error) {
console.error('Error creating directory:', error);
} catch (_error) {
console.error('Error creating directory:', _error);
res.status(500).json({ error: 'Failed to create directory' });
}
});
@ -836,7 +864,7 @@ if (process.env.NODE_ENV !== 'production') {
const chokidar = require('chokidar');
const watcher = chokidar.watch(['public/**/*', 'src/**/*'], {
ignored: /node_modules/,
persistent: true
persistent: true,
});
watcher.on('change', (path: string) => {