mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
Add comprehensive frontend component documentation
- Document all frontend components with JSDoc headers describing their purpose and events - Add @fires and @listens tags for all component events with detailed parameter descriptions - Update spec.md with complete Component Event Architecture section - Add shared terminal-text-formatter.ts for consistent text formatting between client/server - Implement event-driven activity detection replacing polling-based approach - Add content-changed event to vibe-terminal-buffer for activity monitoring - Remove ESC prompt detection in favor of general activity detection - Add plain text endpoint with optional style formatting (/api/sessions/:id/text?styles) 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
908c2d0375
commit
18a28992df
13 changed files with 473 additions and 55 deletions
|
|
@ -443,12 +443,13 @@
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
||||||
CODE_SIGN_ENTITLEMENTS = VibeTunnel/VibeTunnel.entitlements;
|
CODE_SIGN_ENTITLEMENTS = VibeTunnel/VibeTunnel.entitlements;
|
||||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = "$(inherited)";
|
CURRENT_PROJECT_VERSION = "$(inherited)";
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_ASSET_PATHS = "";
|
DEVELOPMENT_ASSET_PATHS = "";
|
||||||
|
DEVELOPMENT_TEAM = 7F5Y92G2Z4;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
|
|
@ -480,12 +481,13 @@
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
||||||
CODE_SIGN_ENTITLEMENTS = VibeTunnel/VibeTunnel.entitlements;
|
CODE_SIGN_ENTITLEMENTS = VibeTunnel/VibeTunnel.entitlements;
|
||||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = "$(inherited)";
|
CURRENT_PROJECT_VERSION = "$(inherited)";
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_ASSET_PATHS = "";
|
DEVELOPMENT_ASSET_PATHS = "";
|
||||||
|
DEVELOPMENT_TEAM = 7F5Y92G2Z4;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
|
|
|
||||||
120
web/spec.md
120
web/spec.md
|
|
@ -83,7 +83,12 @@ web/
|
||||||
- `GET /api/sessions/:id/stream` (517-627): SSE streaming of asciinema cast files
|
- `GET /api/sessions/:id/stream` (517-627): SSE streaming of asciinema cast files
|
||||||
- `POST /api/sessions/:id/input` (630-695): Send input
|
- `POST /api/sessions/:id/input` (630-695): Send input
|
||||||
- `POST /api/sessions/:id/resize` (698-767): Resize terminal
|
- `POST /api/sessions/:id/resize` (698-767): Resize terminal
|
||||||
- `GET /api/sessions/:id/buffer` (455-514): Binary snapshot of current terminal view
|
- `GET /api/sessions/:id/buffer` (569-631): Binary snapshot of current terminal view
|
||||||
|
- `GET /api/sessions/:id/text` (504-654): Plain text of current terminal view
|
||||||
|
- Optional `?styles` query parameter adds style markup
|
||||||
|
- Style format: `[style fg="color" bg="color" bold italic ...]text[/style]`
|
||||||
|
- Colors: indexed (0-255) as `"15"`, RGB as `"255,128,0"`
|
||||||
|
- Attributes: bold, dim, italic, underline, inverse, invisible, strikethrough
|
||||||
|
|
||||||
#### Remotes (`remotes.ts`) - HQ Mode Only
|
#### Remotes (`remotes.ts`) - HQ Mode Only
|
||||||
- `GET /api/remotes` (15-27): List registered servers
|
- `GET /api/remotes` (15-27): List registered servers
|
||||||
|
|
@ -158,30 +163,111 @@ Cells: Variable-length with type byte
|
||||||
- `test-terminals-entry.ts` - Test terminals entry point
|
- `test-terminals-entry.ts` - Test terminals entry point
|
||||||
- `styles.css` - Global styles
|
- `styles.css` - Global styles
|
||||||
|
|
||||||
#### Main Components
|
#### Main Application Component
|
||||||
- `app.ts` - Lit-based SPA (15-331)
|
- `app.ts` - Lit-based SPA (15-331)
|
||||||
- URL-based routing `?session=<id>`
|
- URL-based routing `?session=<id>`
|
||||||
- Global keyboard handlers
|
- Global keyboard handlers
|
||||||
- Error/success message handling (74-90)
|
- Error/success message handling (74-90)
|
||||||
|
- **Events fired**:
|
||||||
|
- `toggle-nav` - Toggle navigation
|
||||||
|
- `navigate-to-list` - Navigate to session list
|
||||||
|
- `error` - Display error message
|
||||||
|
- `success` - Display success message
|
||||||
|
- `navigate` - Navigate to specific session
|
||||||
|
- **Events listened**: Various events from child components
|
||||||
|
|
||||||
|
### Component Event Architecture
|
||||||
|
|
||||||
#### Terminal Components
|
#### Terminal Components
|
||||||
- `terminal.ts` - Custom DOM terminal renderer (634-701)
|
|
||||||
- Virtual scrolling (537-555)
|
##### `terminal.ts` - Custom DOM terminal renderer (17-1000+)
|
||||||
- Touch/momentum support
|
Full terminal implementation with xterm.js for rendering and input handling.
|
||||||
- URL highlighting integration
|
- Virtual scrolling (537-555)
|
||||||
- Copy/paste handling
|
- Touch/momentum support
|
||||||
- `session-view.ts` - Full-screen terminal view (12-1331)
|
- URL highlighting integration
|
||||||
- SSE streaming (275-333)
|
- Copy/paste handling
|
||||||
- Mobile input overlays
|
- **Events fired**:
|
||||||
- Resize synchronization
|
- `terminal-ready` - When terminal is initialized and ready
|
||||||
- `vibe-terminal-buffer.ts` - Terminal buffer display component
|
- `terminal-input` - When user types (detail: string)
|
||||||
|
- `terminal-resize` - When terminal is resized (detail: { cols: number, rows: number })
|
||||||
|
- `url-clicked` - When a URL is clicked (detail: string)
|
||||||
|
|
||||||
|
##### `session-view.ts` - Full-screen terminal view (29-1331)
|
||||||
|
Full-screen terminal view for an active session. Handles terminal I/O, streaming updates via SSE, file browser integration, and mobile overlays.
|
||||||
|
- SSE streaming (275-333)
|
||||||
|
- Mobile input overlays
|
||||||
|
- Resize synchronization
|
||||||
|
- **Events fired**:
|
||||||
|
- `navigate-to-list` - When navigating back to session list
|
||||||
|
- `error` - When an error occurs (detail: string)
|
||||||
|
- `warning` - When a warning occurs (detail: string)
|
||||||
|
- **Events listened**:
|
||||||
|
- `session-exit` - From SSE stream when session exits
|
||||||
|
- `terminal-ready` - From terminal component when ready
|
||||||
|
- `file-selected` - From file browser when file is selected
|
||||||
|
- `browser-cancel` - From file browser when cancelled
|
||||||
|
|
||||||
|
##### `vibe-terminal-buffer.ts` - Terminal buffer display (25-268)
|
||||||
|
Displays a read-only terminal buffer snapshot with automatic resizing. Subscribes to buffer updates via WebSocket and renders the terminal content.
|
||||||
|
- **Events fired**:
|
||||||
|
- `content-changed` - When terminal content changes (no detail)
|
||||||
|
|
||||||
|
#### Session Management Components
|
||||||
|
|
||||||
|
##### `session-list.ts` - Active sessions list view (61-700+)
|
||||||
|
Main session list view showing all active terminal sessions with real-time updates, search/filtering, and session management capabilities.
|
||||||
|
- **Events fired**:
|
||||||
|
- `navigate` - When clicking on a session (detail: { sessionId: string })
|
||||||
|
- `error` - When an error occurs (detail: string)
|
||||||
|
- `success` - When an operation succeeds (detail: string)
|
||||||
|
- `session-created` - When a new session is created (detail: Session)
|
||||||
|
- `session-updated` - When a session is updated (detail: Session)
|
||||||
|
- `sessions-changed` - When the session list changes
|
||||||
|
- `toggle-create-form` - When toggling the create form
|
||||||
|
- **Events listened**:
|
||||||
|
- `session-created` - From create form
|
||||||
|
- `cancel` - From create form
|
||||||
|
- `error` - From create form
|
||||||
|
|
||||||
|
##### `session-card.ts` - Individual session card (31-420+)
|
||||||
|
Individual session card component showing terminal preview and session controls. Displays a live terminal buffer preview and detects activity changes.
|
||||||
|
- **Events fired**:
|
||||||
|
- `view-session` - When viewing a session (detail: Session)
|
||||||
|
- `kill-session` - When killing a session (detail: Session)
|
||||||
|
- `copy-session-id` - When copying session ID (detail: Session)
|
||||||
|
- **Events listened**:
|
||||||
|
- `content-changed` - From vibe-terminal-buffer component
|
||||||
|
|
||||||
|
##### `session-create-form.ts` - New session creation form (27-381)
|
||||||
|
Modal dialog for creating new terminal sessions. Provides command input, working directory selection, and options for spawning in native terminal.
|
||||||
|
- **Events fired**:
|
||||||
|
- `session-created` - When session is successfully created (detail: { sessionId: string, message?: string })
|
||||||
|
- `cancel` - When form is cancelled
|
||||||
|
- `error` - When creation fails (detail: string)
|
||||||
|
- **Events listened**:
|
||||||
|
- `file-selected` - From file browser when directory is selected
|
||||||
|
- `browser-cancel` - From file browser when cancelled
|
||||||
|
|
||||||
#### UI Components
|
#### UI Components
|
||||||
- `app-header.ts` - Application header
|
|
||||||
- `session-list.ts` - Active sessions list view
|
##### `app-header.ts` - Application header (15-280+)
|
||||||
- `session-card.ts` - Individual session card
|
Main application header with logo, title, navigation controls, and session status.
|
||||||
- `session-create-form.ts` - New session creation form
|
- **Events fired**:
|
||||||
- `file-browser.ts` - File browser component
|
- `toggle-nav` - Toggle navigation menu
|
||||||
|
- `navigate-to-list` - Navigate to session list
|
||||||
|
- `toggle-create-form` - Toggle session create form
|
||||||
|
- `toggle-theme` - Toggle dark/light theme
|
||||||
|
- `open-settings` - Open settings modal
|
||||||
|
|
||||||
|
##### `file-browser.ts` - File browser component (48-665)
|
||||||
|
Modal file browser for navigating the filesystem and selecting files/directories. Supports Git status display, file preview with Monaco editor, and diff viewing.
|
||||||
|
- **Events fired**:
|
||||||
|
- `insert-path` - When inserting a file path into terminal (detail: { path: string, type: 'file' | 'directory' })
|
||||||
|
- `open-in-editor` - When opening a file in external editor (detail: { path: string })
|
||||||
|
- `directory-selected` - When a directory is selected in 'select' mode (detail: string)
|
||||||
|
- `browser-cancel` - When the browser is cancelled or closed
|
||||||
|
|
||||||
|
##### Icon Components
|
||||||
- `vibe-logo.ts` - Application logo
|
- `vibe-logo.ts` - Application logo
|
||||||
- `terminal-icon.ts` - Terminal icon
|
- `terminal-icon.ts` - Terminal icon
|
||||||
- `copy-icon.ts` - Copy icon
|
- `copy-icon.ts` - Copy icon
|
||||||
|
|
|
||||||
|
|
@ -297,23 +297,38 @@ export class VibeTunnelApp extends LitElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleKillAll() {
|
private async handleKillAll() {
|
||||||
// Find all session cards and trigger their kill buttons
|
// Find all session cards and call their kill method
|
||||||
const sessionCards = this.querySelectorAll<SessionCard>('session-card');
|
const sessionCards = this.querySelectorAll<SessionCard>('session-card');
|
||||||
|
const killPromises: Promise<boolean>[] = [];
|
||||||
|
|
||||||
sessionCards.forEach((card: SessionCard) => {
|
sessionCards.forEach((card: SessionCard) => {
|
||||||
// Check if this session is running
|
// Check if this session is running
|
||||||
if (card.session && card.session.status === 'running') {
|
if (card.session && card.session.status === 'running') {
|
||||||
// Find all buttons within this card and look for the kill button
|
// Call the public kill method which handles animation and API call
|
||||||
const buttons = card.querySelectorAll('button');
|
killPromises.push(card.kill());
|
||||||
buttons.forEach((button: HTMLButtonElement) => {
|
|
||||||
const buttonText = button.textContent?.toLowerCase() || '';
|
|
||||||
if (buttonText.includes('kill') && !buttonText.includes('killing')) {
|
|
||||||
// This is the kill button, click it to trigger the animation
|
|
||||||
button.click();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (killPromises.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for all kill operations to complete
|
||||||
|
const results = await Promise.all(killPromises);
|
||||||
|
const successCount = results.filter((r) => r).length;
|
||||||
|
|
||||||
|
if (successCount === killPromises.length) {
|
||||||
|
this.showSuccess(`All ${successCount} sessions killed successfully`);
|
||||||
|
} else if (successCount > 0) {
|
||||||
|
this.showError(`Killed ${successCount} of ${killPromises.length} sessions`);
|
||||||
|
} else {
|
||||||
|
this.showError('Failed to kill sessions');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh the session list after a short delay to allow animations to complete
|
||||||
|
setTimeout(() => {
|
||||||
|
this.loadSessions();
|
||||||
|
}, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleCleanExited() {
|
private handleCleanExited() {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,16 @@
|
||||||
|
/**
|
||||||
|
* App Header Component
|
||||||
|
*
|
||||||
|
* Displays the VibeTunnel logo, session statistics, and control buttons.
|
||||||
|
* Provides controls for creating sessions, toggling exited sessions visibility,
|
||||||
|
* killing all sessions, and cleaning up exited sessions.
|
||||||
|
*
|
||||||
|
* @fires create-session - When create button is clicked
|
||||||
|
* @fires hide-exited-change - When hide/show exited toggle is clicked (detail: boolean)
|
||||||
|
* @fires kill-all-sessions - When kill all button is clicked
|
||||||
|
* @fires clean-exited-sessions - When clean exited button is clicked
|
||||||
|
* @fires open-file-browser - When browse button is clicked
|
||||||
|
*/
|
||||||
import { LitElement, html } from 'lit';
|
import { LitElement, html } from 'lit';
|
||||||
import { customElement, property, state } from 'lit/decorators.js';
|
import { customElement, property, state } from 'lit/decorators.js';
|
||||||
import type { Session } from './session-list.js';
|
import type { Session } from './session-list.js';
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,14 @@
|
||||||
|
/**
|
||||||
|
* File Browser Component
|
||||||
|
*
|
||||||
|
* Modal file browser for navigating the filesystem and selecting files/directories.
|
||||||
|
* Supports Git status display, file preview with Monaco editor, and diff viewing.
|
||||||
|
*
|
||||||
|
* @fires insert-path - When inserting a file path into terminal (detail: { path: string, type: 'file' | 'directory' })
|
||||||
|
* @fires open-in-editor - When opening a file in external editor (detail: { path: string })
|
||||||
|
* @fires directory-selected - When a directory is selected in 'select' mode (detail: string)
|
||||||
|
* @fires browser-cancel - When the browser is cancelled or closed
|
||||||
|
*/
|
||||||
import { LitElement, html } from 'lit';
|
import { LitElement, html } from 'lit';
|
||||||
import { customElement, property, state } from 'lit/decorators.js';
|
import { customElement, property, state } from 'lit/decorators.js';
|
||||||
import type { Session } from './session-list.js';
|
import type { Session } from './session-list.js';
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,15 @@
|
||||||
|
/**
|
||||||
|
* Session Card Component
|
||||||
|
*
|
||||||
|
* Displays a single terminal session with its preview, status, and controls.
|
||||||
|
* Shows activity indicators when terminal content changes and provides kill functionality.
|
||||||
|
*
|
||||||
|
* @fires session-select - When card is clicked (detail: Session)
|
||||||
|
* @fires session-killed - When session is successfully killed (detail: { sessionId: string, session: Session })
|
||||||
|
* @fires session-kill-error - When kill operation fails (detail: { sessionId: string, error: string })
|
||||||
|
*
|
||||||
|
* @listens content-changed - From vibe-terminal-buffer when terminal content changes
|
||||||
|
*/
|
||||||
import { LitElement, html } from 'lit';
|
import { LitElement, html } from 'lit';
|
||||||
import { customElement, property, state } from 'lit/decorators.js';
|
import { customElement, property, state } from 'lit/decorators.js';
|
||||||
import './vibe-terminal-buffer.js';
|
import './vibe-terminal-buffer.js';
|
||||||
|
|
@ -28,15 +40,23 @@ export class SessionCard extends LitElement {
|
||||||
@property({ type: Object }) session!: Session;
|
@property({ type: Object }) session!: Session;
|
||||||
@state() private killing = false;
|
@state() private killing = false;
|
||||||
@state() private killingFrame = 0;
|
@state() private killingFrame = 0;
|
||||||
@state() private hasEscPrompt = false;
|
@state() private isActive = false;
|
||||||
|
|
||||||
private killingInterval: number | null = null;
|
private killingInterval: number | null = null;
|
||||||
|
private activityTimeout: number | null = null;
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
if (this.killingInterval) {
|
if (this.killingInterval) {
|
||||||
clearInterval(this.killingInterval);
|
clearInterval(this.killingInterval);
|
||||||
}
|
}
|
||||||
|
if (this.activityTimeout) {
|
||||||
|
clearTimeout(this.activityTimeout);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleCardClick() {
|
private handleCardClick() {
|
||||||
|
|
@ -49,13 +69,39 @@ export class SessionCard extends LitElement {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleEscPromptChange(event: CustomEvent) {
|
private handleContentChanged() {
|
||||||
this.hasEscPrompt = event.detail.hasEscPrompt;
|
// Only track activity for running sessions
|
||||||
|
if (this.session.status !== 'running') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content changed, immediately mark as active
|
||||||
|
this.isActive = true;
|
||||||
|
|
||||||
|
// Clear existing timeout
|
||||||
|
if (this.activityTimeout) {
|
||||||
|
clearTimeout(this.activityTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set timeout to clear activity after 500ms of no changes
|
||||||
|
this.activityTimeout = window.setTimeout(() => {
|
||||||
|
this.isActive = false;
|
||||||
|
this.activityTimeout = null;
|
||||||
|
}, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleKillClick(e: Event) {
|
private async handleKillClick(e: Event) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
await this.kill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public method to kill the session with animation
|
||||||
|
public async kill(): Promise<boolean> {
|
||||||
|
// Don't kill if already killing or session is not running
|
||||||
|
if (this.killing || this.session.status !== 'running') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Start killing animation
|
// Start killing animation
|
||||||
this.killing = true;
|
this.killing = true;
|
||||||
|
|
@ -90,6 +136,7 @@ export class SessionCard extends LitElement {
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`Session ${this.session.id} killed successfully`);
|
console.log(`Session ${this.session.id} killed successfully`);
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error killing session:', error);
|
console.error('Error killing session:', error);
|
||||||
|
|
||||||
|
|
@ -104,6 +151,7 @@ export class SessionCard extends LitElement {
|
||||||
composed: true,
|
composed: true,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
// Stop animation in all cases
|
// Stop animation in all cases
|
||||||
this.stopKillingAnimation();
|
this.stopKillingAnimation();
|
||||||
|
|
@ -172,8 +220,8 @@ export class SessionCard extends LitElement {
|
||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
class="card cursor-pointer overflow-hidden ${this.killing ? 'opacity-60' : ''} ${this
|
class="card cursor-pointer overflow-hidden ${this.killing ? 'opacity-60' : ''} ${this
|
||||||
.hasEscPrompt
|
.isActive && this.session.status === 'running'
|
||||||
? 'border-2 border-status-warning'
|
? 'border-2 border-accent-green'
|
||||||
: ''}"
|
: ''}"
|
||||||
@click=${this.handleCardClick}
|
@click=${this.handleCardClick}
|
||||||
>
|
>
|
||||||
|
|
@ -236,7 +284,7 @@ export class SessionCard extends LitElement {
|
||||||
.sessionId=${this.session.id}
|
.sessionId=${this.session.id}
|
||||||
class="w-full h-full"
|
class="w-full h-full"
|
||||||
style="pointer-events: none;"
|
style="pointer-events: none;"
|
||||||
@esc-prompt-change=${this.handleEscPromptChange}
|
@content-changed=${this.handleContentChanged}
|
||||||
></vibe-terminal-buffer>
|
></vibe-terminal-buffer>
|
||||||
`}
|
`}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -249,6 +297,9 @@ export class SessionCard extends LitElement {
|
||||||
<span class="${this.getStatusColor()} text-xs flex items-center gap-1 flex-shrink-0">
|
<span class="${this.getStatusColor()} text-xs flex items-center gap-1 flex-shrink-0">
|
||||||
<div class="w-2 h-2 rounded-full ${this.getStatusDotColor()}"></div>
|
<div class="w-2 h-2 rounded-full ${this.getStatusDotColor()}"></div>
|
||||||
${this.getStatusText()}
|
${this.getStatusText()}
|
||||||
|
${this.session.status === 'running' && this.isActive
|
||||||
|
? html`<span class="text-accent-green animate-pulse ml-1">●</span>`
|
||||||
|
: ''}
|
||||||
</span>
|
</span>
|
||||||
${this.session.pid
|
${this.session.pid
|
||||||
? html`
|
? html`
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,16 @@
|
||||||
|
/**
|
||||||
|
* Session Create Form Component
|
||||||
|
*
|
||||||
|
* Modal dialog for creating new terminal sessions. Provides command input,
|
||||||
|
* working directory selection, and options for spawning in native terminal.
|
||||||
|
*
|
||||||
|
* @fires session-created - When session is successfully created (detail: { sessionId: string, message?: string })
|
||||||
|
* @fires cancel - When form is cancelled
|
||||||
|
* @fires error - When creation fails (detail: string)
|
||||||
|
*
|
||||||
|
* @listens file-selected - From file browser when directory is selected
|
||||||
|
* @listens browser-cancel - From file browser when cancelled
|
||||||
|
*/
|
||||||
import { LitElement, html, PropertyValues } from 'lit';
|
import { LitElement, html, PropertyValues } from 'lit';
|
||||||
import { customElement, property, state } from 'lit/decorators.js';
|
import { customElement, property, state } from 'lit/decorators.js';
|
||||||
import './file-browser.js';
|
import './file-browser.js';
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,21 @@
|
||||||
|
/**
|
||||||
|
* Session List Component
|
||||||
|
*
|
||||||
|
* Displays a grid of session cards and manages the session creation modal.
|
||||||
|
* Handles session filtering (hide/show exited) and cleanup operations.
|
||||||
|
*
|
||||||
|
* @fires navigate-to-session - When a session is selected (detail: { sessionId: string })
|
||||||
|
* @fires refresh - When session list needs refreshing
|
||||||
|
* @fires error - When an error occurs (detail: string)
|
||||||
|
* @fires session-created - When a new session is created (detail: { sessionId: string, message?: string })
|
||||||
|
* @fires create-modal-close - When create modal should close
|
||||||
|
* @fires hide-exited-change - When hide exited state changes (detail: boolean)
|
||||||
|
* @fires kill-all-sessions - When all sessions should be killed
|
||||||
|
*
|
||||||
|
* @listens session-killed - From session-card when a session is killed
|
||||||
|
* @listens session-kill-error - From session-card when kill fails
|
||||||
|
* @listens clean-exited-sessions - To trigger cleanup of exited sessions
|
||||||
|
*/
|
||||||
import { LitElement, html } from 'lit';
|
import { LitElement, html } from 'lit';
|
||||||
import { customElement, property, state } from 'lit/decorators.js';
|
import { customElement, property, state } from 'lit/decorators.js';
|
||||||
import { repeat } from 'lit/directives/repeat.js';
|
import { repeat } from 'lit/directives/repeat.js';
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,18 @@
|
||||||
|
/**
|
||||||
|
* Session View Component
|
||||||
|
*
|
||||||
|
* Full-screen terminal view for an active session. Handles terminal I/O,
|
||||||
|
* streaming updates via SSE, file browser integration, and mobile overlays.
|
||||||
|
*
|
||||||
|
* @fires navigate-to-list - When navigating back to session list
|
||||||
|
* @fires error - When an error occurs (detail: string)
|
||||||
|
* @fires warning - When a warning occurs (detail: string)
|
||||||
|
*
|
||||||
|
* @listens session-exit - From SSE stream when session exits
|
||||||
|
* @listens terminal-ready - From terminal component when ready
|
||||||
|
* @listens file-selected - From file browser when file is selected
|
||||||
|
* @listens browser-cancel - From file browser when cancelled
|
||||||
|
*/
|
||||||
import { LitElement, PropertyValues, html } from 'lit';
|
import { LitElement, PropertyValues, html } from 'lit';
|
||||||
import { customElement, property, state } from 'lit/decorators.js';
|
import { customElement, property, state } from 'lit/decorators.js';
|
||||||
import type { Session } from './session-list.js';
|
import type { Session } from './session-list.js';
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,14 @@
|
||||||
|
/**
|
||||||
|
* Terminal Component
|
||||||
|
*
|
||||||
|
* Full terminal implementation with xterm.js for rendering and input handling.
|
||||||
|
* Supports copy/paste, URL highlighting, custom scrolling, and responsive sizing.
|
||||||
|
*
|
||||||
|
* @fires terminal-ready - When terminal is initialized and ready
|
||||||
|
* @fires terminal-input - When user types (detail: string)
|
||||||
|
* @fires terminal-resize - When terminal is resized (detail: { cols: number, rows: number })
|
||||||
|
* @fires url-clicked - When a URL is clicked (detail: string)
|
||||||
|
*/
|
||||||
import { LitElement, html, PropertyValues } from 'lit';
|
import { LitElement, html, PropertyValues } from 'lit';
|
||||||
import { customElement, property, state } from 'lit/decorators.js';
|
import { customElement, property, state } from 'lit/decorators.js';
|
||||||
import { Terminal as XtermTerminal, IBufferLine, IBufferCell } from '@xterm/headless';
|
import { Terminal as XtermTerminal, IBufferLine, IBufferCell } from '@xterm/headless';
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,17 @@
|
||||||
|
/**
|
||||||
|
* VibeTunnel Terminal Buffer Component
|
||||||
|
*
|
||||||
|
* Displays a read-only terminal buffer snapshot with automatic resizing.
|
||||||
|
* Subscribes to buffer updates via WebSocket and renders the terminal content.
|
||||||
|
* Detects content changes and emits events when the terminal content updates.
|
||||||
|
*
|
||||||
|
* @fires content-changed - When terminal content changes (no detail)
|
||||||
|
*/
|
||||||
import { LitElement, html } from 'lit';
|
import { LitElement, html } from 'lit';
|
||||||
import { customElement, property, state } from 'lit/decorators.js';
|
import { customElement, property, state } from 'lit/decorators.js';
|
||||||
import { TerminalRenderer, type BufferCell } from '../utils/terminal-renderer.js';
|
import { TerminalRenderer, type BufferCell } from '../utils/terminal-renderer.js';
|
||||||
import { bufferSubscriptionService } from '../services/buffer-subscription-service.js';
|
import { bufferSubscriptionService } from '../services/buffer-subscription-service.js';
|
||||||
|
import { cellsToText } from '../../shared/terminal-text-formatter.js';
|
||||||
|
|
||||||
interface BufferSnapshot {
|
interface BufferSnapshot {
|
||||||
cols: number;
|
cols: number;
|
||||||
|
|
@ -25,11 +35,11 @@ export class VibeTerminalBuffer extends LitElement {
|
||||||
@state() private error: string | null = null;
|
@state() private error: string | null = null;
|
||||||
@state() private displayedFontSize = 14;
|
@state() private displayedFontSize = 14;
|
||||||
@state() private visibleRows = 0;
|
@state() private visibleRows = 0;
|
||||||
@state() private containsEscPrompt = false;
|
|
||||||
|
|
||||||
private container: HTMLElement | null = null;
|
private container: HTMLElement | null = null;
|
||||||
private resizeObserver: ResizeObserver | null = null;
|
private resizeObserver: ResizeObserver | null = null;
|
||||||
private unsubscribe: (() => void) | null = null;
|
private unsubscribe: (() => void) | null = null;
|
||||||
|
private lastTextSnapshot: string | null = null;
|
||||||
|
|
||||||
// Moved to render() method above
|
// Moved to render() method above
|
||||||
|
|
||||||
|
|
@ -123,8 +133,8 @@ export class VibeTerminalBuffer extends LitElement {
|
||||||
this.buffer = snapshot;
|
this.buffer = snapshot;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
|
|
||||||
// Check if buffer contains "esc to interrupt" text
|
// Check for content changes
|
||||||
this.checkForEscPrompt();
|
this.checkForContentChange();
|
||||||
|
|
||||||
// Recalculate dimensions now that we have the actual cols
|
// Recalculate dimensions now that we have the actual cols
|
||||||
this.calculateDimensions();
|
this.calculateDimensions();
|
||||||
|
|
@ -134,28 +144,25 @@ export class VibeTerminalBuffer extends LitElement {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private checkForEscPrompt() {
|
private checkForContentChange() {
|
||||||
if (!this.buffer) {
|
if (!this.buffer) return;
|
||||||
this.containsEscPrompt = false;
|
|
||||||
|
// Get current text with styles to detect any visual changes
|
||||||
|
const currentSnapshot = this.getTextWithStyles(true);
|
||||||
|
|
||||||
|
// Skip the first check
|
||||||
|
if (this.lastTextSnapshot === null) {
|
||||||
|
this.lastTextSnapshot = currentSnapshot;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if any line contains "esc to interrupt" (case insensitive)
|
// Compare with last snapshot
|
||||||
const searchText = 'esc to interrupt';
|
if (currentSnapshot !== this.lastTextSnapshot) {
|
||||||
const found = this.buffer.cells.some((row) => {
|
this.lastTextSnapshot = currentSnapshot;
|
||||||
const lineText = row
|
|
||||||
.map((cell) => cell.char)
|
|
||||||
.join('')
|
|
||||||
.toLowerCase();
|
|
||||||
return lineText.includes(searchText);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (found !== this.containsEscPrompt) {
|
// Dispatch content changed event
|
||||||
this.containsEscPrompt = found;
|
|
||||||
// Dispatch event to notify parent
|
|
||||||
this.dispatchEvent(
|
this.dispatchEvent(
|
||||||
new CustomEvent('esc-prompt-change', {
|
new CustomEvent('content-changed', {
|
||||||
detail: { hasEscPrompt: found },
|
|
||||||
bubbles: true,
|
bubbles: true,
|
||||||
composed: true,
|
composed: true,
|
||||||
})
|
})
|
||||||
|
|
@ -248,4 +255,13 @@ export class VibeTerminalBuffer extends LitElement {
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current buffer text with optional style markup
|
||||||
|
* Returns the text in the same format as the /api/sessions/:id/text?styles endpoint
|
||||||
|
*/
|
||||||
|
getTextWithStyles(includeStyles = true): string {
|
||||||
|
if (!this.buffer) return '';
|
||||||
|
return cellsToText(this.buffer.cells, includeStyles);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { PtyManager, PtyError } from '../pty/index.js';
|
||||||
import { TerminalManager } from '../services/terminal-manager.js';
|
import { TerminalManager } from '../services/terminal-manager.js';
|
||||||
import { StreamWatcher } from '../services/stream-watcher.js';
|
import { StreamWatcher } from '../services/stream-watcher.js';
|
||||||
import { RemoteRegistry } from '../services/remote-registry.js';
|
import { RemoteRegistry } from '../services/remote-registry.js';
|
||||||
|
import { cellsToText } from '../../shared/terminal-text-formatter.js';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
|
|
@ -500,6 +501,66 @@ export function createSessionRoutes(config: SessionRoutesConfig): Router {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get session plain text
|
||||||
|
router.get('/sessions/:sessionId/text', async (req, res) => {
|
||||||
|
const sessionId = req.params.sessionId;
|
||||||
|
const includeStyles = req.query.styles !== undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// If in HQ mode, check if this is a remote session
|
||||||
|
if (isHQMode && remoteRegistry) {
|
||||||
|
const remote = remoteRegistry.getRemoteBySessionId(sessionId);
|
||||||
|
if (remote) {
|
||||||
|
// Forward text request to remote server
|
||||||
|
try {
|
||||||
|
const url = new URL(`${remote.url}/api/sessions/${sessionId}/text`);
|
||||||
|
if (includeStyles) {
|
||||||
|
url.searchParams.set('styles', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${remote.token}`,
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return res.status(response.status).json(await response.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward the text response
|
||||||
|
const text = await response.text();
|
||||||
|
res.setHeader('Content-Type', 'text/plain');
|
||||||
|
return res.send(text);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to get text from remote ${remote.name}:`, error);
|
||||||
|
return res.status(503).json({ error: 'Failed to reach remote server' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local session handling
|
||||||
|
const session = ptyManager.getSession(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
return res.status(404).json({ error: 'Session not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get terminal buffer snapshot
|
||||||
|
const snapshot = await terminalManager.getBufferSnapshot(sessionId);
|
||||||
|
|
||||||
|
// Use shared formatter to convert cells to text
|
||||||
|
const plainText = cellsToText(snapshot.cells, includeStyles);
|
||||||
|
|
||||||
|
// Send as plain text
|
||||||
|
res.setHeader('Content-Type', 'text/plain');
|
||||||
|
res.send(plainText);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting plain text:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to get terminal text' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Get session buffer
|
// Get session buffer
|
||||||
router.get('/sessions/:sessionId/buffer', async (req, res) => {
|
router.get('/sessions/:sessionId/buffer', async (req, res) => {
|
||||||
const sessionId = req.params.sessionId;
|
const sessionId = req.params.sessionId;
|
||||||
|
|
|
||||||
106
web/src/shared/terminal-text-formatter.ts
Normal file
106
web/src/shared/terminal-text-formatter.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
/**
|
||||||
|
* Shared utility for formatting terminal text with style markup
|
||||||
|
* Used by both client and server for consistent text representation
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface BufferCell {
|
||||||
|
char: string;
|
||||||
|
width: number;
|
||||||
|
fg?: number;
|
||||||
|
bg?: number;
|
||||||
|
attributes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format style attributes for a cell into a string
|
||||||
|
*/
|
||||||
|
export function formatCellStyle(cell: BufferCell): string {
|
||||||
|
const attrs: string[] = [];
|
||||||
|
|
||||||
|
// Foreground color
|
||||||
|
if (cell.fg !== undefined) {
|
||||||
|
if (cell.fg >= 0 && cell.fg <= 255) {
|
||||||
|
attrs.push(`fg="${cell.fg}"`);
|
||||||
|
} else {
|
||||||
|
const r = (cell.fg >> 16) & 0xff;
|
||||||
|
const g = (cell.fg >> 8) & 0xff;
|
||||||
|
const b = cell.fg & 0xff;
|
||||||
|
attrs.push(`fg="${r},${g},${b}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background color
|
||||||
|
if (cell.bg !== undefined) {
|
||||||
|
if (cell.bg >= 0 && cell.bg <= 255) {
|
||||||
|
attrs.push(`bg="${cell.bg}"`);
|
||||||
|
} else {
|
||||||
|
const r = (cell.bg >> 16) & 0xff;
|
||||||
|
const g = (cell.bg >> 8) & 0xff;
|
||||||
|
const b = cell.bg & 0xff;
|
||||||
|
attrs.push(`bg="${r},${g},${b}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text attributes
|
||||||
|
if (cell.attributes) {
|
||||||
|
if (cell.attributes & 0x01) attrs.push('bold');
|
||||||
|
if (cell.attributes & 0x02) attrs.push('dim');
|
||||||
|
if (cell.attributes & 0x04) attrs.push('italic');
|
||||||
|
if (cell.attributes & 0x08) attrs.push('underline');
|
||||||
|
if (cell.attributes & 0x10) attrs.push('inverse');
|
||||||
|
if (cell.attributes & 0x20) attrs.push('invisible');
|
||||||
|
if (cell.attributes & 0x40) attrs.push('strikethrough');
|
||||||
|
}
|
||||||
|
|
||||||
|
return attrs.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert buffer cells to text with optional style markup
|
||||||
|
*/
|
||||||
|
export function cellsToText(cells: BufferCell[][], includeStyles = true): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
for (const row of cells) {
|
||||||
|
let line = '';
|
||||||
|
|
||||||
|
if (includeStyles) {
|
||||||
|
let currentStyle = '';
|
||||||
|
let currentText = '';
|
||||||
|
|
||||||
|
const flushStyleGroup = () => {
|
||||||
|
if (currentText) {
|
||||||
|
if (currentStyle) {
|
||||||
|
line += `[style ${currentStyle}]${currentText}[/style]`;
|
||||||
|
} else {
|
||||||
|
line += currentText;
|
||||||
|
}
|
||||||
|
currentText = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const cell of row) {
|
||||||
|
const style = formatCellStyle(cell);
|
||||||
|
|
||||||
|
if (style !== currentStyle) {
|
||||||
|
flushStyleGroup();
|
||||||
|
currentStyle = style;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentText += cell.char;
|
||||||
|
}
|
||||||
|
|
||||||
|
flushStyleGroup();
|
||||||
|
} else {
|
||||||
|
// Plain text without styles
|
||||||
|
for (const cell of row) {
|
||||||
|
line += cell.char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim trailing spaces but preserve empty lines
|
||||||
|
lines.push(line.trimEnd());
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue