feat: add asciinema stream pruning for clear sequences (#155)

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Mario Zechner 2025-07-01 06:01:59 +02:00 committed by GitHub
parent ba8d7be280
commit 40d2cd1998
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 6704 additions and 122 deletions

View file

@ -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: VibeTunnel is a web-based terminal multiplexer with distributed architecture support. It provides:
- PTY-based terminal sessions via node-pty - 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) - Binary-optimized buffer synchronization (current viewport via WebSocket)
- Distributed HQ/remote server architecture - Distributed HQ/remote server architecture
- Web UI with full terminal emulation - Web UI with full terminal emulation
- Push notifications for terminal bell events - Push notifications for terminal bell events
- Multi-method authentication (SSH keys, JWT, PAM) - Multi-method authentication (SSH keys, JWT, PAM)
- Advanced terminal title management
## Directory Structure ## Directory Structure
@ -23,9 +24,10 @@ web/
│ │ ├── pty/ # PTY management │ │ ├── pty/ # PTY management
│ │ ├── routes/ # API endpoints │ │ ├── routes/ # API endpoints
│ │ ├── services/ # Core services │ │ ├── services/ # Core services
│ │ ├── utils/ # Server utilities
│ │ ├── server.ts # Main server implementation │ │ ├── server.ts # Main server implementation
│ │ └── fwd.ts # CLI forwarding tool │ │ └── fwd.ts # CLI forwarding tool
│ ├── client/ # Lit-based web UI │ ├── client/ # Lit-based web UI
│ │ ├── assets/ # Static files (fonts, icons, html) │ │ ├── assets/ # Static files (fonts, icons, html)
│ │ ├── components/ # UI components │ │ ├── components/ # UI components
│ │ ├── services/ # Client services │ │ ├── services/ # Client services
@ -41,104 +43,133 @@ web/
### Core Components ### Core Components
#### Entry Points #### Entry Points
- `cli.ts` (1-62): Main entry point, routes to server or forward mode - `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 - Version display, debug initialization, mode routing
- CLI parsing (132-236): Extensive configuration options - `server.ts:1-1017`: Core server implementation with Express app factory
- Server modes (432-444): Normal, HQ, Remote initialization - CLI parsing: `142-246`
- WebSocket upgrade (564-677): Authentication and buffer streaming - Server modes: `432-455` (Normal, HQ, Remote initialization)
- Graceful shutdown (888-944): Cleanup intervals and service termination - WebSocket upgrade: `585-707` (Authentication and buffer streaming)
- Graceful shutdown: `893-1017` (Cleanup intervals and service termination)
### Authentication (`middleware/auth.ts`) ### Authentication (`middleware/auth.ts`)
- Multi-method authentication (1-159) - Multi-method authentication: `1-159`
- SSH key authentication with Ed25519 support - SSH key authentication with Ed25519 support
- Basic auth (username/password) - Basic auth (username/password)
- Bearer token for HQ↔Remote communication - Bearer token for HQ↔Remote communication
- JWT tokens for session persistence - 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 - Query parameter token support for EventSource
### Session Management ### Session Management
#### PTY Manager (`pty/pty-manager.ts`) #### PTY Manager (`pty/pty-manager.ts`)
- Session creation (78-163): Spawns PTY processes with node-pty - Session creation: `173-400` (Spawns PTY processes with node-pty)
- **Automatic alias resolution** (191-204): Uses `ProcessUtils.resolveCommand()` - **Automatic alias resolution**: `204-217` (Uses `ProcessUtils.resolveCommand()`)
- Terminal resize handling (63-157): Dimension synchronization - Terminal resize handling: `115-168` (Native terminal dimension sync)
- Control pipe support using file watching on all platforms - Control pipe support using file watching
- Bell event emission for push notifications - Bell event emission for push notifications
- Clean termination with SIGTERM→SIGKILL escalation - Clean termination with SIGTERM→SIGKILL escalation
#### Process Utils (`pty/process-utils.ts`) #### 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 - Uses `which` (Unix) or `where` (Windows) to check existence
- Returns appropriate shell command for aliases/builtins - Returns appropriate shell command for aliases/builtins
- Sources shell config files for proper alias support - 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 - Checks `$SHELL` environment variable first
- Platform-specific fallbacks (pwsh/cmd on Windows, zsh/bash on Unix) - 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 Manager (`pty/session-manager.ts`)
- Session persistence in `~/.vibetunnel/control/` - Session persistence in `~/.vibetunnel/control/`
- Filesystem-based session discovery - 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`) #### Terminal Manager (`services/terminal-manager.ts`)
- Headless xterm.js for server-side state - Headless xterm.js for server-side state: `47-76`
- Binary buffer snapshot generation - Binary buffer snapshot generation: `261-292`
- Watches asciinema cast files and applies to terminal - Watches asciinema cast files and applies to terminal
- Debounced buffer change notifications - Debounced buffer change notifications (100ms)
### API Routes (`routes/`) ### API Routes (`routes/`)
#### Sessions (`sessions.ts`) #### 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'` - Returns array with `source: 'local' | 'remote'`
- HQ mode: Aggregates from all remote servers - HQ mode: Aggregates from all remote servers
- `POST /api/sessions` (126-265): Create session - `POST /api/sessions`: `126-265` - Create session
- Body: `{ command, workingDir?, name?, remoteId?, spawn_terminal? }` - Body: `{ command, workingDir?, name?, remoteId?, spawn_terminal?, cols?, rows?, titleMode? }`
- Returns: `{ sessionId: string, message?: string }` - Returns: `{ sessionId: string, message?: string }`
- `GET /api/sessions/:id` (369-410): Get session info - `GET /api/sessions/:id`: `369-410` - Get session info
- `DELETE /api/sessions/:id` (413-467): Kill session - `DELETE /api/sessions/:id`: `413-467` - Kill session
- `DELETE /api/sessions/:id/cleanup` (470-518): Clean session files - `DELETE /api/sessions/:id/cleanup`: `470-518` - Clean session files
- `POST /api/cleanup-exited` (521-598): Clean all exited sessions - `POST /api/cleanup-exited`: `521-598` - Clean all exited sessions
#### Session I/O #### 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 }` - 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 }` - 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 #### 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 - Streams asciinema v2 format with custom exit event
- Replays existing content, then real-time streaming - Replays existing content, then real-time streaming
- `GET /api/sessions/:id/buffer` (662-721): Binary buffer snapshot - `GET /api/sessions/:id/buffer`: `662-721` - Binary buffer snapshot
- `GET /api/sessions/:id/text` (601-659): Plain text output - `GET /api/sessions/:id/text`: `601-659` - Plain text output
- Optional `?styles` for markup: `[style fg="15" bold]text[/style]` - Optional `?styles` for markup: `[style fg="15" bold]text[/style]`
#### Activity Monitoring #### Activity Monitoring
- `GET /api/sessions/activity` (268-324): All sessions activity - `GET /api/sessions/activity`: `268-324` - All sessions activity
- `GET /api/sessions/:id/activity` (327-366): Single session activity - `GET /api/sessions/:id/activity`: `327-366` - Single session activity
- Returns: `{ isActive: boolean, timestamp: string, session: SessionInfo }` - 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 #### Remotes (`remotes.ts`) - HQ Mode Only
- `GET /api/remotes` (19-33): List registered servers - `GET /api/remotes`: `19-33` - List registered servers
- `POST /api/remotes/register` (36-64): Register remote - `POST /api/remotes/register`: `36-64` - Register remote
- `DELETE /api/remotes/:id` (67-84): Unregister remote - `DELETE /api/remotes/:id`: `67-84` - Unregister remote
- `POST /api/remotes/:id/refresh-sessions` (87-152): Refresh session list - `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`) #### Logs (`logs.ts`)
- `POST /api/logs/client` (21-53): Client log submission - `POST /api/logs/client`: `21-53` - Client log submission
- `GET /api/logs/raw` (56-74): Stream raw log file - `GET /api/logs/raw`: `56-74` - Stream raw log file
- `GET /api/logs/info` (77-102): Log file metadata - `GET /api/logs/info`: `77-102` - Log file metadata
- `DELETE /api/logs/clear` (105-119): Clear log file - `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 ### Binary Buffer Protocol
**Note**: "Buffer" refers to the current terminal viewport without scrollback - used for terminal previews. **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): Header (32 bytes):
- Magic: 0x5654 "VT" (2 bytes) - Magic: 0x5654 "VT" (2 bytes)
@ -169,86 +200,96 @@ Cells: Variable-length with type byte
- Heartbeat every 30 seconds - Heartbeat every 30 seconds
### WebSocket (`services/buffer-aggregator.ts`) ### WebSocket (`services/buffer-aggregator.ts`)
- Client connections (30-87): Authentication and subscription - Client connections: `35-71` (Authentication and subscription)
- Message handling (88-127): Subscribe/unsubscribe/ping - Message handling: `76-145` (Subscribe/unsubscribe/ping)
- Binary protocol (156-209): `[0xBF][ID Length][Session ID][Buffer Data]` - Binary protocol: `156-185` - `[0xBF][ID Length][Session ID][Buffer Data]`
- Local and remote session proxy support - Local and remote session proxy support
### Activity Monitoring (`services/activity-monitor.ts`) ### Activity Monitoring (`services/activity-monitor.ts`)
- Monitors `stream-out` file size changes (143-146) - Monitors `stdout` file size changes: `143-147`
- 100ms check interval (41-44) - 100ms check interval: `41-44`
- 500ms inactivity timeout (209-212) - 500ms inactivity timeout: `209-212`
- Persists to `activity.json` per session (220-245) - Persists to `activity.json` per session: `220-245`
- Works for all sessions regardless of creation method - Works for all sessions regardless of creation method
### HQ Mode Components ### HQ Mode Components
#### Remote Registry (`services/remote-registry.ts`) #### Remote Registry (`services/remote-registry.ts`)
- Health checks every 15s (150-187) - Health checks every 15s: `150-187`
- Session ownership tracking (91-148) - Session ownership tracking: `91-148`
- Bearer token authentication - Bearer token authentication
- Automatic unhealthy remote removal - Automatic unhealthy remote removal
#### HQ Client (`services/hq-client.ts`) #### HQ Client (`services/hq-client.ts`)
- Registration with HQ (40-90) - Registration with HQ: `40-90`
- Unique ID generation with UUID v4 - Unique ID generation with UUID v4
- Graceful unregistration on shutdown (92-113) - Graceful unregistration on shutdown: `92-113`
### Additional Services ### Additional Services
#### Push Notifications (`services/push-notification-service.ts`) #### Push Notifications (`services/push-notification-service.ts`)
- Web Push API integration (64-363) - Web Push API integration: `64-363`
- Subscription management in `~/.vibetunnel/notifications/` - Subscription management in `~/.vibetunnel/notifications/`
- Bell event notifications (231-247) - Bell event notifications: `231-247`
- Automatic expired subscription cleanup - Automatic expired subscription cleanup
#### Bell Event Handler (`services/bell-event-handler.ts`) #### Bell Event Handler (`services/bell-event-handler.ts`)
- Processes terminal bell events (59-182) - Processes terminal bell events: `59-182`
- Integrates with push notifications - Integrates with push notifications
- Includes process context in notifications - Includes process context in notifications
#### Authentication Service (`services/auth-service.ts`) #### Authentication Service (`services/auth-service.ts`)
- SSH key authentication (144-159, 201-271) - SSH key authentication: `144-159`, `201-271`
- Ed25519 signature verification - Ed25519 signature verification
- Challenge-response system - Challenge-response system
- Checks `~/.ssh/authorized_keys` - Checks `~/.ssh/authorized_keys`
- Password authentication (105-120) - Password authentication: `105-120`
- PAM authentication fallback (184-196) - PAM authentication fallback: `184-196`
- JWT token management (176-180) - JWT token management: `176-180`
#### Control Directory Watcher (`services/control-dir-watcher.ts`) #### Control Directory Watcher (`services/control-dir-watcher.ts`)
- Monitors external session changes (20-175) - Monitors external session changes: `20-175`
- HQ mode integration (116-163) - HQ mode integration: `116-163`
- Detects new/removed sessions - Detects new/removed sessions
#### Shutdown State (`services/shutdown-state.ts`)
- Global shutdown state tracking
- Allows components to check shutdown status
### Utilities ### Utilities
#### Logger (`utils/logger.ts`) #### 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 - Color-coded console with chalk
- Log levels: log, warn, error, debug - Log levels: log, warn, error, debug
- File output to `~/.vibetunnel/log.txt` - File output to `~/.vibetunnel/log.txt`
- Debug mode via `VIBETUNNEL_DEBUG` - Debug mode via `VIBETUNNEL_DEBUG`
#### VAPID Manager (`utils/vapid-manager.ts`) #### 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` - Stores in `~/.vibetunnel/vapid/keys.json`
- Key rotation support - 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/`) ## Client Architecture (`src/client/`)
### Core Components ### Core Components
#### Entry Points #### 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 - `test-entry.ts`: Test terminals entry
- `styles.css`: Global Tailwind styles - `styles.css`: Global Tailwind styles
#### Main Application (`app.ts`) #### Main Application (`app.ts`)
- Lit-based SPA (15-1200+): `<vibetunnel-app>` - Lit-based SPA: `44-1401` - `<vibetunnel-app>`
- URL-based routing with `?session=<id>` - URL-based routing with `?session=<id>`
- Global keyboard handlers (Cmd+O, Escape) - Global keyboard handlers (Cmd+O, Escape)
- View management: auth/list/session - View management: auth/list/session
- Split-screen support for session list and detail
- **Events fired**: - **Events fired**:
- `toggle-nav`, `navigate-to-list`, `error`, `success`, `navigate` - `toggle-nav`, `navigate-to-list`, `error`, `success`, `navigate`
@ -275,20 +316,21 @@ vibetunnel-app
### Terminal Components ### Terminal Components
#### Terminal (`terminal.ts`) #### Terminal (`terminal.ts`)
- Full xterm.js implementation (1-1000+) - Full xterm.js implementation: `23-150+`
- Virtual scrolling (537-555) - Virtual scrolling: `537-555`
- Touch/momentum support - Touch/momentum support
- URL highlighting integration - URL highlighting integration
- Custom width selection
- **Events**: `terminal-ready`, `terminal-input`, `terminal-resize`, `url-clicked` - **Events**: `terminal-ready`, `terminal-input`, `terminal-resize`, `url-clicked`
#### VibeTunnelBuffer (`vibe-terminal-buffer.ts`) #### VibeTunnelBuffer (`vibe-terminal-buffer.ts`)
- Read-only terminal preview (25-268) - Read-only terminal preview: `26-268`
- WebSocket buffer subscription - WebSocket buffer subscription
- Auto-resizing - Auto-resizing
- **Events**: `content-changed` - **Events**: `content-changed`
#### SessionView (`session-view.ts`) #### SessionView (`session-view.ts`)
- Full-screen terminal view (29-1331) - Full-screen terminal view: `52-200+`
- Manager architecture: - Manager architecture:
- ConnectionManager: SSE streaming - ConnectionManager: SSE streaming
- InputManager: Keyboard/mouse - InputManager: Keyboard/mouse
@ -300,19 +342,19 @@ vibetunnel-app
### Session Management Components ### Session Management Components
#### SessionList (`session-list.ts`) #### SessionList (`session-list.ts`)
- Grid/list layout (61-700+) - Grid/list layout: `34-150+`
- Hide/show exited sessions - Hide/show exited sessions
- Search and filtering - Search and filtering
- **Events**: `navigate-to-session`, `refresh`, `error`, `success` - **Events**: `navigate-to-session`, `refresh`, `error`, `success`
#### SessionCard (`session-card.ts`) #### SessionCard (`session-card.ts`)
- Individual session display (31-420+) - Individual session display: `25-150+`
- Live terminal preview - Live terminal preview
- Activity detection - Activity detection
- **Events**: `session-select`, `session-killed`, `session-kill-error` - **Events**: `session-select`, `session-killed`, `session-kill-error`
#### SessionCreateForm (`session-create-form.ts`) #### SessionCreateForm (`session-create-form.ts`)
- Modal dialog (27-381) - Modal dialog: `27-381`
- Command input with working directory - Command input with working directory
- Native terminal spawn option - Native terminal spawn option
- **Events**: `session-created`, `cancel`, `error` - **Events**: `session-created`, `cancel`, `error`
@ -320,57 +362,71 @@ vibetunnel-app
### UI Components ### UI Components
#### AppHeader (`app-header.ts`) #### AppHeader (`app-header.ts`)
- Main navigation (15-280+) - Main navigation: `15-280+`
- Session status display - Session status display
- Theme toggle - 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`) #### FileBrowser (`file-browser.ts`)
- Filesystem navigation (48-665) - Filesystem navigation: `48-665`
- Git status display - Browse and select modes
- Monaco editor preview - 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`) #### LogViewer (`log-viewer.ts`)
- Real-time log display (1-432) - Real-time log display: `1-432`
- SSE-style polling - SSE-style polling
- Level filtering - Level filtering and search
- Search functionality
### Services ### Services
#### BufferSubscriptionService (`buffer-subscription-service.ts`) #### BufferSubscriptionService (`buffer-subscription-service.ts`)
- WebSocket client (30-87) - WebSocket client: `77-164`
- Binary protocol decoder (163-208) - Binary protocol decoder: `234-259`
- Auto-reconnection with backoff - Auto-reconnection with backoff
- Per-session subscriptions - 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`) #### PushNotificationService (`push-notification-service.ts`)
- Service worker registration - Service worker registration
- Push subscription management - Push subscription management
- Notification action handling - Notification action handling
#### AuthClient (`auth-client.ts`) #### AuthClient (`auth-client.ts`)
- Token management - Token management: `27-100+`
- Authentication state - SSH key and password authentication
- API header generation - API header generation
- SSH agent integration
### Utils ### Utils
#### CastConverter (`cast-converter.ts`) #### Terminal Utils
- Asciinema v2 parser (31-82) - `terminal-renderer.ts:276-418`: Binary buffer decoder and HTML generation
- SSE stream handler (294-427) - `terminal-utils.ts:14-44`: Terminal resize helpers
- Custom exit event support - `terminal-preferences.ts`: Width preferences management
- `xterm-colors.ts`: Color palette definitions
- `url-highlighter.ts`: URL detection and click handling
#### TerminalRenderer (`terminal-renderer.ts`) #### UI Utils
- Binary buffer decoder (279-424) - `responsive-utils.ts`: Media query observer
- HTML generation from buffer - `title-updater.ts`: Dynamic document title
- Style mapping - `keyboard-shortcut-highlighter.ts`: Shortcut formatting
- `offline-notification-manager.ts`: Offline detection
#### URLHighlighter (`url-highlighter.ts`) #### General Utils
- Multi-line URL detection - `cast-converter.ts:31-82`: Asciinema v2 parser
- Protocol validation - `logger.ts`: Namespaced console logging
- Click event handling - `path-utils.ts`: Path formatting and clipboard
- `constants.ts`: UI timing, breakpoints, z-indexes
## Forward Tool (`src/server/fwd.ts`) ## Forward Tool (`src/server/fwd.ts`)
@ -379,38 +435,44 @@ CLI tool for spawning PTY sessions using VibeTunnel infrastructure.
### Usage ### Usage
```bash ```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 # Examples
npx tsx src/fwd.ts claude --resume pnpm exec tsx src/fwd.ts claude --resume
npx tsx src/fwd.ts --session-id abc123 bash -l pnpm exec tsx src/fwd.ts --session-id abc123 --title-mode dynamic bash -l
``` ```
### Key Features ### Key Features
- Interactive terminal forwarding (43-195) - Interactive terminal forwarding: `58-289`
- Automatic shell alias support via ProcessUtils - Automatic shell alias support via ProcessUtils
- Session ID pre-generation support - Session ID pre-generation support
- Terminal title management modes
- Graceful cleanup on exit - 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 ### Integration Points
- Uses central PTY Manager (78-82) - Uses central PTY Manager: `147-150`
- Control pipe handling delegated to PTY Manager - Terminal resize synchronization: `257-269`
- Terminal resize synchronization (148-163) - Raw mode for proper input capture: `276-277`
- Raw mode for proper input capture (166-172) - Auto-detects Claude commands for dynamic titles: `135-143`
## Build System ## Build System
### Main Build (`scripts/build.js`) ### Main Build (`scripts/build.js`)
- Asset copying (7-121) - Asset copying: `7-121`
- CSS compilation with Tailwind - CSS compilation with Tailwind
- Client bundling with esbuild - Client bundling with esbuild
- Server TypeScript compilation - Server TypeScript compilation
- Native executable creation - Native executable creation
### Native Binary (`scripts/build-native.js`) ### Native Binary (`scripts/build-native.js`)
- Node.js SEA integration (1-537) - Node.js SEA integration: `1-537`
- node-pty patching for compatibility (82-218) - node-pty patching for compatibility: `82-218`
- Outputs: - Outputs:
- `native/vibetunnel`: Main executable - `native/vibetunnel`: Main executable
- `native/pty.node`: Terminal emulation - `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/server.ts`: Server implementation
- `src/server/middleware/auth.ts`: Authentication - `src/server/middleware/auth.ts`: Authentication
- `src/server/routes/sessions.ts`: Session API - `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/pty/pty-manager.ts`: PTY management
- `src/server/services/terminal-manager.ts`: Terminal state - `src/server/services/terminal-manager.ts`: Terminal state
- `src/server/services/activity-monitor.ts`: Activity tracking - `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/terminal.ts`: Terminal renderer
- `src/client/components/session-view.ts`: Session viewer - `src/client/components/session-view.ts`: Session viewer
- `src/client/services/buffer-subscription-service.ts`: WebSocket - `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 - `src/client/utils/cast-converter.ts`: Asciinema parser
### Configuration ### Configuration
@ -446,6 +510,7 @@ npx tsx src/fwd.ts --session-id abc123 bash -l
- REST API: Session CRUD, terminal I/O - REST API: Session CRUD, terminal I/O
- SSE: Real-time asciinema streaming - SSE: Real-time asciinema streaming
- WebSocket: Binary buffer updates - WebSocket: Binary buffer updates
- WebSocket Input: Low-latency keyboard/mouse
- Control pipes: External session control - Control pipes: External session control
### Session Data Storage ### Session Data Storage
@ -454,19 +519,20 @@ Each session has a directory in `~/.vibetunnel/control/[sessionId]/`:
- `stream-out`: Asciinema cast file - `stream-out`: Asciinema cast file
- `stdin`: Input pipe - `stdin`: Input pipe
- `control`: Control pipe - `control`: Control pipe
- `stdout`: Raw output file
- `activity.json`: Activity status - `activity.json`: Activity status
## Development Notes ## Development Notes
### Recent Improvements ### Recent Improvements
- Push notification support for terminal bells - WebSocket input endpoint for lower latency
- Multi-method authentication (SSH keys, JWT, PAM) - Advanced terminal title management system
- Unified logging infrastructure with style guide - Unified shutdown state management
- Activity monitoring for all sessions - Atomic file writes for session data
- Control directory watcher for external sessions - Split-screen support in web UI
- Improved TypeScript types (no "as any") - Enhanced activity monitoring with 100ms precision
- Colorful CLI output with chalk - Improved command resolution for aliases
- Auto-generation of security keys (VAPID) - Better error messages for spawn failures
### Testing ### Testing
- Unit tests: `pnpm test` - Unit tests: `pnpm test`

View file

@ -1,6 +1,7 @@
import chalk from 'chalk'; import chalk from 'chalk';
import type { Response } from 'express'; import type { Response } from 'express';
import * as fs from 'fs'; import * as fs from 'fs';
import type { AsciinemaHeader } from '../pty/types.js';
import { createLogger } from '../utils/logger.js'; import { createLogger } from '../utils/logger.js';
const logger = createLogger('stream-watcher'); const logger = createLogger('stream-watcher');
@ -10,6 +11,34 @@ interface StreamClient {
startTime: number; 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 { interface WatcherInfo {
clients: Set<StreamClient>; clients: Set<StreamClient>;
watcher?: fs.FSWatcher; watcher?: fs.FSWatcher;
@ -122,6 +151,149 @@ export class StreamWatcher {
* Send existing content to a client * Send existing content to a client
*/ */
private sendExistingContent(streamPath: string, client: StreamClient): void { 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 { try {
const stream = fs.createReadStream(streamPath, { encoding: 'utf8' }); const stream = fs.createReadStream(streamPath, { encoding: 'utf8' });
let exitEventFound = false; let exitEventFound = false;

View file

@ -81,7 +81,7 @@ export class PromptDetector {
// Check cache first // Check cache first
if (PromptDetector.onlyPromptCache.has(trimmed)) { if (PromptDetector.onlyPromptCache.has(trimmed)) {
return PromptDetector.onlyPromptCache.get(trimmed)!; return PromptDetector.onlyPromptCache.get(trimmed) ?? false;
} }
const result = PROMPT_ONLY_REGEX.test(trimmed); const result = PROMPT_ONLY_REGEX.test(trimmed);
@ -103,7 +103,7 @@ export class PromptDetector {
// Check cache first // Check cache first
if (PromptDetector.endPromptCache.has(cacheKey)) { if (PromptDetector.endPromptCache.has(cacheKey)) {
return PromptDetector.endPromptCache.get(cacheKey)!; return PromptDetector.endPromptCache.get(cacheKey) ?? false;
} }
// Strip ANSI codes for more reliable detection // Strip ANSI codes for more reliable detection

File diff suppressed because it is too large Load diff

View file

@ -157,3 +157,131 @@ export const mockUser = {
username: 'testuser', username: 'testuser',
token: mockAuthToken, 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'],
],
};

View 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
});
});