mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-15 12:55:52 +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_INCLUDE_ALL_APPICON_ASSETS = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = VibeTunnel/VibeTunnel.entitlements;
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(inherited)";
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
DEVELOPMENT_TEAM = 7F5Y92G2Z4;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
|
|
@ -480,12 +481,13 @@
|
|||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = VibeTunnel/VibeTunnel.entitlements;
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(inherited)";
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
DEVELOPMENT_TEAM = 7F5Y92G2Z4;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
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
|
||||
- `POST /api/sessions/:id/input` (630-695): Send input
|
||||
- `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
|
||||
- `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
|
||||
- `styles.css` - Global styles
|
||||
|
||||
#### Main Components
|
||||
#### Main Application Component
|
||||
- `app.ts` - Lit-based SPA (15-331)
|
||||
- URL-based routing `?session=<id>`
|
||||
- Global keyboard handlers
|
||||
- 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.ts` - Custom DOM terminal renderer (634-701)
|
||||
- Virtual scrolling (537-555)
|
||||
- Touch/momentum support
|
||||
- URL highlighting integration
|
||||
- Copy/paste handling
|
||||
- `session-view.ts` - Full-screen terminal view (12-1331)
|
||||
- SSE streaming (275-333)
|
||||
- Mobile input overlays
|
||||
- Resize synchronization
|
||||
- `vibe-terminal-buffer.ts` - Terminal buffer display component
|
||||
|
||||
##### `terminal.ts` - Custom DOM terminal renderer (17-1000+)
|
||||
Full terminal implementation with xterm.js for rendering and input handling.
|
||||
- Virtual scrolling (537-555)
|
||||
- Touch/momentum support
|
||||
- URL highlighting integration
|
||||
- Copy/paste handling
|
||||
- **Events fired**:
|
||||
- `terminal-ready` - When terminal is initialized and ready
|
||||
- `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
|
||||
- `app-header.ts` - Application header
|
||||
- `session-list.ts` - Active sessions list view
|
||||
- `session-card.ts` - Individual session card
|
||||
- `session-create-form.ts` - New session creation form
|
||||
- `file-browser.ts` - File browser component
|
||||
|
||||
##### `app-header.ts` - Application header (15-280+)
|
||||
Main application header with logo, title, navigation controls, and session status.
|
||||
- **Events fired**:
|
||||
- `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
|
||||
- `terminal-icon.ts` - Terminal icon
|
||||
- `copy-icon.ts` - Copy icon
|
||||
|
|
|
|||
|
|
@ -297,23 +297,38 @@ export class VibeTunnelApp extends LitElement {
|
|||
}
|
||||
|
||||
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 killPromises: Promise<boolean>[] = [];
|
||||
|
||||
sessionCards.forEach((card: SessionCard) => {
|
||||
// Check if this session is running
|
||||
if (card.session && card.session.status === 'running') {
|
||||
// Find all buttons within this card and look for the kill button
|
||||
const buttons = card.querySelectorAll('button');
|
||||
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();
|
||||
}
|
||||
});
|
||||
// Call the public kill method which handles animation and API call
|
||||
killPromises.push(card.kill());
|
||||
}
|
||||
});
|
||||
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -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 { customElement, property, state } from 'lit/decorators.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 { customElement, property, state } from 'lit/decorators.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 { customElement, property, state } from 'lit/decorators.js';
|
||||
import './vibe-terminal-buffer.js';
|
||||
|
|
@ -28,15 +40,23 @@ export class SessionCard extends LitElement {
|
|||
@property({ type: Object }) session!: Session;
|
||||
@state() private killing = false;
|
||||
@state() private killingFrame = 0;
|
||||
@state() private hasEscPrompt = false;
|
||||
@state() private isActive = false;
|
||||
|
||||
private killingInterval: number | null = null;
|
||||
private activityTimeout: number | null = null;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this.killingInterval) {
|
||||
clearInterval(this.killingInterval);
|
||||
}
|
||||
if (this.activityTimeout) {
|
||||
clearTimeout(this.activityTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
private handleCardClick() {
|
||||
|
|
@ -49,13 +69,39 @@ export class SessionCard extends LitElement {
|
|||
);
|
||||
}
|
||||
|
||||
private handleEscPromptChange(event: CustomEvent) {
|
||||
this.hasEscPrompt = event.detail.hasEscPrompt;
|
||||
private handleContentChanged() {
|
||||
// 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) {
|
||||
e.stopPropagation();
|
||||
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
|
||||
this.killing = true;
|
||||
|
|
@ -90,6 +136,7 @@ export class SessionCard extends LitElement {
|
|||
);
|
||||
|
||||
console.log(`Session ${this.session.id} killed successfully`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error killing session:', error);
|
||||
|
||||
|
|
@ -104,6 +151,7 @@ export class SessionCard extends LitElement {
|
|||
composed: true,
|
||||
})
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
// Stop animation in all cases
|
||||
this.stopKillingAnimation();
|
||||
|
|
@ -172,8 +220,8 @@ export class SessionCard extends LitElement {
|
|||
return html`
|
||||
<div
|
||||
class="card cursor-pointer overflow-hidden ${this.killing ? 'opacity-60' : ''} ${this
|
||||
.hasEscPrompt
|
||||
? 'border-2 border-status-warning'
|
||||
.isActive && this.session.status === 'running'
|
||||
? 'border-2 border-accent-green'
|
||||
: ''}"
|
||||
@click=${this.handleCardClick}
|
||||
>
|
||||
|
|
@ -236,7 +284,7 @@ export class SessionCard extends LitElement {
|
|||
.sessionId=${this.session.id}
|
||||
class="w-full h-full"
|
||||
style="pointer-events: none;"
|
||||
@esc-prompt-change=${this.handleEscPromptChange}
|
||||
@content-changed=${this.handleContentChanged}
|
||||
></vibe-terminal-buffer>
|
||||
`}
|
||||
</div>
|
||||
|
|
@ -249,6 +297,9 @@ export class SessionCard extends LitElement {
|
|||
<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>
|
||||
${this.getStatusText()}
|
||||
${this.session.status === 'running' && this.isActive
|
||||
? html`<span class="text-accent-green animate-pulse ml-1">●</span>`
|
||||
: ''}
|
||||
</span>
|
||||
${this.session.pid
|
||||
? 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 { customElement, property, state } from 'lit/decorators.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 { customElement, property, state } from 'lit/decorators.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 { customElement, property, state } from 'lit/decorators.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 { customElement, property, state } from 'lit/decorators.js';
|
||||
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 { customElement, property, state } from 'lit/decorators.js';
|
||||
import { TerminalRenderer, type BufferCell } from '../utils/terminal-renderer.js';
|
||||
import { bufferSubscriptionService } from '../services/buffer-subscription-service.js';
|
||||
import { cellsToText } from '../../shared/terminal-text-formatter.js';
|
||||
|
||||
interface BufferSnapshot {
|
||||
cols: number;
|
||||
|
|
@ -25,11 +35,11 @@ export class VibeTerminalBuffer extends LitElement {
|
|||
@state() private error: string | null = null;
|
||||
@state() private displayedFontSize = 14;
|
||||
@state() private visibleRows = 0;
|
||||
@state() private containsEscPrompt = false;
|
||||
|
||||
private container: HTMLElement | null = null;
|
||||
private resizeObserver: ResizeObserver | null = null;
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
private lastTextSnapshot: string | null = null;
|
||||
|
||||
// Moved to render() method above
|
||||
|
||||
|
|
@ -123,8 +133,8 @@ export class VibeTerminalBuffer extends LitElement {
|
|||
this.buffer = snapshot;
|
||||
this.error = null;
|
||||
|
||||
// Check if buffer contains "esc to interrupt" text
|
||||
this.checkForEscPrompt();
|
||||
// Check for content changes
|
||||
this.checkForContentChange();
|
||||
|
||||
// Recalculate dimensions now that we have the actual cols
|
||||
this.calculateDimensions();
|
||||
|
|
@ -134,28 +144,25 @@ export class VibeTerminalBuffer extends LitElement {
|
|||
});
|
||||
}
|
||||
|
||||
private checkForEscPrompt() {
|
||||
if (!this.buffer) {
|
||||
this.containsEscPrompt = false;
|
||||
private checkForContentChange() {
|
||||
if (!this.buffer) return;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Check if any line contains "esc to interrupt" (case insensitive)
|
||||
const searchText = 'esc to interrupt';
|
||||
const found = this.buffer.cells.some((row) => {
|
||||
const lineText = row
|
||||
.map((cell) => cell.char)
|
||||
.join('')
|
||||
.toLowerCase();
|
||||
return lineText.includes(searchText);
|
||||
});
|
||||
// Compare with last snapshot
|
||||
if (currentSnapshot !== this.lastTextSnapshot) {
|
||||
this.lastTextSnapshot = currentSnapshot;
|
||||
|
||||
if (found !== this.containsEscPrompt) {
|
||||
this.containsEscPrompt = found;
|
||||
// Dispatch event to notify parent
|
||||
// Dispatch content changed event
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('esc-prompt-change', {
|
||||
detail: { hasEscPrompt: found },
|
||||
new CustomEvent('content-changed', {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
|
|
@ -248,4 +255,13 @@ export class VibeTerminalBuffer extends LitElement {
|
|||
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 { StreamWatcher } from '../services/stream-watcher.js';
|
||||
import { RemoteRegistry } from '../services/remote-registry.js';
|
||||
import { cellsToText } from '../../shared/terminal-text-formatter.js';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
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
|
||||
router.get('/sessions/:sessionId/buffer', async (req, res) => {
|
||||
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