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

@ -1,2 +1,2 @@
// Entry point for the app
import './app.js';
import './app.js';

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);
@ -232,14 +230,14 @@ export class VibeTunnelApp extends LitElement {
private setupHotReload(): void {
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
try {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}?hotReload=true`;
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();
this.hotReloadWs = new WebSocket(wsUrl);
this.hotReloadWs.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.type === 'reload') {
window.location.reload();
}
};
} catch (error) {
@ -251,45 +249,53 @@ export class VibeTunnelApp extends LitElement {
render() {
return html`
<!-- Error notification overlay -->
${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>
</div>
</div>
` : ''}
${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>
</div>
</div>
`
: ''}
<!-- Main content -->
${this.currentView === 'session' && this.selectedSessionId ?
keyed(this.selectedSessionId, html`
<session-view
.session=${this.sessions.find(s => s.id === this.selectedSessionId)}
></session-view>
`) : html`
<div class="max-w-4xl mx-auto">
<app-header
.sessions=${this.sessions}
.hideExited=${this.hideExited}
@create-session=${this.handleCreateSession}
@hide-exited-change=${this.handleHideExitedChange}
@kill-all-sessions=${this.handleKillAll}
></app-header>
<session-list
.sessions=${this.sessions}
.loading=${this.loading}
.hideExited=${this.hideExited}
.showCreateModal=${this.showCreateModal}
@session-killed=${this.handleSessionKilled}
@session-created=${this.handleSessionCreated}
@create-modal-close=${this.handleCreateModalClose}
@refresh=${this.handleRefresh}
@error=${this.handleError}
@hide-exited-change=${this.handleHideExitedChange}
@kill-all-sessions=${this.handleKillAll}
></session-list>
</div>
`}
${this.currentView === 'session' && this.selectedSessionId
? keyed(
this.selectedSessionId,
html`
<session-view
.session=${this.sessions.find((s) => s.id === this.selectedSessionId)}
></session-view>
`
)
: html`
<div class="max-w-4xl mx-auto">
<app-header
.sessions=${this.sessions}
.hideExited=${this.hideExited}
@create-session=${this.handleCreateSession}
@hide-exited-change=${this.handleHideExitedChange}
@kill-all-sessions=${this.handleKillAll}
></app-header>
<session-list
.sessions=${this.sessions}
.loading=${this.loading}
.hideExited=${this.hideExited}
.showCreateModal=${this.showCreateModal}
@session-killed=${this.handleSessionKilled}
@session-created=${this.handleSessionCreated}
@create-modal-close=${this.handleCreateModalClose}
@refresh=${this.handleRefresh}
@error=${this.handleError}
@hide-exited-change=${this.handleHideExitedChange}
@kill-all-sessions=${this.handleKillAll}
></session-list>
</div>
`}
`;
}
}
}

View file

@ -18,12 +18,12 @@ export class AppHeader extends LitElement {
private handleKillAll() {
if (this.killingAll) return;
this.killingAll = true;
this.requestUpdate();
this.dispatchEvent(new CustomEvent('kill-all-sessions'));
// Reset the state after a delay to allow for the kill operations to complete
setTimeout(() => {
this.killingAll = false;
@ -32,8 +32,8 @@ 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) {
this.killingAll = false;
@ -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`
<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>
</svg>
` : ''}
${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>
</svg>
`
: ''}
</div>
</div>
hide exited
</label>
${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>
` : ''}
${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}
@ -83,4 +101,4 @@ export class AppHeader extends LitElement {
</div>
`;
}
}
}

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,12 +145,17 @@ 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>
<button
<button
class="bg-vs-user text-vs-bg hover:bg-vs-accent font-mono px-2 py-1 text-xs border-none rounded"
@click=${this.handleCreateFolder}
?disabled=${this.loading}
@ -159,80 +166,90 @@ export class FileBrowser extends LitElement {
</div>
<div class="text-vs-muted text-sm break-all">${this.currentPath}</div>
</div>
<div class="p-4 flex-1 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>
`)}
`}
${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>
<!-- Create folder dialog -->
${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">
<input
type="text"
class="flex-1 bg-vs-bg border border-vs-border text-vs-text px-2 py-1 text-sm font-mono"
placeholder="Folder name"
.value=${this.newFolderName}
@input=${this.handleFolderNameInput}
@keydown=${this.handleFolderNameKeydown}
?disabled=${this.creating}
/>
<button
class="bg-vs-user text-vs-bg hover:bg-vs-accent font-mono px-2 py-1 text-xs border-none"
@click=${this.createFolder}
?disabled=${this.creating || !this.newFolderName.trim()}
>
${this.creating ? '...' : 'create'}
</button>
<button
class="bg-vs-muted text-vs-bg hover:bg-vs-text font-mono px-2 py-1 text-xs border-none"
@click=${this.handleCancelCreateFolder}
?disabled=${this.creating}
>
cancel
</button>
</div>
</div>
` : ''}
${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">
<input
type="text"
class="flex-1 bg-vs-bg border border-vs-border text-vs-text px-2 py-1 text-sm font-mono"
placeholder="Folder name"
.value=${this.newFolderName}
@input=${this.handleFolderNameInput}
@keydown=${this.handleFolderNameKeydown}
?disabled=${this.creating}
/>
<button
class="bg-vs-user text-vs-bg hover:bg-vs-accent font-mono px-2 py-1 text-xs border-none"
@click=${this.createFolder}
?disabled=${this.creating || !this.newFolderName.trim()}
>
${this.creating ? '...' : 'create'}
</button>
<button
class="bg-vs-muted text-vs-bg hover:bg-vs-text font-mono px-2 py-1 text-xs border-none"
@click=${this.handleCancelCreateFolder}
?disabled=${this.creating}
>
cancel
</button>
</div>
</div>
`
: ''}
<div class="p-4 border-t border-vs-border flex gap-4 justify-end flex-shrink-0">
<button
<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
<button
class="bg-vs-user text-vs-bg hover:bg-vs-accent font-mono px-4 py-2 border-none"
@click=${this.handleSelect}
>
@ -243,4 +260,4 @@ export class FileBrowser extends LitElement {
</div>
`;
}
}
}

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', {
detail: this.session,
bubbles: true,
composed: true
}));
this.dispatchEvent(
new CustomEvent('session-select', {
detail: this.session,
bubbles: 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,37 +181,46 @@ 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`
<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}
?disabled=${this.killing}
>
${this.killing ? 'killing...' : 'kill'}
</button>
` : ''}
<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}
?disabled=${this.killing}
>
${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`
<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>
`}
${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> `}
</div>
<!-- Compact Footer -->
@ -220,17 +230,21 @@ export class SessionCard extends LitElement {
<div class="w-2 h-2 rounded-full ${this.getStatusDotColor()}"></div>
${this.getStatusText()}
</span>
${this.session.pid ? html`
<span
class="cursor-pointer hover:text-vs-accent transition-colors"
@click=${this.handlePidClick}
title="Click to copy PID"
>
PID: ${this.session.pid} <span class="opacity-50">(click to copy)</span>
</span>
` : ''}
${this.session.pid
? html`
<span
class="cursor-pointer hover:text-vs-accent transition-colors"
@click=${this.handlePidClick}
title="Click to copy PID"
>
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

@ -35,16 +35,16 @@ export class SessionCreateForm extends LitElement {
try {
const savedWorkingDir = localStorage.getItem(this.STORAGE_KEY_WORKING_DIR);
const savedCommand = localStorage.getItem(this.STORAGE_KEY_COMMAND);
console.log('Loading from localStorage:', { savedWorkingDir, savedCommand });
if (savedWorkingDir) {
this.workingDir = savedWorkingDir;
}
if (savedCommand) {
this.command = savedCommand;
}
// Force re-render to update the input values
this.requestUpdate();
} catch (error) {
@ -56,9 +56,9 @@ export class SessionCreateForm extends LitElement {
try {
const workingDir = this.workingDir.trim();
const command = this.command.trim();
console.log('Saving to localStorage:', { workingDir, command });
// Only save non-empty values
if (workingDir) {
localStorage.setItem(this.STORAGE_KEY_WORKING_DIR, workingDir);
@ -73,7 +73,7 @@ export class SessionCreateForm extends LitElement {
updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
// Load from localStorage when form becomes visible
if (changedProperties.has('visible') && this.visible) {
this.loadFromLocalStorage();
@ -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,37 +122,43 @@ 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) {
const result = await response.json();
// Save to localStorage before clearing the command
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;
}
@ -163,7 +173,7 @@ export class SessionCreateForm extends LitElement {
for (let i = 0; i < commandStr.length; i++) {
const char = commandStr[i];
if ((char === '"' || char === "'") && !inQuotes) {
inQuotes = true;
quoteChar = char;
@ -179,11 +189,11 @@ export class SessionCreateForm extends LitElement {
current += char;
}
}
if (current) {
args.push(current);
}
return args;
}
@ -197,64 +207,73 @@ 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
<button
class="text-vs-muted hover:text-vs-text text-lg leading-none border-none bg-transparent cursor-pointer"
@click=${this.handleCancel}
>×</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">
<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-text mb-2">Command:</div>
<input
type="text"
class="w-full bg-vs-bg text-vs-text border border-vs-border outline-none font-mono px-4 py-2"
.value=${this.command}
@input=${this.handleCommandChange}
@keydown=${(e: KeyboardEvent) => e.key === 'Enter' && this.handleCreate()}
placeholder="zsh"
?disabled=${this.disabled || this.isCreating}
/>
</div>
<div class="p-4">
<div class="mb-4">
<div class="text-vs-text 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-text mb-2">Command:</div>
<input
type="text"
class="w-full bg-vs-bg text-vs-text border border-vs-border outline-none font-mono px-4 py-2"
.value=${this.command}
@input=${this.handleCommandChange}
@keydown=${(e: KeyboardEvent) => e.key === 'Enter' && this.handleCreate()}
placeholder="zsh"
?disabled=${this.disabled || this.isCreating}
/>
</div>
<div class="flex gap-4 justify-end">
<button
<button
class="bg-vs-muted text-vs-bg hover:bg-vs-text font-mono px-4 py-2 border-none"
@click=${this.handleCancel}
?disabled=${this.isCreating}
>
cancel
</button>
<button
<button
class="bg-vs-user text-vs-text hover:bg-vs-accent font-mono px-4 py-2 border-none disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-vs-user"
@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>
@ -262,7 +281,7 @@ export class SessionCreateForm extends LitElement {
</div>
</div>
</div>
<file-browser
.visible=${this.showFileBrowser}
.currentPath=${this.workingDir}
@ -271,4 +290,4 @@ export class SessionCreateForm extends LitElement {
></file-browser>
`;
}
}
}

View file

@ -39,22 +39,23 @@ export class SessionList extends LitElement {
window.location.search = `?session=${session.id}`;
}
private async handleCleanupExited() {
if (this.cleaningExited) return;
this.cleaningExited = true;
this.requestUpdate();
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,48 +66,59 @@ export class SessionList extends LitElement {
}
}
render() {
const filteredSessions = this.hideExited
? this.sessions.filter(session => session.status !== 'exited')
const filteredSessions = this.hideExited
? 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`
<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"
@click=${this.handleCleanupExited}
?disabled=${this.cleaningExited}
>
${this.cleaningExited ? '[~] CLEANING...' : 'CLEAN EXITED'}
</button>
</div>
` : ''}
${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')}
</div>
` : 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}>
</session-card>
`)}
</div>
`}
${!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"
@click=${this.handleCleanupExited}
?disabled=${this.cleaningExited}
>
${this.cleaningExited ? '[~] CLEANING...' : 'CLEAN EXITED'}
</button>
</div>
`
: ''}
${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'}
</div>
`
: 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}>
</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,8 +78,9 @@ export class SessionView extends LitElement {
}
// Detect mobile device
this.isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
window.innerWidth <= 768;
this.isMobile =
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
window.innerWidth <= 768;
// Only add listeners if not already added
if (!this.isMobile && !this.keyboardListenerAdded) {
@ -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();
@ -158,7 +157,7 @@ export class SessionView extends LitElement {
// Create the interactive terminal div inside the container
const container = this.querySelector('#terminal-container') as HTMLElement;
if (!container) return;
terminalElement = document.createElement('div');
terminalElement.id = 'interactive-terminal';
terminalElement.className = 'w-full h-full';
@ -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) {
@ -200,10 +202,10 @@ export class SessionView extends LitElement {
// Handle clipboard shortcuts: Cmd+C/V on macOS, Shift+Ctrl+C/V on Linux/Windows
const isMacOS = navigator.platform.toLowerCase().includes('mac');
const isPasteShortcut =
const isPasteShortcut =
(isMacOS && e.metaKey && e.key === 'v' && !e.ctrlKey && !e.shiftKey) ||
(!isMacOS && e.ctrlKey && e.shiftKey && e.key === 'V');
const isCopyShortcut =
const isCopyShortcut =
(isMacOS && e.metaKey && e.key === 'c' && !e.ctrlKey && !e.shiftKey) ||
(!isMacOS && e.ctrlKey && e.shiftKey && e.key === 'C');
@ -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,14 +505,13 @@ export class SessionView extends LitElement {
}
};
private async handlePaste() {
if (!this.session) return;
try {
// Try clipboard API first (requires user activation)
const clipboardText = await navigator.clipboard.readText();
if (clipboardText) {
// Send the clipboard text to the terminal
await this.sendInputText(clipboardText);
@ -517,7 +520,7 @@ export class SessionView extends LitElement {
console.error('Failed to read from clipboard:', error);
// Show user a message about using Ctrl+V instead
console.log('Tip: Try using Ctrl+V (Cmd+V on Mac) to paste instead');
// Fallback: try to use the older document.execCommand method
try {
const textArea = document.createElement('textarea');
@ -527,14 +530,14 @@ export class SessionView extends LitElement {
textArea.style.top = '-9999px';
document.body.appendChild(textArea);
textArea.focus();
if (document.execCommand('paste')) {
const pastedText = textArea.value;
if (pastedText) {
await this.sendInputText(pastedText);
}
}
document.body.removeChild(textArea);
} catch (fallbackError) {
console.error('Fallback paste method also failed:', fallbackError);
@ -549,14 +552,17 @@ export class SessionView extends LitElement {
try {
// Get the terminal instance from the renderer
const terminal = this.renderer.getTerminal();
// Get the selected text from the terminal
const selectedText = terminal.getSelection();
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');
}
@ -567,7 +573,7 @@ export class SessionView extends LitElement {
if (this.renderer) {
const terminal = this.renderer.getTerminal();
const selectedText = terminal.getSelection();
if (selectedText) {
const textArea = document.createElement('textarea');
textArea.value = selectedText;
@ -575,11 +581,14 @@ export class SessionView extends LitElement {
textArea.style.opacity = '0';
document.body.appendChild(textArea);
textArea.select();
if (document.execCommand('copy')) {
console.log('Text copied to clipboard (fallback):', selectedText.substring(0, 50) + (selectedText.length > 50 ? '...' : ''));
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,147 +691,168 @@ 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`
<!-- Loading overlay -->
<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
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="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`
<div class="flex-shrink-0 p-4 bg-vs-bg">
<!-- First row: Arrow keys -->
<div class="flex gap-2 mb-2">
<button
class="flex-1 bg-vs-muted text-vs-bg hover:bg-vs-accent font-mono px-3 py-2 border-none rounded transition-colors text-sm"
@click=${() => this.handleSpecialKey('arrow_up')}
>
<span class="text-xl"></span>
</button>
<button
class="flex-1 bg-vs-muted text-vs-bg hover:bg-vs-accent font-mono px-3 py-2 border-none rounded transition-colors text-sm"
@click=${() => this.handleSpecialKey('arrow_down')}
>
<span class="text-xl"></span>
</button>
<button
class="flex-1 bg-vs-muted text-vs-bg hover:bg-vs-accent font-mono px-3 py-2 border-none rounded transition-colors text-sm"
@click=${() => this.handleSpecialKey('arrow_left')}
>
<span class="text-xl"></span>
</button>
<button
class="flex-1 bg-vs-muted text-vs-bg hover:bg-vs-accent font-mono px-3 py-2 border-none rounded transition-colors text-sm"
@click=${() => this.handleSpecialKey('arrow_right')}
>
<span class="text-xl"></span>
</button>
</div>
${this.isMobile && !this.showMobileInput
? html`
<div class="flex-shrink-0 p-4 bg-vs-bg">
<!-- First row: Arrow keys -->
<div class="flex gap-2 mb-2">
<button
class="flex-1 bg-vs-muted text-vs-bg hover:bg-vs-accent font-mono px-3 py-2 border-none rounded transition-colors text-sm"
@click=${() => this.handleSpecialKey('arrow_up')}
>
<span class="text-xl"></span>
</button>
<button
class="flex-1 bg-vs-muted text-vs-bg hover:bg-vs-accent font-mono px-3 py-2 border-none rounded transition-colors text-sm"
@click=${() => this.handleSpecialKey('arrow_down')}
>
<span class="text-xl"></span>
</button>
<button
class="flex-1 bg-vs-muted text-vs-bg hover:bg-vs-accent font-mono px-3 py-2 border-none rounded transition-colors text-sm"
@click=${() => this.handleSpecialKey('arrow_left')}
>
<span class="text-xl"></span>
</button>
<button
class="flex-1 bg-vs-muted text-vs-bg hover:bg-vs-accent font-mono px-3 py-2 border-none rounded transition-colors text-sm"
@click=${() => this.handleSpecialKey('arrow_right')}
>
<span class="text-xl"></span>
</button>
</div>
<!-- Second row: Special keys -->
<div class="flex gap-2">
<button
class="bg-vs-user text-vs-text hover:bg-vs-accent font-mono px-3 py-2 border-none rounded transition-colors text-sm"
@click=${() => this.handleSpecialKey('\t')}
>
<span class="text-xl"></span>
</button>
<button
class="bg-vs-function text-vs-bg hover:bg-vs-highlight font-mono px-3 py-2 border-none rounded transition-colors text-sm"
@click=${() => this.handleSpecialKey('enter')}
>
<span class="text-xl"></span>
</button>
<button
class="bg-vs-warning text-vs-bg hover:bg-vs-highlight font-mono px-3 py-2 border-none rounded transition-colors text-sm"
@click=${() => this.handleSpecialKey('escape')}
>
ESC
</button>
<button
class="bg-vs-error text-vs-text hover:bg-vs-highlight font-mono px-3 py-2 border-none rounded transition-colors text-sm"
@click=${() => this.handleSpecialKey('\x03')}
>
^C
</button>
<button
class="flex-1 bg-vs-function text-vs-bg hover:bg-vs-highlight font-mono px-3 py-2 border-none rounded transition-colors text-sm"
@click=${this.handleMobileInputToggle}
>
TYPE
</button>
<!-- Second row: Special keys -->
<div class="flex gap-2">
<button
class="bg-vs-user text-vs-text hover:bg-vs-accent font-mono px-3 py-2 border-none rounded transition-colors text-sm"
@click=${() => this.handleSpecialKey('\t')}
>
<span class="text-xl"></span>
</button>
<button
class="bg-vs-function text-vs-bg hover:bg-vs-highlight font-mono px-3 py-2 border-none rounded transition-colors text-sm"
@click=${() => this.handleSpecialKey('enter')}
>
<span class="text-xl"></span>
</button>
<button
class="bg-vs-warning text-vs-bg hover:bg-vs-highlight font-mono px-3 py-2 border-none rounded transition-colors text-sm"
@click=${() => this.handleSpecialKey('escape')}
>
ESC
</button>
<button
class="bg-vs-error text-vs-text hover:bg-vs-highlight font-mono px-3 py-2 border-none rounded transition-colors text-sm"
@click=${() => this.handleSpecialKey('\x03')}
>
^C
</button>
<button
class="flex-1 bg-vs-function text-vs-bg hover:bg-vs-highlight font-mono px-3 py-2 border-none rounded transition-colors text-sm"
@click=${this.handleMobileInputToggle}
>
TYPE
</button>
</div>
</div>
</div>
` : ''}
`
: ''}
<!-- Full-Screen Input Overlay (only when opened) -->
${this.isMobile && this.showMobileInput ? html`
<div class="fixed inset-0 bg-vs-bg-secondary bg-opacity-95 z-50 flex flex-col" style="height: 100vh; height: 100dvh;">
<!-- Input Header -->
<div class="flex items-center justify-between p-4 border-b border-vs-border flex-shrink-0">
<div class="text-vs-text font-mono text-sm">Terminal Input</div>
<button
class="text-vs-muted hover:text-vs-text text-lg leading-none border-none bg-transparent cursor-pointer"
@click=${this.handleMobileInputToggle}
${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"
>
×
</button>
</div>
<!-- Input Area with dynamic height -->
<div class="flex-1 p-4 flex flex-col min-h-0">
<div class="text-vs-muted text-sm mb-2 flex-shrink-0">
Type your command(s) below. Supports multiline input.
</div>
<textarea
id="mobile-input-textarea"
class="flex-1 bg-vs-bg text-vs-text border border-vs-border font-mono text-sm p-4 resize-none outline-none"
placeholder="Enter your command here..."
.value=${this.mobileInputText}
@input=${this.handleMobileInputChange}
@keydown=${(e: KeyboardEvent) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.handleMobileInputSend();
}
}}
style="min-height: 120px; margin-bottom: 16px;"
></textarea>
</div>
<!-- Controls - Fixed above keyboard -->
<div id="mobile-controls" class="fixed bottom-0 left-0 right-0 p-4 border-t border-vs-border bg-vs-bg-secondary z-60" style="padding-bottom: max(1rem, env(safe-area-inset-bottom)); transform: translateY(0px);">
<!-- Send Buttons Row -->
<div class="flex gap-2 mb-3">
<div class="text-vs-text font-mono text-sm">Terminal Input</div>
<button
class="flex-1 bg-vs-user text-vs-text hover:bg-vs-accent font-mono px-4 py-3 border-none rounded transition-colors text-sm font-bold"
@click=${this.handleMobileInputSendOnly}
?disabled=${!this.mobileInputText.trim()}
class="text-vs-muted hover:text-vs-text text-lg leading-none border-none bg-transparent cursor-pointer"
@click=${this.handleMobileInputToggle}
>
SEND
</button>
<button
class="flex-1 bg-vs-function text-vs-bg hover:bg-vs-highlight font-mono px-4 py-3 border-none rounded transition-colors text-sm font-bold"
@click=${this.handleMobileInputSend}
?disabled=${!this.mobileInputText.trim()}
>
SEND + ENTER
×
</button>
</div>
<div class="text-vs-muted text-xs text-center">
SEND: text only SEND + ENTER: text with enter key
<!-- Input Area with dynamic height -->
<div class="flex-1 p-4 flex flex-col min-h-0">
<div class="text-vs-muted text-sm mb-2 flex-shrink-0">
Type your command(s) below. Supports multiline input.
</div>
<textarea
id="mobile-input-textarea"
class="flex-1 bg-vs-bg text-vs-text border border-vs-border font-mono text-sm p-4 resize-none outline-none"
placeholder="Enter your command here..."
.value=${this.mobileInputText}
@input=${this.handleMobileInputChange}
@keydown=${(e: KeyboardEvent) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.handleMobileInputSend();
}
}}
style="min-height: 120px; margin-bottom: 16px;"
></textarea>
</div>
<!-- Controls - Fixed above keyboard -->
<div
id="mobile-controls"
class="fixed bottom-0 left-0 right-0 p-4 border-t border-vs-border bg-vs-bg-secondary z-60"
style="padding-bottom: max(1rem, env(safe-area-inset-bottom)); transform: translateY(0px);"
>
<!-- Send Buttons Row -->
<div class="flex gap-2 mb-3">
<button
class="flex-1 bg-vs-user text-vs-text hover:bg-vs-accent font-mono px-4 py-3 border-none rounded transition-colors text-sm font-bold"
@click=${this.handleMobileInputSendOnly}
?disabled=${!this.mobileInputText.trim()}
>
SEND
</button>
<button
class="flex-1 bg-vs-function text-vs-bg hover:bg-vs-highlight font-mono px-4 py-3 border-none rounded transition-colors text-sm font-bold"
@click=${this.handleMobileInputSend}
?disabled=${!this.mobileInputText.trim()}
>
SEND + ENTER
</button>
</div>
<div class="text-vs-muted text-xs text-center">
SEND: text only SEND + ENTER: text with enter key
</div>
</div>
</div>
</div>
` : ''}
`
: ''}
</div>
`;
}
}
}

View file

@ -1,2 +1,2 @@
// Entry point for renderer bundle - exports XTerm-based renderer
export { Renderer } from './renderer';
export { Renderer } from './renderer';

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,
};
}
@ -379,4 +386,4 @@ export class Renderer {
terminalElement.style.pointerEvents = enabled ? 'auto' : 'none';
}
}
}
}

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;
@ -27,19 +26,19 @@ export class ScaleFitAddon implements ITerminalAddon {
public dispose(): void {}
public fit(): void {
// For full terminals, resize both font and dimensions
const dims = this.proposeDimensions();
if (!dims || !this._terminal || isNaN(dims.cols) || isNaN(dims.rows)) {
return;
}
// For full terminals, resize both font and dimensions
const dims = this.proposeDimensions();
if (!dims || !this._terminal || isNaN(dims.cols) || isNaN(dims.rows)) {
return;
}
// Only resize rows, keep cols the same (font scaling handles width)
if (this._terminal.rows !== dims.rows) {
this._terminal.resize(this._terminal.cols, dims.rows);
}
// Only resize rows, keep cols the same (font scaling handles width)
if (this._terminal.rows !== dims.rows) {
this._terminal.resize(this._terminal.cols, dims.rows);
}
// Force responsive sizing by overriding XTerm's fixed dimensions
this.forceResponsiveSizing();
// Force responsive sizing by overriding XTerm's fixed dimensions
this.forceResponsiveSizing();
}
public proposeDimensions(): ITerminalDimensions | undefined {
@ -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,8 +199,9 @@ 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')) +
parseInt(elementStyle.getPropertyValue('padding-right'));
const paddingHor =
parseInt(elementStyle.getPropertyValue('padding-left')) +
parseInt(elementStyle.getPropertyValue('padding-right'));
const availableWidth = parentWidth - paddingHor;
const charWidthRatio = 0.63;
@ -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,
};
}
}
@ -285,4 +291,4 @@ export class ScaleFitAddon implements ITerminalAddon {
xtermViewport.style.maxWidth = '100%';
}
}
}
}

File diff suppressed because it is too large Load diff