mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-03-25 09:25:50 +00:00
feat: add asciinema stream pruning for clear sequences (#155)
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
parent
ba8d7be280
commit
40d2cd1998
6 changed files with 6704 additions and 122 deletions
306
web/spec.md
306
web/spec.md
|
|
@ -6,12 +6,13 @@ A comprehensive navigation guide for the VibeTunnel web terminal system.
|
|||
|
||||
VibeTunnel is a web-based terminal multiplexer with distributed architecture support. It provides:
|
||||
- PTY-based terminal sessions via node-pty
|
||||
- Real-time terminal streaming via SSE (asciinema cast files)
|
||||
- Real-time terminal streaming via SSE (asciinema cast v2 format)
|
||||
- Binary-optimized buffer synchronization (current viewport via WebSocket)
|
||||
- Distributed HQ/remote server architecture
|
||||
- Web UI with full terminal emulation
|
||||
- Push notifications for terminal bell events
|
||||
- Multi-method authentication (SSH keys, JWT, PAM)
|
||||
- Advanced terminal title management
|
||||
|
||||
## Directory Structure
|
||||
|
||||
|
|
@ -23,9 +24,10 @@ web/
|
|||
│ │ ├── pty/ # PTY management
|
||||
│ │ ├── routes/ # API endpoints
|
||||
│ │ ├── services/ # Core services
|
||||
│ │ ├── utils/ # Server utilities
|
||||
│ │ ├── server.ts # Main server implementation
|
||||
│ │ └── fwd.ts # CLI forwarding tool
|
||||
│ ├── client/ # Lit-based web UI
|
||||
│ ├── client/ # Lit-based web UI
|
||||
│ │ ├── assets/ # Static files (fonts, icons, html)
|
||||
│ │ ├── components/ # UI components
|
||||
│ │ ├── services/ # Client services
|
||||
|
|
@ -41,104 +43,133 @@ web/
|
|||
### Core Components
|
||||
|
||||
#### Entry Points
|
||||
- `cli.ts` (1-62): Main entry point, routes to server or forward mode
|
||||
- `server/server.ts` (1-953): Core server implementation with Express app factory
|
||||
- CLI parsing (132-236): Extensive configuration options
|
||||
- Server modes (432-444): Normal, HQ, Remote initialization
|
||||
- WebSocket upgrade (564-677): Authentication and buffer streaming
|
||||
- Graceful shutdown (888-944): Cleanup intervals and service termination
|
||||
- `cli.ts:1-62`: Main entry point, routes to server or forward mode
|
||||
- Version display, debug initialization, mode routing
|
||||
- `server.ts:1-1017`: Core server implementation with Express app factory
|
||||
- CLI parsing: `142-246`
|
||||
- Server modes: `432-455` (Normal, HQ, Remote initialization)
|
||||
- WebSocket upgrade: `585-707` (Authentication and buffer streaming)
|
||||
- Graceful shutdown: `893-1017` (Cleanup intervals and service termination)
|
||||
|
||||
### Authentication (`middleware/auth.ts`)
|
||||
- Multi-method authentication (1-159)
|
||||
- Multi-method authentication: `1-159`
|
||||
- SSH key authentication with Ed25519 support
|
||||
- Basic auth (username/password)
|
||||
- Bearer token for HQ↔Remote communication
|
||||
- JWT tokens for session persistence
|
||||
- Local bypass for localhost connections (24-48, 68-87)
|
||||
- Local bypass for localhost connections: `24-48`, `68-87`
|
||||
- Query parameter token support for EventSource
|
||||
|
||||
### Session Management
|
||||
|
||||
#### PTY Manager (`pty/pty-manager.ts`)
|
||||
- Session creation (78-163): Spawns PTY processes with node-pty
|
||||
- **Automatic alias resolution** (191-204): Uses `ProcessUtils.resolveCommand()`
|
||||
- Terminal resize handling (63-157): Dimension synchronization
|
||||
- Control pipe support using file watching on all platforms
|
||||
- Session creation: `173-400` (Spawns PTY processes with node-pty)
|
||||
- **Automatic alias resolution**: `204-217` (Uses `ProcessUtils.resolveCommand()`)
|
||||
- Terminal resize handling: `115-168` (Native terminal dimension sync)
|
||||
- Control pipe support using file watching
|
||||
- Bell event emission for push notifications
|
||||
- Clean termination with SIGTERM→SIGKILL escalation
|
||||
|
||||
#### Process Utils (`pty/process-utils.ts`)
|
||||
- `resolveCommand()` (242-378): Detects if command exists in PATH
|
||||
- `resolveCommand()`: `241-388` (Detects if command exists in PATH)
|
||||
- Uses `which` (Unix) or `where` (Windows) to check existence
|
||||
- Returns appropriate shell command for aliases/builtins
|
||||
- Sources shell config files for proper alias support
|
||||
- `getUserShell()` (384-484): Determines user's preferred shell
|
||||
- `getUserShell()`: `394-484` (Determines user's preferred shell)
|
||||
- Checks `$SHELL` environment variable first
|
||||
- Platform-specific fallbacks (pwsh/cmd on Windows, zsh/bash on Unix)
|
||||
- Interactive shell detection (220-235): Auto-adds `-i -l` flags
|
||||
- Interactive shell detection: `220-235` (Auto-adds `-i -l` flags)
|
||||
|
||||
#### Session Manager (`pty/session-manager.ts`)
|
||||
- Session persistence in `~/.vibetunnel/control/`
|
||||
- Filesystem-based session discovery
|
||||
- Zombie session cleanup
|
||||
- Zombie session cleanup: `337-366`
|
||||
- Atomic writes for session metadata: `99-115`
|
||||
|
||||
#### Terminal Manager (`services/terminal-manager.ts`)
|
||||
- Headless xterm.js for server-side state
|
||||
- Binary buffer snapshot generation
|
||||
- Headless xterm.js for server-side state: `47-76`
|
||||
- Binary buffer snapshot generation: `261-292`
|
||||
- Watches asciinema cast files and applies to terminal
|
||||
- Debounced buffer change notifications
|
||||
- Debounced buffer change notifications (100ms)
|
||||
|
||||
### API Routes (`routes/`)
|
||||
|
||||
#### Sessions (`sessions.ts`)
|
||||
- `GET /api/sessions` (51-124): List all sessions
|
||||
- `GET /api/sessions`: `51-124` - List all sessions
|
||||
- Returns array with `source: 'local' | 'remote'`
|
||||
- HQ mode: Aggregates from all remote servers
|
||||
- `POST /api/sessions` (126-265): Create session
|
||||
- Body: `{ command, workingDir?, name?, remoteId?, spawn_terminal? }`
|
||||
- `POST /api/sessions`: `126-265` - Create session
|
||||
- Body: `{ command, workingDir?, name?, remoteId?, spawn_terminal?, cols?, rows?, titleMode? }`
|
||||
- Returns: `{ sessionId: string, message?: string }`
|
||||
- `GET /api/sessions/:id` (369-410): Get session info
|
||||
- `DELETE /api/sessions/:id` (413-467): Kill session
|
||||
- `DELETE /api/sessions/:id/cleanup` (470-518): Clean session files
|
||||
- `POST /api/cleanup-exited` (521-598): Clean all exited sessions
|
||||
- `GET /api/sessions/:id`: `369-410` - Get session info
|
||||
- `DELETE /api/sessions/:id`: `413-467` - Kill session
|
||||
- `DELETE /api/sessions/:id/cleanup`: `470-518` - Clean session files
|
||||
- `POST /api/cleanup-exited`: `521-598` - Clean all exited sessions
|
||||
|
||||
#### Session I/O
|
||||
- `POST /api/sessions/:id/input` (874-950): Send keyboard input
|
||||
- `POST /api/sessions/:id/input`: `874-950` - Send keyboard input
|
||||
- Body: `{ text: string }` OR `{ key: SpecialKey }`
|
||||
- `POST /api/sessions/:id/resize` (953-1025): Resize terminal
|
||||
- `POST /api/sessions/:id/resize`: `953-1025` - Resize terminal
|
||||
- Body: `{ cols: number, rows: number }`
|
||||
- `POST /api/sessions/:id/reset-size` (1028-1083): Reset to native size
|
||||
- `POST /api/sessions/:id/reset-size`: `1028-1083` - Reset to native size
|
||||
|
||||
#### Session Output
|
||||
- `GET /api/sessions/:id/stream` (723-871): SSE streaming
|
||||
- `GET /api/sessions/:id/stream`: `723-871` - SSE streaming
|
||||
- Streams asciinema v2 format with custom exit event
|
||||
- Replays existing content, then real-time streaming
|
||||
- `GET /api/sessions/:id/buffer` (662-721): Binary buffer snapshot
|
||||
- `GET /api/sessions/:id/text` (601-659): Plain text output
|
||||
- `GET /api/sessions/:id/buffer`: `662-721` - Binary buffer snapshot
|
||||
- `GET /api/sessions/:id/text`: `601-659` - Plain text output
|
||||
- Optional `?styles` for markup: `[style fg="15" bold]text[/style]`
|
||||
|
||||
#### Activity Monitoring
|
||||
- `GET /api/sessions/activity` (268-324): All sessions activity
|
||||
- `GET /api/sessions/:id/activity` (327-366): Single session activity
|
||||
- `GET /api/sessions/activity`: `268-324` - All sessions activity
|
||||
- `GET /api/sessions/:id/activity`: `327-366` - Single session activity
|
||||
- Returns: `{ isActive: boolean, timestamp: string, session: SessionInfo }`
|
||||
|
||||
#### WebSocket Input (`routes/websocket-input.ts`)
|
||||
- `GET /ws/input?sessionId=<id>&token=<token>`: Low-latency input
|
||||
- Fire-and-forget protocol for keyboard/mouse input
|
||||
- Direct PTY forwarding for performance
|
||||
|
||||
#### Remotes (`remotes.ts`) - HQ Mode Only
|
||||
- `GET /api/remotes` (19-33): List registered servers
|
||||
- `POST /api/remotes/register` (36-64): Register remote
|
||||
- `DELETE /api/remotes/:id` (67-84): Unregister remote
|
||||
- `POST /api/remotes/:id/refresh-sessions` (87-152): Refresh session list
|
||||
- `GET /api/remotes`: `19-33` - List registered servers
|
||||
- `POST /api/remotes/register`: `36-64` - Register remote
|
||||
- `DELETE /api/remotes/:id`: `67-84` - Unregister remote
|
||||
- `POST /api/remotes/:id/refresh-sessions`: `87-152` - Refresh session list
|
||||
|
||||
#### Filesystem (`filesystem.ts`)
|
||||
- `GET /api/fs/browse`: Browse directory with Git status
|
||||
- `GET /api/fs/preview`: File preview with Monaco support
|
||||
- `GET /api/fs/content`: Text file content
|
||||
- `GET /api/fs/diff`: Git diff for files
|
||||
- `POST /api/fs/mkdir`: Create directory
|
||||
|
||||
#### Logs (`logs.ts`)
|
||||
- `POST /api/logs/client` (21-53): Client log submission
|
||||
- `GET /api/logs/raw` (56-74): Stream raw log file
|
||||
- `GET /api/logs/info` (77-102): Log file metadata
|
||||
- `DELETE /api/logs/clear` (105-119): Clear log file
|
||||
- `POST /api/logs/client`: `21-53` - Client log submission
|
||||
- `GET /api/logs/raw`: `56-74` - Stream raw log file
|
||||
- `GET /api/logs/info`: `77-102` - Log file metadata
|
||||
- `DELETE /api/logs/clear`: `105-119` - Clear log file
|
||||
|
||||
#### Push Notifications (`push.ts`)
|
||||
- `GET /api/push/vapid-public-key`: Get VAPID public key
|
||||
- `POST /api/push/subscribe`: Subscribe to notifications
|
||||
- `POST /api/push/unsubscribe`: Unsubscribe
|
||||
- `POST /api/push/test`: Send test notification
|
||||
- `GET /api/push/status`: Get service status
|
||||
|
||||
#### Authentication (`auth.ts`)
|
||||
- `POST /api/auth/challenge`: Create SSH key auth challenge
|
||||
- `POST /api/auth/ssh-key`: Authenticate with SSH key
|
||||
- `POST /api/auth/password`: Authenticate with password
|
||||
- `GET /api/auth/verify`: Verify authentication status
|
||||
- `GET /api/auth/config`: Get auth configuration
|
||||
- `GET /api/auth/avatar/:userId`: Get user avatar (macOS)
|
||||
|
||||
### Binary Buffer Protocol
|
||||
|
||||
**Note**: "Buffer" refers to the current terminal viewport without scrollback - used for terminal previews.
|
||||
|
||||
#### Format (`terminal-manager.ts:361-555`)
|
||||
#### Format (`terminal-manager.ts:361-579`)
|
||||
```
|
||||
Header (32 bytes):
|
||||
- Magic: 0x5654 "VT" (2 bytes)
|
||||
|
|
@ -169,86 +200,96 @@ Cells: Variable-length with type byte
|
|||
- Heartbeat every 30 seconds
|
||||
|
||||
### WebSocket (`services/buffer-aggregator.ts`)
|
||||
- Client connections (30-87): Authentication and subscription
|
||||
- Message handling (88-127): Subscribe/unsubscribe/ping
|
||||
- Binary protocol (156-209): `[0xBF][ID Length][Session ID][Buffer Data]`
|
||||
- Client connections: `35-71` (Authentication and subscription)
|
||||
- Message handling: `76-145` (Subscribe/unsubscribe/ping)
|
||||
- Binary protocol: `156-185` - `[0xBF][ID Length][Session ID][Buffer Data]`
|
||||
- Local and remote session proxy support
|
||||
|
||||
### Activity Monitoring (`services/activity-monitor.ts`)
|
||||
- Monitors `stream-out` file size changes (143-146)
|
||||
- 100ms check interval (41-44)
|
||||
- 500ms inactivity timeout (209-212)
|
||||
- Persists to `activity.json` per session (220-245)
|
||||
- Monitors `stdout` file size changes: `143-147`
|
||||
- 100ms check interval: `41-44`
|
||||
- 500ms inactivity timeout: `209-212`
|
||||
- Persists to `activity.json` per session: `220-245`
|
||||
- Works for all sessions regardless of creation method
|
||||
|
||||
### HQ Mode Components
|
||||
|
||||
#### Remote Registry (`services/remote-registry.ts`)
|
||||
- Health checks every 15s (150-187)
|
||||
- Session ownership tracking (91-148)
|
||||
- Health checks every 15s: `150-187`
|
||||
- Session ownership tracking: `91-148`
|
||||
- Bearer token authentication
|
||||
- Automatic unhealthy remote removal
|
||||
|
||||
#### HQ Client (`services/hq-client.ts`)
|
||||
- Registration with HQ (40-90)
|
||||
- Registration with HQ: `40-90`
|
||||
- Unique ID generation with UUID v4
|
||||
- Graceful unregistration on shutdown (92-113)
|
||||
- Graceful unregistration on shutdown: `92-113`
|
||||
|
||||
### Additional Services
|
||||
|
||||
#### Push Notifications (`services/push-notification-service.ts`)
|
||||
- Web Push API integration (64-363)
|
||||
- Web Push API integration: `64-363`
|
||||
- Subscription management in `~/.vibetunnel/notifications/`
|
||||
- Bell event notifications (231-247)
|
||||
- Bell event notifications: `231-247`
|
||||
- Automatic expired subscription cleanup
|
||||
|
||||
#### Bell Event Handler (`services/bell-event-handler.ts`)
|
||||
- Processes terminal bell events (59-182)
|
||||
- Processes terminal bell events: `59-182`
|
||||
- Integrates with push notifications
|
||||
- Includes process context in notifications
|
||||
|
||||
#### Authentication Service (`services/auth-service.ts`)
|
||||
- SSH key authentication (144-159, 201-271)
|
||||
- SSH key authentication: `144-159`, `201-271`
|
||||
- Ed25519 signature verification
|
||||
- Challenge-response system
|
||||
- Checks `~/.ssh/authorized_keys`
|
||||
- Password authentication (105-120)
|
||||
- PAM authentication fallback (184-196)
|
||||
- JWT token management (176-180)
|
||||
- Password authentication: `105-120`
|
||||
- PAM authentication fallback: `184-196`
|
||||
- JWT token management: `176-180`
|
||||
|
||||
#### Control Directory Watcher (`services/control-dir-watcher.ts`)
|
||||
- Monitors external session changes (20-175)
|
||||
- HQ mode integration (116-163)
|
||||
- Monitors external session changes: `20-175`
|
||||
- HQ mode integration: `116-163`
|
||||
- Detects new/removed sessions
|
||||
|
||||
#### Shutdown State (`services/shutdown-state.ts`)
|
||||
- Global shutdown state tracking
|
||||
- Allows components to check shutdown status
|
||||
|
||||
### Utilities
|
||||
|
||||
#### Logger (`utils/logger.ts`)
|
||||
- Structured logging with file and console output (1-186)
|
||||
- Structured logging with file and console output: `1-186`
|
||||
- Color-coded console with chalk
|
||||
- Log levels: log, warn, error, debug
|
||||
- File output to `~/.vibetunnel/log.txt`
|
||||
- Debug mode via `VIBETUNNEL_DEBUG`
|
||||
|
||||
#### VAPID Manager (`utils/vapid-manager.ts`)
|
||||
- Auto-generates VAPID keys for push notifications (20-331)
|
||||
- Auto-generates VAPID keys for push notifications: `20-331`
|
||||
- Stores in `~/.vibetunnel/vapid/keys.json`
|
||||
- Key rotation support
|
||||
|
||||
#### Version (`version.ts`)
|
||||
- Version info with build date and git commit
|
||||
- Runtime version banner display
|
||||
- Development mode detection
|
||||
|
||||
## Client Architecture (`src/client/`)
|
||||
|
||||
### Core Components
|
||||
|
||||
#### Entry Points
|
||||
- `app-entry.ts` (1-28): Main entry, initializes Monaco and push notifications
|
||||
- `app-entry.ts:1-28`: Main entry, initializes Monaco and push notifications
|
||||
- `test-entry.ts`: Test terminals entry
|
||||
- `styles.css`: Global Tailwind styles
|
||||
|
||||
#### Main Application (`app.ts`)
|
||||
- Lit-based SPA (15-1200+): `<vibetunnel-app>`
|
||||
- Lit-based SPA: `44-1401` - `<vibetunnel-app>`
|
||||
- URL-based routing with `?session=<id>`
|
||||
- Global keyboard handlers (Cmd+O, Escape)
|
||||
- View management: auth/list/session
|
||||
- Split-screen support for session list and detail
|
||||
- **Events fired**:
|
||||
- `toggle-nav`, `navigate-to-list`, `error`, `success`, `navigate`
|
||||
|
||||
|
|
@ -275,20 +316,21 @@ vibetunnel-app
|
|||
### Terminal Components
|
||||
|
||||
#### Terminal (`terminal.ts`)
|
||||
- Full xterm.js implementation (1-1000+)
|
||||
- Virtual scrolling (537-555)
|
||||
- Full xterm.js implementation: `23-150+`
|
||||
- Virtual scrolling: `537-555`
|
||||
- Touch/momentum support
|
||||
- URL highlighting integration
|
||||
- Custom width selection
|
||||
- **Events**: `terminal-ready`, `terminal-input`, `terminal-resize`, `url-clicked`
|
||||
|
||||
#### VibeTunnelBuffer (`vibe-terminal-buffer.ts`)
|
||||
- Read-only terminal preview (25-268)
|
||||
- Read-only terminal preview: `26-268`
|
||||
- WebSocket buffer subscription
|
||||
- Auto-resizing
|
||||
- **Events**: `content-changed`
|
||||
|
||||
#### SessionView (`session-view.ts`)
|
||||
- Full-screen terminal view (29-1331)
|
||||
- Full-screen terminal view: `52-200+`
|
||||
- Manager architecture:
|
||||
- ConnectionManager: SSE streaming
|
||||
- InputManager: Keyboard/mouse
|
||||
|
|
@ -300,19 +342,19 @@ vibetunnel-app
|
|||
### Session Management Components
|
||||
|
||||
#### SessionList (`session-list.ts`)
|
||||
- Grid/list layout (61-700+)
|
||||
- Grid/list layout: `34-150+`
|
||||
- Hide/show exited sessions
|
||||
- Search and filtering
|
||||
- **Events**: `navigate-to-session`, `refresh`, `error`, `success`
|
||||
|
||||
#### SessionCard (`session-card.ts`)
|
||||
- Individual session display (31-420+)
|
||||
- Individual session display: `25-150+`
|
||||
- Live terminal preview
|
||||
- Activity detection
|
||||
- **Events**: `session-select`, `session-killed`, `session-kill-error`
|
||||
|
||||
#### SessionCreateForm (`session-create-form.ts`)
|
||||
- Modal dialog (27-381)
|
||||
- Modal dialog: `27-381`
|
||||
- Command input with working directory
|
||||
- Native terminal spawn option
|
||||
- **Events**: `session-created`, `cancel`, `error`
|
||||
|
|
@ -320,57 +362,71 @@ vibetunnel-app
|
|||
### UI Components
|
||||
|
||||
#### AppHeader (`app-header.ts`)
|
||||
- Main navigation (15-280+)
|
||||
- Main navigation: `15-280+`
|
||||
- Session status display
|
||||
- Theme toggle
|
||||
- **Events**: `toggle-nav`, `navigate-to-list`, `toggle-create-form`
|
||||
- **Events**: `create-session`, `hide-exited-change`, `kill-all-sessions`, `logout`
|
||||
|
||||
#### FileBrowser (`file-browser.ts`)
|
||||
- Filesystem navigation (48-665)
|
||||
- Git status display
|
||||
- Filesystem navigation: `48-665`
|
||||
- Browse and select modes
|
||||
- Monaco editor preview
|
||||
- **Events**: `insert-path`, `open-in-editor`, `directory-selected`
|
||||
- **Events**: `file-selected`, `browser-cancel`
|
||||
|
||||
#### UnifiedSettings (`unified-settings.ts`)
|
||||
- Settings modal with multiple tabs
|
||||
- Terminal preferences, notifications, appearance
|
||||
- **Events**: `close`, `notifications-enabled`, `success`, `error`
|
||||
|
||||
#### LogViewer (`log-viewer.ts`)
|
||||
- Real-time log display (1-432)
|
||||
- Real-time log display: `1-432`
|
||||
- SSE-style polling
|
||||
- Level filtering
|
||||
- Search functionality
|
||||
- Level filtering and search
|
||||
|
||||
### Services
|
||||
|
||||
#### BufferSubscriptionService (`buffer-subscription-service.ts`)
|
||||
- WebSocket client (30-87)
|
||||
- Binary protocol decoder (163-208)
|
||||
- WebSocket client: `77-164`
|
||||
- Binary protocol decoder: `234-259`
|
||||
- Auto-reconnection with backoff
|
||||
- Per-session subscriptions
|
||||
|
||||
#### WebSocketInputClient (`websocket-input-client.ts`)
|
||||
- Alternative input method via WebSocket
|
||||
- Low-latency keyboard/mouse input
|
||||
- Fire-and-forget protocol
|
||||
|
||||
#### PushNotificationService (`push-notification-service.ts`)
|
||||
- Service worker registration
|
||||
- Push subscription management
|
||||
- Notification action handling
|
||||
|
||||
#### AuthClient (`auth-client.ts`)
|
||||
- Token management
|
||||
- Authentication state
|
||||
- Token management: `27-100+`
|
||||
- SSH key and password authentication
|
||||
- API header generation
|
||||
- SSH agent integration
|
||||
|
||||
### Utils
|
||||
|
||||
#### CastConverter (`cast-converter.ts`)
|
||||
- Asciinema v2 parser (31-82)
|
||||
- SSE stream handler (294-427)
|
||||
- Custom exit event support
|
||||
#### Terminal Utils
|
||||
- `terminal-renderer.ts:276-418`: Binary buffer decoder and HTML generation
|
||||
- `terminal-utils.ts:14-44`: Terminal resize helpers
|
||||
- `terminal-preferences.ts`: Width preferences management
|
||||
- `xterm-colors.ts`: Color palette definitions
|
||||
- `url-highlighter.ts`: URL detection and click handling
|
||||
|
||||
#### TerminalRenderer (`terminal-renderer.ts`)
|
||||
- Binary buffer decoder (279-424)
|
||||
- HTML generation from buffer
|
||||
- Style mapping
|
||||
#### UI Utils
|
||||
- `responsive-utils.ts`: Media query observer
|
||||
- `title-updater.ts`: Dynamic document title
|
||||
- `keyboard-shortcut-highlighter.ts`: Shortcut formatting
|
||||
- `offline-notification-manager.ts`: Offline detection
|
||||
|
||||
#### URLHighlighter (`url-highlighter.ts`)
|
||||
- Multi-line URL detection
|
||||
- Protocol validation
|
||||
- Click event handling
|
||||
#### General Utils
|
||||
- `cast-converter.ts:31-82`: Asciinema v2 parser
|
||||
- `logger.ts`: Namespaced console logging
|
||||
- `path-utils.ts`: Path formatting and clipboard
|
||||
- `constants.ts`: UI timing, breakpoints, z-indexes
|
||||
|
||||
## Forward Tool (`src/server/fwd.ts`)
|
||||
|
||||
|
|
@ -379,38 +435,44 @@ CLI tool for spawning PTY sessions using VibeTunnel infrastructure.
|
|||
|
||||
### Usage
|
||||
```bash
|
||||
npx tsx src/fwd.ts [--session-id <id>] <command> [args...]
|
||||
pnpm exec tsx src/fwd.ts [--session-id <id>] [--title-mode <mode>] <command> [args...]
|
||||
|
||||
# Examples
|
||||
npx tsx src/fwd.ts claude --resume
|
||||
npx tsx src/fwd.ts --session-id abc123 bash -l
|
||||
pnpm exec tsx src/fwd.ts claude --resume
|
||||
pnpm exec tsx src/fwd.ts --session-id abc123 --title-mode dynamic bash -l
|
||||
```
|
||||
|
||||
### Key Features
|
||||
- Interactive terminal forwarding (43-195)
|
||||
- Interactive terminal forwarding: `58-289`
|
||||
- Automatic shell alias support via ProcessUtils
|
||||
- Session ID pre-generation support
|
||||
- Terminal title management modes
|
||||
- Graceful cleanup on exit
|
||||
- Colorful output with chalk
|
||||
|
||||
### Title Modes
|
||||
- `none`: No title management (default)
|
||||
- `filter`: Block all title changes
|
||||
- `static`: Show working directory and command
|
||||
- `dynamic`: Show directory, command, and activity
|
||||
|
||||
### Integration Points
|
||||
- Uses central PTY Manager (78-82)
|
||||
- Control pipe handling delegated to PTY Manager
|
||||
- Terminal resize synchronization (148-163)
|
||||
- Raw mode for proper input capture (166-172)
|
||||
- Uses central PTY Manager: `147-150`
|
||||
- Terminal resize synchronization: `257-269`
|
||||
- Raw mode for proper input capture: `276-277`
|
||||
- Auto-detects Claude commands for dynamic titles: `135-143`
|
||||
|
||||
## Build System
|
||||
|
||||
### Main Build (`scripts/build.js`)
|
||||
- Asset copying (7-121)
|
||||
- Asset copying: `7-121`
|
||||
- CSS compilation with Tailwind
|
||||
- Client bundling with esbuild
|
||||
- Server TypeScript compilation
|
||||
- Native executable creation
|
||||
|
||||
### Native Binary (`scripts/build-native.js`)
|
||||
- Node.js SEA integration (1-537)
|
||||
- node-pty patching for compatibility (82-218)
|
||||
- Node.js SEA integration: `1-537`
|
||||
- node-pty patching for compatibility: `82-218`
|
||||
- Outputs:
|
||||
- `native/vibetunnel`: Main executable
|
||||
- `native/pty.node`: Terminal emulation
|
||||
|
|
@ -424,6 +486,7 @@ npx tsx src/fwd.ts --session-id abc123 bash -l
|
|||
- `src/server/server.ts`: Server implementation
|
||||
- `src/server/middleware/auth.ts`: Authentication
|
||||
- `src/server/routes/sessions.ts`: Session API
|
||||
- `src/server/routes/websocket-input.ts`: WebSocket input
|
||||
- `src/server/pty/pty-manager.ts`: PTY management
|
||||
- `src/server/services/terminal-manager.ts`: Terminal state
|
||||
- `src/server/services/activity-monitor.ts`: Activity tracking
|
||||
|
|
@ -435,6 +498,7 @@ npx tsx src/fwd.ts --session-id abc123 bash -l
|
|||
- `src/client/components/terminal.ts`: Terminal renderer
|
||||
- `src/client/components/session-view.ts`: Session viewer
|
||||
- `src/client/services/buffer-subscription-service.ts`: WebSocket
|
||||
- `src/client/services/websocket-input-client.ts`: Low-latency input
|
||||
- `src/client/utils/cast-converter.ts`: Asciinema parser
|
||||
|
||||
### Configuration
|
||||
|
|
@ -446,6 +510,7 @@ npx tsx src/fwd.ts --session-id abc123 bash -l
|
|||
- REST API: Session CRUD, terminal I/O
|
||||
- SSE: Real-time asciinema streaming
|
||||
- WebSocket: Binary buffer updates
|
||||
- WebSocket Input: Low-latency keyboard/mouse
|
||||
- Control pipes: External session control
|
||||
|
||||
### Session Data Storage
|
||||
|
|
@ -454,19 +519,20 @@ Each session has a directory in `~/.vibetunnel/control/[sessionId]/`:
|
|||
- `stream-out`: Asciinema cast file
|
||||
- `stdin`: Input pipe
|
||||
- `control`: Control pipe
|
||||
- `stdout`: Raw output file
|
||||
- `activity.json`: Activity status
|
||||
|
||||
## Development Notes
|
||||
|
||||
### Recent Improvements
|
||||
- Push notification support for terminal bells
|
||||
- Multi-method authentication (SSH keys, JWT, PAM)
|
||||
- Unified logging infrastructure with style guide
|
||||
- Activity monitoring for all sessions
|
||||
- Control directory watcher for external sessions
|
||||
- Improved TypeScript types (no "as any")
|
||||
- Colorful CLI output with chalk
|
||||
- Auto-generation of security keys (VAPID)
|
||||
- WebSocket input endpoint for lower latency
|
||||
- Advanced terminal title management system
|
||||
- Unified shutdown state management
|
||||
- Atomic file writes for session data
|
||||
- Split-screen support in web UI
|
||||
- Enhanced activity monitoring with 100ms precision
|
||||
- Improved command resolution for aliases
|
||||
- Better error messages for spawn failures
|
||||
|
||||
### Testing
|
||||
- Unit tests: `pnpm test`
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import chalk from 'chalk';
|
||||
import type { Response } from 'express';
|
||||
import * as fs from 'fs';
|
||||
import type { AsciinemaHeader } from '../pty/types.js';
|
||||
import { createLogger } from '../utils/logger.js';
|
||||
|
||||
const logger = createLogger('stream-watcher');
|
||||
|
|
@ -10,6 +11,34 @@ interface StreamClient {
|
|||
startTime: number;
|
||||
}
|
||||
|
||||
// Type for asciinema event array format
|
||||
type AsciinemaOutputEvent = [number, 'o', string];
|
||||
type AsciinemaInputEvent = [number, 'i', string];
|
||||
type AsciinemaResizeEvent = [number, 'r', string];
|
||||
type AsciinemaExitEvent = ['exit', number, string];
|
||||
type AsciinemaEvent =
|
||||
| AsciinemaOutputEvent
|
||||
| AsciinemaInputEvent
|
||||
| AsciinemaResizeEvent
|
||||
| AsciinemaExitEvent;
|
||||
|
||||
// Type guard functions
|
||||
function isOutputEvent(event: AsciinemaEvent): event is AsciinemaOutputEvent {
|
||||
return (
|
||||
Array.isArray(event) && event.length === 3 && event[1] === 'o' && typeof event[0] === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
function isResizeEvent(event: AsciinemaEvent): event is AsciinemaResizeEvent {
|
||||
return (
|
||||
Array.isArray(event) && event.length === 3 && event[1] === 'r' && typeof event[0] === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
function isExitEvent(event: AsciinemaEvent): event is AsciinemaExitEvent {
|
||||
return Array.isArray(event) && event[0] === 'exit';
|
||||
}
|
||||
|
||||
interface WatcherInfo {
|
||||
clients: Set<StreamClient>;
|
||||
watcher?: fs.FSWatcher;
|
||||
|
|
@ -122,6 +151,149 @@ export class StreamWatcher {
|
|||
* Send existing content to a client
|
||||
*/
|
||||
private sendExistingContent(streamPath: string, client: StreamClient): void {
|
||||
try {
|
||||
// First pass: analyze the stream to find the last clear and track resize events
|
||||
const analysisStream = fs.createReadStream(streamPath, { encoding: 'utf8' });
|
||||
let lineBuffer = '';
|
||||
const events: AsciinemaEvent[] = [];
|
||||
let lastClearIndex = -1;
|
||||
let lastResizeBeforeClear: AsciinemaResizeEvent | null = null;
|
||||
let currentResize: AsciinemaResizeEvent | null = null;
|
||||
let header: AsciinemaHeader | null = null;
|
||||
|
||||
analysisStream.on('data', (chunk: string | Buffer) => {
|
||||
lineBuffer += chunk.toString();
|
||||
const lines = lineBuffer.split('\n');
|
||||
lineBuffer = lines.pop() || ''; // Keep incomplete line for next chunk
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
if (parsed.version && parsed.width && parsed.height) {
|
||||
header = parsed;
|
||||
} else if (Array.isArray(parsed)) {
|
||||
// Check if it's an exit event first
|
||||
if (parsed[0] === 'exit') {
|
||||
events.push(parsed as AsciinemaExitEvent);
|
||||
} else if (parsed.length >= 3 && typeof parsed[0] === 'number') {
|
||||
const event = parsed as AsciinemaEvent;
|
||||
|
||||
// Track resize events
|
||||
if (isResizeEvent(event)) {
|
||||
currentResize = event;
|
||||
}
|
||||
|
||||
// Check for clear sequence in output events
|
||||
if (isOutputEvent(event) && event[2].includes('\x1b[3J')) {
|
||||
lastClearIndex = events.length;
|
||||
lastResizeBeforeClear = currentResize;
|
||||
logger.debug(
|
||||
`found clear sequence at event index ${lastClearIndex}, current resize: ${currentResize ? currentResize[2] : 'none'}`
|
||||
);
|
||||
}
|
||||
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.debug(`skipping invalid JSON line during analysis: ${e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
analysisStream.on('end', () => {
|
||||
// Process any remaining line in analysis
|
||||
if (lineBuffer.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(lineBuffer);
|
||||
if (Array.isArray(parsed)) {
|
||||
if (parsed[0] === 'exit') {
|
||||
events.push(parsed as AsciinemaExitEvent);
|
||||
} else if (parsed.length >= 3 && typeof parsed[0] === 'number') {
|
||||
const event = parsed as AsciinemaEvent;
|
||||
|
||||
if (isResizeEvent(event)) {
|
||||
currentResize = event;
|
||||
}
|
||||
if (isOutputEvent(event) && event[2].includes('\x1b[3J')) {
|
||||
lastClearIndex = events.length;
|
||||
lastResizeBeforeClear = currentResize;
|
||||
logger.debug(
|
||||
`found clear sequence at event index ${lastClearIndex} (last event)`
|
||||
);
|
||||
}
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.debug(`skipping invalid JSON in line buffer during analysis: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Now replay the stream with pruning
|
||||
let startIndex = 0;
|
||||
|
||||
if (lastClearIndex >= 0) {
|
||||
// Start from after the last clear
|
||||
startIndex = lastClearIndex + 1;
|
||||
logger.log(
|
||||
chalk.green(`pruning stream: skipping ${lastClearIndex + 1} events before last clear`)
|
||||
);
|
||||
}
|
||||
|
||||
// Send header first - update dimensions if we have a resize
|
||||
if (header) {
|
||||
const headerToSend = { ...header };
|
||||
if (lastClearIndex >= 0 && lastResizeBeforeClear) {
|
||||
// Update header with last known dimensions before clear
|
||||
const dimensions = lastResizeBeforeClear[2].split('x');
|
||||
headerToSend.width = Number.parseInt(dimensions[0], 10);
|
||||
headerToSend.height = Number.parseInt(dimensions[1], 10);
|
||||
}
|
||||
client.response.write(`data: ${JSON.stringify(headerToSend)}\n\n`);
|
||||
}
|
||||
|
||||
// Send remaining events
|
||||
let exitEventFound = false;
|
||||
for (let i = startIndex; i < events.length; i++) {
|
||||
const event = events[i];
|
||||
if (isExitEvent(event)) {
|
||||
exitEventFound = true;
|
||||
client.response.write(`data: ${JSON.stringify(event)}\n\n`);
|
||||
} else if (isOutputEvent(event) || isResizeEvent(event)) {
|
||||
// Set timestamp to 0 for existing content
|
||||
const instantEvent: AsciinemaEvent = [0, event[1], event[2]];
|
||||
client.response.write(`data: ${JSON.stringify(instantEvent)}\n\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// If exit event found, close connection
|
||||
if (exitEventFound) {
|
||||
logger.log(
|
||||
chalk.yellow(
|
||||
`session ${client.response.locals?.sessionId || 'unknown'} already ended, closing stream`
|
||||
)
|
||||
);
|
||||
client.response.end();
|
||||
}
|
||||
});
|
||||
|
||||
analysisStream.on('error', (error) => {
|
||||
logger.error('failed to analyze stream for pruning:', error);
|
||||
// Fall back to original implementation without pruning
|
||||
this.sendExistingContentWithoutPruning(streamPath, client);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('failed to create read stream:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Original implementation without pruning (fallback)
|
||||
*/
|
||||
private sendExistingContentWithoutPruning(streamPath: string, client: StreamClient): void {
|
||||
try {
|
||||
const stream = fs.createReadStream(streamPath, { encoding: 'utf8' });
|
||||
let exitEventFound = false;
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ export class PromptDetector {
|
|||
|
||||
// Check cache first
|
||||
if (PromptDetector.onlyPromptCache.has(trimmed)) {
|
||||
return PromptDetector.onlyPromptCache.get(trimmed)!;
|
||||
return PromptDetector.onlyPromptCache.get(trimmed) ?? false;
|
||||
}
|
||||
|
||||
const result = PROMPT_ONLY_REGEX.test(trimmed);
|
||||
|
|
@ -103,7 +103,7 @@ export class PromptDetector {
|
|||
|
||||
// Check cache first
|
||||
if (PromptDetector.endPromptCache.has(cacheKey)) {
|
||||
return PromptDetector.endPromptCache.get(cacheKey)!;
|
||||
return PromptDetector.endPromptCache.get(cacheKey) ?? false;
|
||||
}
|
||||
|
||||
// Strip ANSI codes for more reliable detection
|
||||
|
|
|
|||
6012
web/src/test/fixtures/asciinema/real-world-claude-session.cast
vendored
Normal file
6012
web/src/test/fixtures/asciinema/real-world-claude-session.cast
vendored
Normal file
File diff suppressed because it is too large
Load diff
128
web/src/test/fixtures/test-data.ts
vendored
128
web/src/test/fixtures/test-data.ts
vendored
|
|
@ -157,3 +157,131 @@ export const mockUser = {
|
|||
username: 'testuser',
|
||||
token: mockAuthToken,
|
||||
};
|
||||
|
||||
// Asciinema fixtures for stream pruning tests
|
||||
export const mockAsciinemaWithClears = {
|
||||
header: {
|
||||
version: 2,
|
||||
width: 120,
|
||||
height: 40,
|
||||
timestamp: 1704103200,
|
||||
},
|
||||
events: [
|
||||
// Initial content
|
||||
[0, 'o', 'Line 1: Initial content that will be cleared\\r\\n'],
|
||||
[0.1, 'o', 'Line 2: More content\\r\\n'],
|
||||
[0.2, 'o', 'Line 3: Even more content\\r\\n'],
|
||||
[0.3, 'r', '80x24'], // Resize
|
||||
[0.4, 'o', 'Line 4: Content after resize\\r\\n'],
|
||||
[0.5, 'o', 'Line 5: More content to clear\\r\\n'],
|
||||
// First clear
|
||||
[0.6, 'o', '\u001b[3J'], // Clear scrollback
|
||||
[0.7, 'o', '\u001b[H\u001b[2J'], // Home + clear screen
|
||||
[0.8, 'o', 'Line 6: Content after first clear\\r\\n'],
|
||||
[0.9, 'o', 'Line 7: More content\\r\\n'],
|
||||
[1.0, 'r', '100x30'], // Another resize
|
||||
[1.1, 'o', 'Line 8: Content with new dimensions\\r\\n'],
|
||||
// Second clear (this should be the last one)
|
||||
[1.2, 'o', '\u001b[2J\u001b[3J\u001b[H'], // Clear in one command
|
||||
[1.3, 'o', 'Line 9: Final content that should remain\\r\\n'],
|
||||
[1.4, 'o', 'Line 10: This should be visible\\r\\n'],
|
||||
[1.5, 'o', 'Line 11: Last line\\r\\n'],
|
||||
['exit', 0, 'test-session'],
|
||||
],
|
||||
};
|
||||
|
||||
export const mockAsciinemaWithClearMidLine = {
|
||||
header: {
|
||||
version: 2,
|
||||
width: 80,
|
||||
height: 24,
|
||||
timestamp: 1704103200,
|
||||
},
|
||||
events: [
|
||||
[0, 'o', 'Before clear'],
|
||||
[0.1, 'o', 'This has a clear \u001b[3J in the middle'],
|
||||
[0.2, 'o', 'After clear\\r\\n'],
|
||||
['exit', 0, 'test-session'],
|
||||
],
|
||||
};
|
||||
|
||||
export const mockAsciinemaNoClears = {
|
||||
header: {
|
||||
version: 2,
|
||||
width: 80,
|
||||
height: 24,
|
||||
timestamp: 1704103200,
|
||||
},
|
||||
events: [
|
||||
[0, 'o', 'Line 1: No clears in this stream\\r\\n'],
|
||||
[0.1, 'o', 'Line 2: Just regular content\\r\\n'],
|
||||
[0.2, 'o', 'Line 3: Should replay everything\\r\\n'],
|
||||
['exit', 0, 'test-session'],
|
||||
],
|
||||
};
|
||||
|
||||
// Real-world asciinema data from Claude session with multiple clears
|
||||
export const mockRealWorldAsciinema = {
|
||||
header: {
|
||||
version: 2,
|
||||
width: 80,
|
||||
height: 24,
|
||||
timestamp: 1751323457,
|
||||
command: 'claude',
|
||||
title: 'Claude session',
|
||||
},
|
||||
events: [
|
||||
// Initial content before first clear
|
||||
[1189.0, 'o', 'Some previous Claude output that will be cleared\\r\\n'],
|
||||
[1189.1, 'o', 'More content before clear...\\r\\n'],
|
||||
[1189.2, 'r', '179x60'], // Terminal resize
|
||||
[1189.455, 'o', '\u001b[2J\u001b[3J\u001b[H'], // First clear
|
||||
[1189.5, 'o', 'Content after first clear\\r\\n'],
|
||||
[1190.0, 'o', 'More content...\\r\\n'],
|
||||
[1190.325, 'o', '\u001b[2J\u001b[3J\u001b[H'], // Second clear
|
||||
[1190.4, 'o', 'Content after second clear\\r\\n'],
|
||||
// ... more content ...
|
||||
[1361.0, 'o', 'Final Claude output before last clear\\r\\n'],
|
||||
[1361.137, 'o', '\u001b[2J\u001b[3J\u001b[H'], // Third clear
|
||||
[1361.2, 'o', 'Some content after third clear\\r\\n'],
|
||||
[1362.0, 'r', '180x61'], // Another resize
|
||||
[1362.264, 'o', '\u001b[2J\u001b[3J\u001b[H'], // Fourth and final clear
|
||||
// Content after the last clear - this should be preserved
|
||||
[
|
||||
1362.29,
|
||||
'o',
|
||||
'\u001b[2K\u001b[1A\u001b[2K\u001b[1A\u001b[2K\u001b[1A\u001b[2K\u001b[G\u001b[38;2;215;119;87m╭───────────────────────────────────────────────────╮\u001b[39m\\r\\n',
|
||||
],
|
||||
[
|
||||
1362.29,
|
||||
'o',
|
||||
'\u001b[38;2;215;119;87m│\u001b[39m \u001b[38;2;215;119;87m✻\u001b[39m Welcome to \u001b[1mClaude Code\u001b[22m! \u001b[38;2;215;119;87m│\u001b[39m\\r\\n',
|
||||
],
|
||||
[
|
||||
1362.29,
|
||||
'o',
|
||||
'\u001b[38;2;215;119;87m│\u001b[39m \u001b[38;2;215;119;87m│\u001b[39m\\r\\n',
|
||||
],
|
||||
[
|
||||
1362.29,
|
||||
'o',
|
||||
'\u001b[38;2;215;119;87m│\u001b[39m \u001b[3m\u001b[38;2;153;153;153m/help for help, /status for your current setup\u001b[39m\u001b[23m \u001b[38;2;215;119;87m│\u001b[39m\\r\\n',
|
||||
],
|
||||
[
|
||||
1362.29,
|
||||
'o',
|
||||
'\u001b[38;2;215;119;87m│\u001b[39m \u001b[38;2;215;119;87m│\u001b[39m\\r\\n',
|
||||
],
|
||||
[
|
||||
1362.29,
|
||||
'o',
|
||||
'\u001b[38;2;215;119;87m│\u001b[39m \u001b[38;2;153;153;153mcwd: /Users/badlogic/workspaces/vibetunnel/web\u001b[39m \u001b[38;2;215;119;87m│\u001b[39m\\r\\n',
|
||||
],
|
||||
[
|
||||
1362.29,
|
||||
'o',
|
||||
'\u001b[38;2;215;119;87m╰───────────────────────────────────────────────────╯\u001b[39m\\r\\n\\r\\n\\r\\n ',
|
||||
],
|
||||
['exit', 0, 'claude-session'],
|
||||
],
|
||||
};
|
||||
|
|
|
|||
204
web/src/test/unit/stream-pruning.test.ts
Normal file
204
web/src/test/unit/stream-pruning.test.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import type { Response } from 'express';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { AsciinemaHeader } from '../../server/pty/types.js';
|
||||
import { StreamWatcher } from '../../server/services/stream-watcher.js';
|
||||
import {
|
||||
mockAsciinemaNoClears,
|
||||
mockAsciinemaWithClearMidLine,
|
||||
mockAsciinemaWithClears,
|
||||
} from '../fixtures/test-data.js';
|
||||
|
||||
// Type for asciinema events used in tests
|
||||
type TestAsciinemaEvent = [number | 'exit', string | number, string?];
|
||||
|
||||
describe('StreamWatcher - Asciinema Stream Pruning', () => {
|
||||
let streamWatcher: StreamWatcher;
|
||||
let tempDir: string;
|
||||
let mockResponse: Partial<Response>;
|
||||
let writtenData: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
// Create temp directory for test files
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stream-pruning-test-'));
|
||||
|
||||
// Mock response object
|
||||
writtenData = [];
|
||||
mockResponse = {
|
||||
write: vi.fn((data: string) => {
|
||||
writtenData.push(data);
|
||||
return true;
|
||||
}),
|
||||
end: vi.fn(),
|
||||
locals: {},
|
||||
};
|
||||
|
||||
streamWatcher = new StreamWatcher();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up temp directory
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Helper to create test asciinema file
|
||||
function createTestFile(
|
||||
filename: string,
|
||||
header: AsciinemaHeader,
|
||||
events: TestAsciinemaEvent[]
|
||||
): string {
|
||||
const filepath = path.join(tempDir, filename);
|
||||
const lines = [JSON.stringify(header), ...events.map((event) => JSON.stringify(event))];
|
||||
fs.writeFileSync(filepath, lines.join('\n'));
|
||||
return filepath;
|
||||
}
|
||||
|
||||
// Helper to parse SSE data
|
||||
function parseSSEData(data: string[]): Array<AsciinemaHeader | TestAsciinemaEvent> {
|
||||
return data
|
||||
.filter((line) => line.startsWith('data: '))
|
||||
.map((line) => {
|
||||
const jsonStr = line.substring(6).trim();
|
||||
try {
|
||||
return JSON.parse(jsonStr);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
it('should prune content before the last clear sequence', async () => {
|
||||
const filepath = createTestFile(
|
||||
'with-clears.cast',
|
||||
mockAsciinemaWithClears.header as AsciinemaHeader,
|
||||
mockAsciinemaWithClears.events as TestAsciinemaEvent[]
|
||||
);
|
||||
|
||||
// Use reflection to call private method
|
||||
// biome-ignore lint/suspicious/noExplicitAny: accessing private method for testing
|
||||
const sendExistingContent = (streamWatcher as any).sendExistingContent.bind(streamWatcher);
|
||||
sendExistingContent(filepath, { response: mockResponse, startTime: Date.now() / 1000 });
|
||||
|
||||
// Wait for async operations to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
const events = parseSSEData(writtenData);
|
||||
|
||||
// Should have header + final content only
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
|
||||
// First event should be header with updated dimensions
|
||||
const header = events[0] as AsciinemaHeader;
|
||||
expect(header.version).toBe(2);
|
||||
expect(header.width).toBe(100); // From last resize before clear
|
||||
expect(header.height).toBe(30);
|
||||
|
||||
// Should only have content after the last clear
|
||||
const outputEvents = events.filter((e) => Array.isArray(e) && e[1] === 'o');
|
||||
expect(outputEvents.length).toBe(3); // Lines 9, 10, 11
|
||||
expect(outputEvents[0][2]).toContain('Line 9: Final content');
|
||||
expect(outputEvents[1][2]).toContain('Line 10: This should be visible');
|
||||
expect(outputEvents[2][2]).toContain('Line 11: Last line');
|
||||
|
||||
// Should have exit event
|
||||
const exitEvent = events.find((e) => Array.isArray(e) && e[0] === 'exit');
|
||||
expect(exitEvent).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle clear sequence in middle of line', async () => {
|
||||
const filepath = createTestFile(
|
||||
'clear-mid-line.cast',
|
||||
mockAsciinemaWithClearMidLine.header as AsciinemaHeader,
|
||||
mockAsciinemaWithClearMidLine.events as TestAsciinemaEvent[]
|
||||
);
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: accessing private method for testing
|
||||
const sendExistingContent = (streamWatcher as any).sendExistingContent.bind(streamWatcher);
|
||||
sendExistingContent(filepath, { response: mockResponse, startTime: Date.now() / 1000 });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
const events = parseSSEData(writtenData);
|
||||
|
||||
// Should only have content after the clear
|
||||
const outputEvents = events.filter((e) => Array.isArray(e) && e[1] === 'o');
|
||||
expect(outputEvents.length).toBe(1); // Only "After clear"
|
||||
expect(outputEvents[0][2]).toContain('After clear');
|
||||
});
|
||||
|
||||
it('should not prune streams without clear sequences', async () => {
|
||||
const filepath = createTestFile(
|
||||
'no-clears.cast',
|
||||
mockAsciinemaNoClears.header as AsciinemaHeader,
|
||||
mockAsciinemaNoClears.events as TestAsciinemaEvent[]
|
||||
);
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: accessing private method for testing
|
||||
const sendExistingContent = (streamWatcher as any).sendExistingContent.bind(streamWatcher);
|
||||
sendExistingContent(filepath, { response: mockResponse, startTime: Date.now() / 1000 });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
const events = parseSSEData(writtenData);
|
||||
|
||||
// Should have all events
|
||||
const outputEvents = events.filter((e) => Array.isArray(e) && e[1] === 'o');
|
||||
expect(outputEvents.length).toBe(3); // All 3 lines
|
||||
expect(outputEvents[0][2]).toContain('Line 1: No clears');
|
||||
expect(outputEvents[1][2]).toContain('Line 2: Just regular');
|
||||
expect(outputEvents[2][2]).toContain('Line 3: Should replay');
|
||||
});
|
||||
|
||||
it('should fall back to non-pruning on read errors', async () => {
|
||||
const nonExistentPath = path.join(tempDir, 'does-not-exist.cast');
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: accessing private method for testing
|
||||
const sendExistingContent = (streamWatcher as any).sendExistingContent.bind(streamWatcher);
|
||||
sendExistingContent(nonExistentPath, { response: mockResponse, startTime: Date.now() / 1000 });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
// Should have called the fallback method (no data written due to missing file)
|
||||
expect(writtenData.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle real-world Claude session with multiple clears', async () => {
|
||||
// Use the actual real-world cast file
|
||||
const filepath = path.join(__dirname, '../fixtures/asciinema/real-world-claude-session.cast');
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: accessing private method for testing
|
||||
const sendExistingContent = (streamWatcher as any).sendExistingContent.bind(streamWatcher);
|
||||
sendExistingContent(filepath, { response: mockResponse, startTime: Date.now() / 1000 });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
const events = parseSSEData(writtenData);
|
||||
|
||||
// Should have pruned everything before the last clear
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
|
||||
// First event should be header
|
||||
const header = events[0] as AsciinemaHeader;
|
||||
expect(header.version).toBe(2);
|
||||
|
||||
// Check that we're getting content after the last clear
|
||||
const outputEvents = events.filter((e) => Array.isArray(e) && e[1] === 'o');
|
||||
expect(outputEvents.length).toBeGreaterThan(0);
|
||||
|
||||
// The real file has 4 clear sequences, we should only see content after the last one
|
||||
// Check that we have the welcome banner (appears after the last clear)
|
||||
const welcomeContent = outputEvents.map((e) => e[2]).join('');
|
||||
// Strip ANSI escape sequences for easier testing
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape sequences are necessary for terminal output
|
||||
const cleanContent = welcomeContent.replace(/\u001b\[[^m]*m/g, '');
|
||||
expect(cleanContent).toContain('Welcome to Claude Code');
|
||||
expect(cleanContent).toContain('/help for help');
|
||||
|
||||
// We should NOT see content from before the clears
|
||||
expect(cleanContent).not.toContain('Some previous Claude output');
|
||||
expect(cleanContent).not.toContain('cd workspaces'); // This was at the beginning
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue