mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-03-25 09:25:50 +00:00
Add tmux integration to VibeTunnel (#460)
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
parent
40f4a6f413
commit
dbba6127df
24 changed files with 4116 additions and 4 deletions
204
web/docs/multiplexer-integration.md
Normal file
204
web/docs/multiplexer-integration.md
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
# Terminal Multiplexer Integration
|
||||
|
||||
VibeTunnel supports seamless integration with terminal multiplexers like tmux, Zellij, and GNU Screen, allowing you to attach to existing sessions and manage them through the web interface.
|
||||
|
||||
## Overview
|
||||
|
||||
The multiplexer integration allows you to:
|
||||
- List and attach to existing tmux/Zellij/Screen sessions
|
||||
- Navigate between windows and panes (tmux)
|
||||
- Create new sessions
|
||||
- Kill sessions, windows (tmux), and panes (tmux)
|
||||
- Maintain persistent terminal sessions across connections
|
||||
|
||||
## Supported Multiplexers
|
||||
|
||||
### tmux
|
||||
- Full support for sessions, windows, and panes
|
||||
- Shows session details including creation time, attached status, and window count
|
||||
- Navigate to specific windows and panes
|
||||
- Create sessions with optional initial commands
|
||||
- Kill individual panes, windows, or entire sessions
|
||||
|
||||
### Zellij
|
||||
- Session management with creation time tracking
|
||||
- Automatic session creation on first attach
|
||||
- Layout support for new sessions
|
||||
- ANSI color code handling in session names
|
||||
- Proper cleanup of exited sessions
|
||||
|
||||
### GNU Screen
|
||||
- Session listing and management
|
||||
- Shows session status (attached/detached)
|
||||
- Create new sessions with optional commands
|
||||
- Attach to existing sessions
|
||||
- Kill sessions
|
||||
|
||||
## Usage
|
||||
|
||||
### Accessing Multiplexer Sessions
|
||||
|
||||
1. Click the terminal icon in the session list
|
||||
2. The multiplexer modal will open showing available sessions
|
||||
3. For tmux sessions, expand to see windows and panes
|
||||
4. Click "Attach" to connect to any session, window, or pane
|
||||
|
||||
### Creating New Sessions
|
||||
|
||||
#### tmux
|
||||
```bash
|
||||
# Create a new session
|
||||
POST /api/multiplexer/sessions
|
||||
{
|
||||
"type": "tmux",
|
||||
"name": "dev-session",
|
||||
"command": "vim" // optional initial command
|
||||
}
|
||||
```
|
||||
|
||||
#### Zellij
|
||||
```bash
|
||||
# Create a new session (created on first attach)
|
||||
POST /api/multiplexer/sessions
|
||||
{
|
||||
"type": "zellij",
|
||||
"name": "dev-session",
|
||||
"layout": "compact" // optional layout
|
||||
}
|
||||
```
|
||||
|
||||
#### GNU Screen
|
||||
```bash
|
||||
# Create a new session
|
||||
POST /api/multiplexer/sessions
|
||||
{
|
||||
"type": "screen",
|
||||
"name": "dev-session",
|
||||
"command": "vim" // optional initial command
|
||||
}
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
|
||||
#### Get Multiplexer Status
|
||||
```bash
|
||||
GET /api/multiplexer/status
|
||||
```
|
||||
Returns the availability and session list for all multiplexers.
|
||||
|
||||
#### Get tmux Windows
|
||||
```bash
|
||||
GET /api/multiplexer/tmux/sessions/:session/windows
|
||||
```
|
||||
Returns all windows in a tmux session.
|
||||
|
||||
#### Get tmux Panes
|
||||
```bash
|
||||
GET /api/multiplexer/tmux/sessions/:session/panes?window=:windowIndex
|
||||
```
|
||||
Returns panes in a session or specific window.
|
||||
|
||||
#### Attach to Session
|
||||
```bash
|
||||
POST /api/multiplexer/attach
|
||||
{
|
||||
"type": "tmux|zellij|screen",
|
||||
"sessionName": "main",
|
||||
"windowIndex": 0, // tmux only, optional
|
||||
"paneIndex": 1, // tmux only, optional
|
||||
"cols": 120, // optional terminal dimensions
|
||||
"rows": 40
|
||||
}
|
||||
```
|
||||
|
||||
#### Kill Session
|
||||
```bash
|
||||
DELETE /api/multiplexer/:type/sessions/:sessionName
|
||||
```
|
||||
|
||||
#### Kill Window (tmux only)
|
||||
```bash
|
||||
DELETE /api/multiplexer/tmux/sessions/:sessionName/windows/:windowIndex
|
||||
```
|
||||
|
||||
#### Kill Pane (tmux only)
|
||||
```bash
|
||||
DELETE /api/multiplexer/tmux/sessions/:sessionName/panes/:paneId
|
||||
```
|
||||
|
||||
### Legacy tmux API Compatibility
|
||||
|
||||
The following legacy endpoints are maintained for backward compatibility:
|
||||
- `GET /api/tmux/sessions` - List tmux sessions
|
||||
- `POST /api/tmux/attach` - Attach to tmux session
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Architecture
|
||||
|
||||
The multiplexer integration consists of:
|
||||
- `MultiplexerManager` - Unified interface for all multiplexers
|
||||
- `TmuxManager` - tmux-specific implementation
|
||||
- `ZellijManager` - Zellij-specific implementation
|
||||
- `ScreenManager` - GNU Screen-specific implementation
|
||||
- `multiplexer-modal` - LitElement component for the UI
|
||||
|
||||
### Session Attachment
|
||||
|
||||
When attaching to a multiplexer session:
|
||||
1. A new VibeTunnel PTY session is created
|
||||
2. The session runs the appropriate attach command:
|
||||
- tmux: `tmux attach-session -t main`
|
||||
- Zellij: `zellij attach main`
|
||||
- Screen: `screen -r 12345.main`
|
||||
3. The multiplexer takes over the terminal, providing its own UI
|
||||
4. Users can navigate within the multiplexer using native keybindings
|
||||
|
||||
### Key Features
|
||||
|
||||
#### Automatic Detection
|
||||
The system automatically detects installed multiplexers and only shows available options.
|
||||
|
||||
#### Session Persistence
|
||||
Multiplexer sessions persist even when VibeTunnel is restarted, allowing you to maintain long-running processes.
|
||||
|
||||
#### Native Experience
|
||||
Once attached, you interact with the multiplexer using its native keybindings:
|
||||
- tmux: `Ctrl-B` (default prefix)
|
||||
- Zellij: `Ctrl-G` (default prefix)
|
||||
- Screen: `Ctrl-A` (default prefix)
|
||||
|
||||
#### Clean Session Names
|
||||
Zellij session names are automatically cleaned of ANSI escape codes for better display.
|
||||
|
||||
#### Kill Confirmation
|
||||
All destructive actions (killing sessions, windows, panes) require confirmation to prevent accidental data loss.
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use descriptive session names** - Makes it easier to identify sessions later
|
||||
2. **Organize with windows** (tmux) - Group related tasks in different windows
|
||||
3. **Leverage layouts** (Zellij) - Use predefined layouts for common workflows
|
||||
4. **Clean up old sessions** - Kill sessions you're no longer using to free resources
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Sessions Not Showing
|
||||
- Ensure tmux/Zellij/Screen is installed on the system
|
||||
- Check that sessions exist by running:
|
||||
- tmux: `tmux ls`
|
||||
- Zellij: `zellij list-sessions`
|
||||
- Screen: `screen -ls`
|
||||
|
||||
### Cannot Attach to Session
|
||||
- Verify the session name is correct
|
||||
- Check if the session is already attached elsewhere (some configurations prevent multiple attachments)
|
||||
|
||||
### Display Issues
|
||||
- Ensure terminal dimensions match between client and server
|
||||
- Try resizing the browser window to trigger a resize event
|
||||
|
||||
### Screen-Specific Issues
|
||||
- Screen returns exit code 1 when sessions exist (this is normal behavior)
|
||||
- Session names include PID prefix (e.g., `12345.session-name`)
|
||||
- Use `screen -R` instead of `screen -r` for more forgiving reattachment
|
||||
|
|
@ -26,6 +26,7 @@ import { titleManager } from './utils/title-manager.js';
|
|||
// Import components
|
||||
import './components/app-header.js';
|
||||
import './components/session-create-form.js';
|
||||
import './components/multiplexer-modal.js';
|
||||
import './components/session-list.js';
|
||||
import './components/session-view.js';
|
||||
import './components/session-card.js';
|
||||
|
|
@ -69,6 +70,7 @@ export class VibeTunnelApp extends LitElement {
|
|||
@state() private hideExited = this.loadHideExitedState();
|
||||
@state() private showCreateModal = false;
|
||||
@state() private createDialogWorkingDir = '';
|
||||
@state() private showTmuxModal = false;
|
||||
@state() private showSSHKeyManager = false;
|
||||
@state() private showSettings = false;
|
||||
@state() private isAuthenticated = false;
|
||||
|
|
@ -136,10 +138,12 @@ export class VibeTunnelApp extends LitElement {
|
|||
// Update hasActiveOverlay whenever any overlay state changes
|
||||
if (
|
||||
changedProperties.has('showCreateModal') ||
|
||||
changedProperties.has('showTmuxModal') ||
|
||||
changedProperties.has('showSSHKeyManager') ||
|
||||
changedProperties.has('showSettings')
|
||||
) {
|
||||
this.hasActiveOverlay = this.showCreateModal || this.showSSHKeyManager || this.showSettings;
|
||||
this.hasActiveOverlay =
|
||||
this.showCreateModal || this.showTmuxModal || this.showSSHKeyManager || this.showSettings;
|
||||
}
|
||||
|
||||
// Force re-render when sessions change or view changes to update log button position
|
||||
|
|
@ -1529,6 +1533,10 @@ export class VibeTunnelApp extends LitElement {
|
|||
this.handleCreateSession();
|
||||
};
|
||||
|
||||
private handleOpenTmuxSessions = () => {
|
||||
this.showTmuxModal = true;
|
||||
};
|
||||
|
||||
private handleCaptureToggled = (e: CustomEvent) => {
|
||||
logger.log(`🎯 handleCaptureToggled called with:`, e.detail);
|
||||
this.keyboardCaptureActive = e.detail.active;
|
||||
|
|
@ -1760,6 +1768,7 @@ export class VibeTunnelApp extends LitElement {
|
|||
@kill-all-sessions=${this.handleKillAll}
|
||||
@clean-exited-sessions=${this.handleCleanExited}
|
||||
@open-file-browser=${this.handleOpenFileBrowser}
|
||||
@open-tmux-sessions=${this.handleOpenTmuxSessions}
|
||||
@open-settings=${this.handleOpenSettings}
|
||||
@logout=${this.handleLogout}
|
||||
@navigate-to-list=${this.handleNavigateToList}
|
||||
|
|
@ -1874,6 +1883,15 @@ export class VibeTunnelApp extends LitElement {
|
|||
<!-- Git Notification Handler -->
|
||||
<git-notification-handler></git-notification-handler>
|
||||
|
||||
<!-- Multiplexer Modal (tmux/Zellij) -->
|
||||
<multiplexer-modal
|
||||
.open=${this.showTmuxModal}
|
||||
@close=${() => {
|
||||
this.showTmuxModal = false;
|
||||
}}
|
||||
@navigate-to-session=${this.handleNavigateToSession}
|
||||
@create-session=${this.handleCreateSession}
|
||||
></multiplexer-modal>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
* @fires kill-all-sessions - When kill all button is clicked
|
||||
* @fires clean-exited-sessions - When clean exited button is clicked
|
||||
* @fires open-file-browser - When browse button is clicked
|
||||
* @fires open-tmux-sessions - When tmux sessions button is clicked
|
||||
* @fires logout - When logout is clicked
|
||||
* @fires toggle-sidebar - When sidebar toggle button is clicked
|
||||
*/
|
||||
|
|
@ -59,6 +60,7 @@ export class AppHeader extends LitElement {
|
|||
@kill-all-sessions=${this.forwardEvent}
|
||||
@clean-exited-sessions=${this.forwardEvent}
|
||||
@open-file-browser=${this.forwardEvent}
|
||||
@open-tmux-sessions=${this.forwardEvent}
|
||||
@open-settings=${this.forwardEvent}
|
||||
@logout=${this.forwardEvent}
|
||||
@navigate-to-list=${this.forwardEvent}
|
||||
|
|
@ -79,6 +81,7 @@ export class AppHeader extends LitElement {
|
|||
@kill-all-sessions=${this.forwardEvent}
|
||||
@clean-exited-sessions=${this.forwardEvent}
|
||||
@open-file-browser=${this.forwardEvent}
|
||||
@open-tmux-sessions=${this.forwardEvent}
|
||||
@open-settings=${this.forwardEvent}
|
||||
@logout=${this.forwardEvent}
|
||||
@navigate-to-list=${this.forwardEvent}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,15 @@ export class FullHeader extends HeaderBase {
|
|||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="p-2 bg-bg-tertiary text-muted border border-border hover:border-primary hover:text-primary hover:bg-surface-hover rounded-lg transition-all duration-200"
|
||||
@click=${this.handleOpenTmuxSessions}
|
||||
title="tmux Sessions"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M2 2v12h12V2H2zM1 2a1 1 0 011-1h12a1 1 0 011 1v12a1 1 0 01-1 1H2a1 1 0 01-1-1V2zm7 3h5v2H8V5zm0 3h5v2H8V8zm0 3h5v2H8v-2zM3 5h4v2H3V5zm0 3h4v2H3V8zm0 3h4v2H3v-2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="p-2 bg-primary text-text-bright hover:bg-primary-light rounded-lg transition-all duration-200 vt-create-button"
|
||||
@click=${this.handleCreateSession}
|
||||
|
|
|
|||
|
|
@ -71,6 +71,10 @@ export abstract class HeaderBase extends LitElement {
|
|||
this.dispatchEvent(new CustomEvent('open-file-browser'));
|
||||
}
|
||||
|
||||
protected handleOpenTmuxSessions() {
|
||||
this.dispatchEvent(new CustomEvent('open-tmux-sessions'));
|
||||
}
|
||||
|
||||
protected handleOpenSettings() {
|
||||
this.showUserMenu = false;
|
||||
this.dispatchEvent(new CustomEvent('open-settings'));
|
||||
|
|
|
|||
648
web/src/client/components/multiplexer-modal.ts
Normal file
648
web/src/client/components/multiplexer-modal.ts
Normal file
|
|
@ -0,0 +1,648 @@
|
|||
import type { PropertyValues } from 'lit';
|
||||
import { html, LitElement } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import type {
|
||||
MultiplexerStatus,
|
||||
MultiplexerTarget,
|
||||
MultiplexerType,
|
||||
TmuxPane,
|
||||
TmuxWindow,
|
||||
} from '../../shared/multiplexer-types.js';
|
||||
import { apiClient } from '../services/api-client.js';
|
||||
import './modal-wrapper.js';
|
||||
|
||||
@customElement('multiplexer-modal')
|
||||
export class MultiplexerModal extends LitElement {
|
||||
// Disable shadow DOM to use Tailwind classes
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
@property({ type: Boolean, reflect: true })
|
||||
open = false;
|
||||
|
||||
@state()
|
||||
private activeTab: MultiplexerType = 'tmux';
|
||||
|
||||
@state()
|
||||
private multiplexerStatus: MultiplexerStatus | null = null;
|
||||
|
||||
@state()
|
||||
private windows: Map<string, TmuxWindow[]> = new Map();
|
||||
|
||||
@state()
|
||||
private panes: Map<string, TmuxPane[]> = new Map();
|
||||
|
||||
@state()
|
||||
private expandedSessions: Set<string> = new Set();
|
||||
|
||||
@state()
|
||||
private expandedWindows: Set<string> = new Set();
|
||||
|
||||
@state()
|
||||
private loading = true;
|
||||
|
||||
@state()
|
||||
private error: string | null = null;
|
||||
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (this.open) {
|
||||
await this.loadMultiplexerStatus();
|
||||
}
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues) {
|
||||
if (changedProps.has('open') && this.open) {
|
||||
this.loadMultiplexerStatus();
|
||||
}
|
||||
}
|
||||
|
||||
private async loadMultiplexerStatus() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
// Get status of all multiplexers
|
||||
const statusResponse = await apiClient.get<MultiplexerStatus>('/multiplexer/status');
|
||||
this.multiplexerStatus = statusResponse;
|
||||
|
||||
// Set active tab to first available multiplexer
|
||||
if (!statusResponse.tmux.available) {
|
||||
if (statusResponse.zellij.available) {
|
||||
this.activeTab = 'zellij';
|
||||
} else if (statusResponse.screen.available) {
|
||||
this.activeTab = 'screen';
|
||||
}
|
||||
}
|
||||
|
||||
// Load windows for tmux sessions
|
||||
this.windows.clear();
|
||||
if (statusResponse.tmux.available) {
|
||||
for (const session of statusResponse.tmux.sessions) {
|
||||
try {
|
||||
const windowsResponse = await apiClient.get<{ windows: TmuxWindow[] }>(
|
||||
`/multiplexer/tmux/sessions/${session.name}/windows`
|
||||
);
|
||||
this.windows.set(session.name, windowsResponse.windows);
|
||||
} catch (error) {
|
||||
console.error(`Failed to load windows for tmux session ${session.name}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load multiplexer status:', error);
|
||||
this.error = 'Failed to load terminal sessions';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private toggleSession(sessionName: string) {
|
||||
if (this.expandedSessions.has(sessionName)) {
|
||||
this.expandedSessions.delete(sessionName);
|
||||
} else {
|
||||
this.expandedSessions.add(sessionName);
|
||||
}
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private toggleWindow(sessionName: string, windowIndex: number) {
|
||||
const key = `${sessionName}:${windowIndex}`;
|
||||
if (this.expandedWindows.has(key)) {
|
||||
this.expandedWindows.delete(key);
|
||||
} else {
|
||||
this.expandedWindows.add(key);
|
||||
// Load panes for this window if not already loaded
|
||||
this.loadPanesForWindow(sessionName, windowIndex);
|
||||
}
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private async loadPanesForWindow(sessionName: string, windowIndex: number) {
|
||||
const key = `${sessionName}:${windowIndex}`;
|
||||
if (this.panes.has(key)) return; // Already loaded
|
||||
|
||||
try {
|
||||
const response = await apiClient.get<{ panes: TmuxPane[] }>(
|
||||
`/multiplexer/tmux/sessions/${sessionName}/panes?window=${windowIndex}`
|
||||
);
|
||||
this.panes.set(key, response.panes);
|
||||
this.requestUpdate();
|
||||
} catch (error) {
|
||||
console.error(`Failed to load panes for window ${key}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
private formatTimestamp(timestamp: string): string {
|
||||
const ts = Number.parseInt(timestamp, 10);
|
||||
if (Number.isNaN(ts)) return timestamp;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const diff = now - ts;
|
||||
|
||||
if (diff < 60) return `${diff}s ago`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
return `${Math.floor(diff / 86400)}d ago`;
|
||||
}
|
||||
|
||||
private formatPaneInfo(pane: TmuxPane): string {
|
||||
// If we have a meaningful title that's not just the hostname, use it
|
||||
if (pane.title && !pane.title.includes('< /dev/null') && !pane.title.match(/^[\w.-]+$/)) {
|
||||
return pane.title;
|
||||
}
|
||||
|
||||
// If we have a current path, show it with the command
|
||||
if (pane.currentPath && pane.command) {
|
||||
// Simple home directory replacement for display
|
||||
const shortPath = pane.currentPath.replace(/^\/Users\/[^/]+/, '~');
|
||||
return `${pane.command} (${shortPath})`;
|
||||
}
|
||||
|
||||
// Otherwise just show command or 'shell'
|
||||
return pane.command || 'shell';
|
||||
}
|
||||
|
||||
private async attachToSession(target: MultiplexerTarget) {
|
||||
try {
|
||||
const response = await apiClient.post<{
|
||||
success: boolean;
|
||||
sessionId?: string;
|
||||
command?: string;
|
||||
}>('/multiplexer/attach', {
|
||||
type: target.type,
|
||||
sessionName: target.session,
|
||||
windowIndex: target.window,
|
||||
paneIndex: target.pane,
|
||||
cols: window.innerWidth > 768 ? 120 : 80,
|
||||
rows: window.innerHeight > 600 ? 30 : 24,
|
||||
titleMode: 'dynamic',
|
||||
metadata: {
|
||||
source: 'multiplexer-modal',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
// Close modal and navigate to the new session
|
||||
this.handleClose();
|
||||
// Dispatch navigation event that the app can handle
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('navigate-to-session', {
|
||||
detail: { sessionId: response.sessionId },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to attach to ${target.type} session:`, error);
|
||||
this.error = `Failed to attach to ${target.type} session`;
|
||||
}
|
||||
}
|
||||
|
||||
private async createNewSession() {
|
||||
try {
|
||||
// Generate a unique session name
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
|
||||
const sessionName = `session-${timestamp}`;
|
||||
|
||||
if (this.activeTab === 'tmux' || this.activeTab === 'screen') {
|
||||
// For tmux and screen, create the session first
|
||||
const createResponse = await apiClient.post<{ success: boolean }>('/multiplexer/sessions', {
|
||||
type: this.activeTab,
|
||||
name: sessionName,
|
||||
});
|
||||
|
||||
if (!createResponse.success) {
|
||||
throw new Error(`Failed to create ${this.activeTab} session`);
|
||||
}
|
||||
}
|
||||
|
||||
// For all multiplexers, attach to the session
|
||||
// Zellij will create the session automatically with the -c flag
|
||||
const attachResponse = await apiClient.post<{
|
||||
success: boolean;
|
||||
sessionId?: string;
|
||||
command?: string;
|
||||
}>('/multiplexer/attach', {
|
||||
type: this.activeTab,
|
||||
sessionName: sessionName,
|
||||
cols: window.innerWidth > 768 ? 120 : 80,
|
||||
rows: window.innerHeight > 600 ? 30 : 24,
|
||||
titleMode: 'dynamic',
|
||||
metadata: {
|
||||
source: 'multiplexer-modal-new',
|
||||
},
|
||||
});
|
||||
|
||||
if (attachResponse.success) {
|
||||
// Close modal and navigate to the new session
|
||||
this.handleClose();
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('navigate-to-session', {
|
||||
detail: { sessionId: attachResponse.sessionId },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to create new ${this.activeTab} session:`, error);
|
||||
this.error = `Failed to create new ${this.activeTab} session`;
|
||||
}
|
||||
}
|
||||
|
||||
private async killSession(type: MultiplexerType, sessionName: string) {
|
||||
if (
|
||||
!confirm(
|
||||
`Are you sure you want to kill session "${sessionName}"? This will terminate all windows and panes.`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.delete<{ success: boolean }>(
|
||||
`/multiplexer/${type}/sessions/${sessionName}`
|
||||
);
|
||||
if (response.success) {
|
||||
await this.loadMultiplexerStatus();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to kill ${type} session:`, error);
|
||||
this.error = `Failed to kill ${type} session`;
|
||||
}
|
||||
}
|
||||
|
||||
private async killWindow(sessionName: string, windowIndex: number) {
|
||||
if (
|
||||
!confirm(
|
||||
`Are you sure you want to kill window ${windowIndex}? This will terminate all panes in this window.`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.delete<{ success: boolean }>(
|
||||
`/multiplexer/tmux/sessions/${sessionName}/windows/${windowIndex}`
|
||||
);
|
||||
if (response.success) {
|
||||
await this.loadMultiplexerStatus();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to kill window:`, error);
|
||||
this.error = `Failed to kill window`;
|
||||
}
|
||||
}
|
||||
|
||||
private async killPane(sessionName: string, paneId: string) {
|
||||
if (!confirm(`Are you sure you want to kill this pane?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.delete<{ success: boolean }>(
|
||||
`/multiplexer/tmux/sessions/${sessionName}/panes/${paneId}`
|
||||
);
|
||||
if (response.success) {
|
||||
// Reload panes for the affected window
|
||||
this.panes.clear();
|
||||
this.expandedWindows.forEach((key) => {
|
||||
const [session, windowStr] = key.split(':');
|
||||
if (session === sessionName) {
|
||||
this.loadPanesForWindow(session, Number.parseInt(windowStr, 10));
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to kill pane:`, error);
|
||||
this.error = `Failed to kill pane`;
|
||||
}
|
||||
}
|
||||
|
||||
private handleClose() {
|
||||
this.dispatchEvent(new CustomEvent('close'));
|
||||
}
|
||||
|
||||
private switchTab(type: MultiplexerType) {
|
||||
this.activeTab = type;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.open) return null;
|
||||
|
||||
const status = this.multiplexerStatus;
|
||||
const activeMultiplexer = status ? status[this.activeTab] : null;
|
||||
|
||||
return html`
|
||||
<div class="fixed inset-0 z-50 ${this.open ? 'flex' : 'hidden'} items-center justify-center p-4">
|
||||
<modal-wrapper .open=${this.open} @close=${this.handleClose}>
|
||||
<div class="w-full max-w-2xl max-h-[80vh] flex flex-col bg-bg-secondary border border-border rounded-xl p-6 shadow-xl">
|
||||
<h2 class="m-0 mb-4 text-xl font-semibold text-text">Terminal Sessions</h2>
|
||||
|
||||
${
|
||||
status &&
|
||||
(status.tmux.available || status.zellij.available || status.screen.available)
|
||||
? html`
|
||||
<div class="flex gap-2 mb-4 border-b border-border">
|
||||
${
|
||||
status.tmux.available
|
||||
? html`
|
||||
<button
|
||||
class="px-4 py-2 border-none bg-transparent text-text-muted cursor-pointer relative transition-colors hover:text-text ${this.activeTab === 'tmux' ? 'text-primary' : ''}"
|
||||
@click=${() => this.switchTab('tmux')}
|
||||
>
|
||||
tmux
|
||||
<span class="ml-2 text-xs px-1.5 py-0.5 bg-bg-tertiary rounded-full">${status.tmux.sessions.length}</span>
|
||||
${this.activeTab === 'tmux' ? html`<div class="absolute bottom-[-1px] left-0 right-0 h-0.5 bg-primary"></div>` : ''}
|
||||
</button>
|
||||
`
|
||||
: null
|
||||
}
|
||||
${
|
||||
status.zellij.available
|
||||
? html`
|
||||
<button
|
||||
class="px-4 py-2 border-none bg-transparent text-text-muted cursor-pointer relative transition-colors hover:text-text ${this.activeTab === 'zellij' ? 'text-primary' : ''}"
|
||||
@click=${() => this.switchTab('zellij')}
|
||||
>
|
||||
Zellij
|
||||
<span class="ml-2 text-xs px-1.5 py-0.5 bg-bg-tertiary rounded-full">${status.zellij.sessions.length}</span>
|
||||
${this.activeTab === 'zellij' ? html`<div class="absolute bottom-[-1px] left-0 right-0 h-0.5 bg-primary"></div>` : ''}
|
||||
</button>
|
||||
`
|
||||
: null
|
||||
}
|
||||
${
|
||||
status.screen.available
|
||||
? html`
|
||||
<button
|
||||
class="px-4 py-2 border-none bg-transparent text-text-muted cursor-pointer relative transition-colors hover:text-text ${this.activeTab === 'screen' ? 'text-primary' : ''}"
|
||||
@click=${() => this.switchTab('screen')}
|
||||
>
|
||||
Screen
|
||||
<span class="ml-2 text-xs px-1.5 py-0.5 bg-bg-tertiary rounded-full">${status.screen.sessions.length}</span>
|
||||
${this.activeTab === 'screen' ? html`<div class="absolute bottom-[-1px] left-0 right-0 h-0.5 bg-primary"></div>` : ''}
|
||||
</button>
|
||||
`
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
`
|
||||
: null
|
||||
}
|
||||
|
||||
${
|
||||
this.loading
|
||||
? html`<div class="mb-4 p-3 bg-bg-tertiary rounded-lg text-text-muted text-center">Loading terminal sessions...</div>`
|
||||
: !status
|
||||
? html`<div class="mb-4 p-3 bg-bg-tertiary rounded-lg text-text-muted text-center">No multiplexer status available</div>`
|
||||
: !status.tmux.available && !status.zellij.available && !status.screen.available
|
||||
? html`
|
||||
<div class="text-center py-12 text-text-muted">
|
||||
<h3 class="m-0 mb-2 text-text">No Terminal Multiplexer Available</h3>
|
||||
<p>No terminal multiplexer (tmux, Zellij, or Screen) is installed on this system.</p>
|
||||
<p>Install tmux, Zellij, or GNU Screen to use this feature.</p>
|
||||
</div>
|
||||
`
|
||||
: !activeMultiplexer?.available
|
||||
? html`
|
||||
<div class="text-center py-12 text-text-muted">
|
||||
<h3 class="m-0 mb-2 text-text">${this.activeTab} Not Available</h3>
|
||||
<p>${this.activeTab} is not installed or not available on this system.</p>
|
||||
<p>Install ${this.activeTab} to use this feature.</p>
|
||||
</div>
|
||||
`
|
||||
: this.error
|
||||
? html`<div class="mb-4 p-3 bg-bg-tertiary rounded-lg text-text-muted text-center">${this.error}</div>`
|
||||
: activeMultiplexer.sessions.length === 0
|
||||
? html`
|
||||
<div class="text-center py-12 text-text-muted">
|
||||
<h3 class="m-0 mb-2 text-text">No ${this.activeTab} Sessions</h3>
|
||||
<p>There are no active ${this.activeTab} sessions.</p>
|
||||
<button class="mt-4 px-6 py-3 bg-primary text-white border-none rounded-md text-sm cursor-pointer transition-colors hover:bg-primary-hover" @click=${this.createNewSession}>
|
||||
Create New Session
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="flex-1 overflow-y-auto -mx-4 px-4">
|
||||
${repeat(
|
||||
activeMultiplexer.sessions,
|
||||
(session) => `${session.type}-${session.name}`,
|
||||
(session) => {
|
||||
const sessionWindows = this.windows.get(session.name) || [];
|
||||
const isExpanded = this.expandedSessions.has(session.name);
|
||||
|
||||
return html`
|
||||
<div class="mb-2 border border-border rounded-lg overflow-hidden transition-all hover:border-primary hover:shadow-md">
|
||||
<div
|
||||
class="px-4 py-3 bg-bg-secondary cursor-pointer flex items-center justify-between transition-colors hover:bg-bg-tertiary"
|
||||
@click=${() =>
|
||||
session.type === 'tmux' ? this.toggleSession(session.name) : null}
|
||||
style="cursor: ${session.type === 'tmux' ? 'pointer' : 'default'}"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<div class="font-semibold text-text mb-1">${session.name}</div>
|
||||
<div class="text-sm text-text-muted flex gap-4">
|
||||
${
|
||||
session.windows !== undefined
|
||||
? html`<span>${session.windows} window${session.windows !== 1 ? 's' : ''}</span>`
|
||||
: null
|
||||
}
|
||||
${
|
||||
session.exited
|
||||
? html`<span class="bg-red-500 text-white px-1.5 py-0.5 rounded text-xs font-semibold">EXITED</span>`
|
||||
: null
|
||||
}
|
||||
${
|
||||
session.activity
|
||||
? html`<span>Last activity: ${this.formatTimestamp(session.activity)}</span>`
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
${
|
||||
session.attached
|
||||
? html`<div class="w-2 h-2 rounded-full bg-primary" title="Attached"></div>`
|
||||
: null
|
||||
}
|
||||
${
|
||||
session.current
|
||||
? html`<div class="w-2 h-2 rounded-full bg-primary" title="Current"></div>`
|
||||
: null
|
||||
}
|
||||
<button
|
||||
class="px-3 py-1.5 bg-primary text-white border-none rounded text-xs font-medium cursor-pointer transition-colors hover:bg-primary-hover active:scale-95"
|
||||
@click=${(e: Event) => {
|
||||
e.stopPropagation();
|
||||
this.attachToSession({
|
||||
type: session.type,
|
||||
session: session.name,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Attach
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1.5 bg-red-500 text-white border-none rounded text-xs font-medium cursor-pointer transition-colors hover:bg-red-600 active:scale-95"
|
||||
@click=${(e: Event) => {
|
||||
e.stopPropagation();
|
||||
this.killSession(session.type, session.name);
|
||||
}}
|
||||
title="Kill session"
|
||||
>
|
||||
Kill
|
||||
</button>
|
||||
${
|
||||
session.type === 'tmux'
|
||||
? html`<span class="transition-transform ${isExpanded ? 'rotate-90' : ''}">▶</span>`
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${
|
||||
session.type === 'tmux' && isExpanded && sessionWindows.length > 0
|
||||
? html`
|
||||
<div class="px-2 py-2 pl-8 bg-bg border-t border-border">
|
||||
${repeat(
|
||||
sessionWindows,
|
||||
(window) => `${session.name}-${window.index}`,
|
||||
(window) => {
|
||||
const windowKey = `${session.name}:${window.index}`;
|
||||
const isWindowExpanded =
|
||||
this.expandedWindows.has(windowKey);
|
||||
const windowPanes = this.panes.get(windowKey) || [];
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div
|
||||
class="p-2 mb-1 rounded cursor-pointer flex items-center justify-between transition-colors hover:bg-bg-secondary ${window.active ? 'bg-bg-tertiary font-medium' : ''}"
|
||||
@click=${(e: Event) => {
|
||||
e.stopPropagation();
|
||||
if (window.panes > 1) {
|
||||
this.toggleWindow(session.name, window.index);
|
||||
} else {
|
||||
this.attachToSession({
|
||||
type: session.type,
|
||||
session: session.name,
|
||||
window: window.index,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-mono text-sm text-text-muted">${window.index}:</span>
|
||||
<span>${window.name}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="px-2 py-0.5 bg-red-500 text-white border-none rounded text-xs font-medium cursor-pointer transition-colors hover:bg-red-600 active:scale-95"
|
||||
@click=${(e: Event) => {
|
||||
e.stopPropagation();
|
||||
this.killWindow(session.name, window.index);
|
||||
}}
|
||||
title="Kill window"
|
||||
>
|
||||
Kill
|
||||
</button>
|
||||
<span class="text-xs text-text-dim">
|
||||
${window.panes} pane${window.panes !== 1 ? 's' : ''}
|
||||
${window.panes > 1 ? html`<span class="ml-2 transition-transform ${isWindowExpanded ? 'rotate-90' : ''}">▶</span>` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${
|
||||
isWindowExpanded && windowPanes.length > 0
|
||||
? html`
|
||||
<div class="px-1 py-1 pl-6 bg-bg border-t border-border">
|
||||
${repeat(
|
||||
windowPanes,
|
||||
(pane) =>
|
||||
`${session.name}:${window.index}.${pane.index}`,
|
||||
(pane) => html`
|
||||
<div
|
||||
class="px-2 py-1.5 mb-0.5 rounded cursor-pointer flex items-center justify-between text-sm transition-colors hover:bg-bg-secondary ${pane.active ? 'bg-bg-tertiary font-medium' : ''}"
|
||||
@click=${(e: Event) => {
|
||||
e.stopPropagation();
|
||||
this.attachToSession({
|
||||
type: session.type,
|
||||
session: session.name,
|
||||
window: window.index,
|
||||
pane: pane.index,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-mono text-xs text-text-muted">%${pane.index}</span>
|
||||
<span class="text-text">${this.formatPaneInfo(pane)}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="px-2 py-0.5 bg-red-500 text-white border-none rounded text-xs font-medium cursor-pointer transition-colors hover:bg-red-600 active:scale-95"
|
||||
@click=${(e: Event) => {
|
||||
e.stopPropagation();
|
||||
this.killPane(
|
||||
session.name,
|
||||
`${session.name}:${window.index}.${pane.index}`
|
||||
);
|
||||
}}
|
||||
title="Kill pane"
|
||||
>
|
||||
Kill
|
||||
</button>
|
||||
<span class="text-xs text-text-dim">${pane.width}×${pane.height}</span>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
<div class="mt-4 flex gap-2 justify-end">
|
||||
<button class="px-4 py-2 border border-border rounded-md bg-bg-secondary text-text text-sm cursor-pointer transition-all hover:bg-bg-tertiary hover:border-primary" @click=${this.handleClose}>Cancel</button>
|
||||
${
|
||||
!this.loading && activeMultiplexer?.available
|
||||
? html`
|
||||
<button class="px-4 py-2 bg-primary text-white border border-primary rounded-md text-sm cursor-pointer transition-colors hover:bg-primary-hover" @click=${this.createNewSession}>
|
||||
New Session
|
||||
</button>
|
||||
`
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</modal-wrapper>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'multiplexer-modal': MultiplexerModal;
|
||||
}
|
||||
}
|
||||
|
|
@ -76,6 +76,16 @@ export class SidebarHeader extends HeaderBase {
|
|||
|
||||
<!-- Action buttons group with consistent styling -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<!-- tmux Sessions button -->
|
||||
<button
|
||||
class="p-2 text-primary bg-bg-tertiary border border-border hover:bg-surface-hover hover:border-primary rounded-md transition-all duration-200 flex-shrink-0"
|
||||
@click=${this.handleOpenTmuxSessions}
|
||||
title="tmux Sessions"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M2 2v12h12V2H2zM1 2a1 1 0 011-1h12a1 1 0 011 1v12a1 1 0 01-1 1H2a1 1 0 01-1-1V2zm7 3h5v2H8V5zm0 3h5v2H8V8zm0 3h5v2H8v-2zM3 5h4v2H3V5zm0 3h4v2H3V8zm0 3h4v2H3v-2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Create Session button with dark theme styling -->
|
||||
<button
|
||||
class="p-2 text-primary bg-bg-tertiary border border-border hover:bg-surface-hover hover:border-primary rounded-md transition-all duration-200 flex-shrink-0"
|
||||
|
|
|
|||
148
web/src/client/services/api-client.ts
Normal file
148
web/src/client/services/api-client.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { createLogger } from '../utils/logger.js';
|
||||
import { authClient } from './auth-client.js';
|
||||
|
||||
const logger = createLogger('api-client');
|
||||
|
||||
/**
|
||||
* Standard error response structure from the API
|
||||
*
|
||||
* @interface ErrorResponse
|
||||
* @property {string} [message] - Human-readable error message describing what went wrong
|
||||
* @property {string} [error] - Technical error code or identifier for programmatic handling
|
||||
*/
|
||||
interface ErrorResponse {
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP client for making authenticated API requests to the VibeTunnel backend.
|
||||
* Automatically includes authentication headers and handles error responses.
|
||||
*/
|
||||
class ApiClient {
|
||||
/**
|
||||
* Make a GET request to the API
|
||||
* @param path - The API endpoint path (without /api prefix)
|
||||
* @returns Promise resolving to the response data
|
||||
*/
|
||||
async get<T = unknown>(path: string): Promise<T> {
|
||||
try {
|
||||
const response = await fetch(`/api${path}`, {
|
||||
headers: {
|
||||
...authClient.getAuthHeader(),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await this.parseError(response);
|
||||
throw new Error(error.message || `Request failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
logger.error(`GET ${path} failed:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a POST request to the API
|
||||
* @param path - The API endpoint path (without /api prefix)
|
||||
* @param data - Request body data
|
||||
* @returns Promise resolving to the response data
|
||||
*/
|
||||
async post<T = unknown>(path: string, data?: unknown): Promise<T> {
|
||||
try {
|
||||
const response = await fetch(`/api${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...authClient.getAuthHeader(),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await this.parseError(response);
|
||||
throw new Error(error.message || `Request failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
logger.error(`POST ${path} failed:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a PUT request to the API
|
||||
* @param path - The API endpoint path (without /api prefix)
|
||||
* @param data - Request body data
|
||||
* @returns Promise resolving to the response data
|
||||
*/
|
||||
async put<T = unknown>(path: string, data: unknown): Promise<T> {
|
||||
try {
|
||||
const response = await fetch(`/api${path}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
...authClient.getAuthHeader(),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await this.parseError(response);
|
||||
throw new Error(error.message || `Request failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
logger.error(`PUT ${path} failed:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a DELETE request to the API
|
||||
* @param path - The API endpoint path (without /api prefix)
|
||||
* @returns Promise resolving to the response data
|
||||
*/
|
||||
async delete<T = unknown>(path: string): Promise<T> {
|
||||
try {
|
||||
const response = await fetch(`/api${path}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
...authClient.getAuthHeader(),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await this.parseError(response);
|
||||
throw new Error(error.message || `Request failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
return text ? JSON.parse(text) : ({} as T);
|
||||
} catch (error) {
|
||||
logger.error(`DELETE ${path} failed:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async parseError(response: Response): Promise<ErrorResponse> {
|
||||
try {
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType?.includes('application/json')) {
|
||||
return await response.json();
|
||||
}
|
||||
return { message: await response.text() };
|
||||
} catch {
|
||||
return { message: response.statusText };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient();
|
||||
|
|
@ -548,7 +548,7 @@ describe('PushNotificationService', () => {
|
|||
await new Promise((resolve) => setTimeout(resolve, 100)); // allow time for listener to be registered
|
||||
|
||||
expect(testNotificationHandler!).toBeDefined();
|
||||
testNotificationHandler!({
|
||||
testNotificationHandler?.({
|
||||
title: 'VibeTunnel Test',
|
||||
body: 'Push notifications are working correctly!',
|
||||
});
|
||||
|
|
@ -589,7 +589,7 @@ describe('PushNotificationService', () => {
|
|||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect(testNotificationHandler!).toBeDefined();
|
||||
testNotificationHandler!({});
|
||||
testNotificationHandler?.({});
|
||||
|
||||
await testPromise;
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export const Z_INDEX = {
|
|||
|
||||
// Modals and overlays (100-199)
|
||||
MODAL_BACKDROP: 100,
|
||||
MODAL: 105,
|
||||
FILE_PICKER: 110,
|
||||
SESSION_EXITED_OVERLAY: 120,
|
||||
NOTIFICATION: 150, // Notifications appear above modals but below file browser
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { EventEmitter } from 'events';
|
||||
import type { Request, Response } from 'express';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { PtyManager } from '../pty/index.js';
|
||||
import { createEventsRouter } from './events.js';
|
||||
|
||||
// Mock dependencies
|
||||
|
|
|
|||
171
web/src/server/routes/multiplexer.ts
Normal file
171
web/src/server/routes/multiplexer.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import { Router } from 'express';
|
||||
import type { MultiplexerType } from '../../shared/multiplexer-types.js';
|
||||
import type { SessionCreateOptions } from '../../shared/types.js';
|
||||
import type { PtyManager } from '../pty/pty-manager.js';
|
||||
import { MultiplexerManager } from '../services/multiplexer-manager.js';
|
||||
import { createLogger } from '../utils/logger.js';
|
||||
|
||||
const logger = createLogger('multiplexer-routes');
|
||||
|
||||
export function createMultiplexerRoutes(options: { ptyManager: PtyManager }): Router {
|
||||
const { ptyManager } = options;
|
||||
const router = Router();
|
||||
const multiplexerManager = MultiplexerManager.getInstance(ptyManager);
|
||||
|
||||
/**
|
||||
* Get available multiplexers and their sessions
|
||||
*/
|
||||
router.get('/status', async (_req, res) => {
|
||||
try {
|
||||
const status = await multiplexerManager.getAvailableMultiplexers();
|
||||
res.json(status);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get multiplexer status', { error });
|
||||
res.status(500).json({ error: 'Failed to get multiplexer status' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get windows for a tmux session
|
||||
*/
|
||||
router.get('/tmux/sessions/:sessionName/windows', async (req, res) => {
|
||||
try {
|
||||
const { sessionName } = req.params;
|
||||
const windows = await multiplexerManager.getTmuxWindows(sessionName);
|
||||
res.json({ windows });
|
||||
} catch (error) {
|
||||
logger.error('Failed to list tmux windows', { error });
|
||||
res.status(500).json({ error: 'Failed to list tmux windows' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get panes for a tmux window
|
||||
*/
|
||||
router.get('/tmux/sessions/:sessionName/panes', async (req, res) => {
|
||||
try {
|
||||
const { sessionName } = req.params;
|
||||
const windowIndex = req.query.window
|
||||
? Number.parseInt(req.query.window as string, 10)
|
||||
: undefined;
|
||||
const panes = await multiplexerManager.getTmuxPanes(sessionName, windowIndex);
|
||||
res.json({ panes });
|
||||
} catch (error) {
|
||||
logger.error('Failed to list tmux panes', { error });
|
||||
res.status(500).json({ error: 'Failed to list tmux panes' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a new session
|
||||
*/
|
||||
router.post('/sessions', async (req, res) => {
|
||||
try {
|
||||
const { type, name, options } = req.body;
|
||||
|
||||
if (!type || !name) {
|
||||
return res.status(400).json({ error: 'Type and name are required' });
|
||||
}
|
||||
|
||||
await multiplexerManager.createSession(type, name, options);
|
||||
res.json({ success: true, type, name });
|
||||
} catch (error) {
|
||||
logger.error('Failed to create session', { error });
|
||||
res.status(500).json({ error: 'Failed to create session' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Attach to a session
|
||||
*/
|
||||
router.post('/attach', async (req, res) => {
|
||||
try {
|
||||
const { type, sessionName, windowIndex, paneIndex, cols, rows, workingDir, titleMode } =
|
||||
req.body;
|
||||
|
||||
if (!type || !sessionName) {
|
||||
return res.status(400).json({ error: 'Type and session name are required' });
|
||||
}
|
||||
|
||||
const options: Partial<SessionCreateOptions> & {
|
||||
windowIndex?: number;
|
||||
paneIndex?: number;
|
||||
} = {
|
||||
cols,
|
||||
rows,
|
||||
workingDir,
|
||||
titleMode,
|
||||
windowIndex,
|
||||
paneIndex,
|
||||
};
|
||||
|
||||
const sessionId = await multiplexerManager.attachToSession(type, sessionName, options);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
sessionId,
|
||||
target: {
|
||||
type,
|
||||
session: sessionName,
|
||||
window: windowIndex,
|
||||
pane: paneIndex,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to attach to session', { error });
|
||||
res.status(500).json({ error: 'Failed to attach to session' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Kill a session
|
||||
*/
|
||||
router.delete('/:type/sessions/:sessionName', async (req, res) => {
|
||||
try {
|
||||
const { type, sessionName } = req.params;
|
||||
await multiplexerManager.killSession(type as MultiplexerType, sessionName);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Failed to kill session', { error });
|
||||
res.status(500).json({ error: 'Failed to kill session' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Kill a tmux window
|
||||
*/
|
||||
router.delete('/tmux/sessions/:sessionName/windows/:windowIndex', async (req, res) => {
|
||||
try {
|
||||
const { sessionName, windowIndex } = req.params;
|
||||
await multiplexerManager.killTmuxWindow(sessionName, Number.parseInt(windowIndex, 10));
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Failed to kill window', { error });
|
||||
res.status(500).json({ error: 'Failed to kill window' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Kill a tmux pane
|
||||
*/
|
||||
router.delete('/tmux/sessions/:sessionName/panes/:paneId', async (req, res) => {
|
||||
try {
|
||||
const { sessionName, paneId } = req.params;
|
||||
await multiplexerManager.killTmuxPane(sessionName, paneId);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Failed to kill pane', { error });
|
||||
res.status(500).json({ error: 'Failed to kill pane' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get current multiplexer context
|
||||
*/
|
||||
router.get('/context', (_req, res) => {
|
||||
const context = multiplexerManager.getCurrentMultiplexer();
|
||||
res.json({ context });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
176
web/src/server/routes/tmux.ts
Normal file
176
web/src/server/routes/tmux.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import { Router } from 'express';
|
||||
import type { SessionCreateOptions } from '../../shared/types.js';
|
||||
import type { PtyManager } from '../pty/pty-manager.js';
|
||||
import { TmuxManager } from '../services/tmux-manager.js';
|
||||
import { createLogger } from '../utils/logger.js';
|
||||
|
||||
const logger = createLogger('tmux-routes');
|
||||
|
||||
export function createTmuxRoutes(options: { ptyManager: PtyManager }): Router {
|
||||
const { ptyManager } = options;
|
||||
const router = Router();
|
||||
const tmuxManager = TmuxManager.getInstance(ptyManager);
|
||||
|
||||
/**
|
||||
* Check if tmux is available
|
||||
*/
|
||||
router.get('/available', async (_req, res) => {
|
||||
try {
|
||||
const available = await tmuxManager.isAvailable();
|
||||
res.json({ available });
|
||||
} catch (error) {
|
||||
logger.error('Failed to check tmux availability', { error });
|
||||
res.status(500).json({ error: 'Failed to check tmux availability' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* List all tmux sessions
|
||||
*/
|
||||
router.get('/sessions', async (_req, res) => {
|
||||
try {
|
||||
const sessions = await tmuxManager.listSessions();
|
||||
res.json({ sessions });
|
||||
} catch (error) {
|
||||
logger.error('Failed to list tmux sessions', { error });
|
||||
res.status(500).json({ error: 'Failed to list tmux sessions' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* List windows in a tmux session
|
||||
*/
|
||||
router.get('/sessions/:sessionName/windows', async (req, res) => {
|
||||
try {
|
||||
const { sessionName } = req.params;
|
||||
const windows = await tmuxManager.listWindows(sessionName);
|
||||
res.json({ windows });
|
||||
} catch (error) {
|
||||
logger.error('Failed to list tmux windows', { error });
|
||||
res.status(500).json({ error: 'Failed to list tmux windows' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* List panes in a tmux session or window
|
||||
*/
|
||||
router.get('/sessions/:sessionName/panes', async (req, res) => {
|
||||
try {
|
||||
const { sessionName } = req.params;
|
||||
const windowIndex = req.query.window
|
||||
? Number.parseInt(req.query.window as string, 10)
|
||||
: undefined;
|
||||
const panes = await tmuxManager.listPanes(sessionName, windowIndex);
|
||||
res.json({ panes });
|
||||
} catch (error) {
|
||||
logger.error('Failed to list tmux panes', { error });
|
||||
res.status(500).json({ error: 'Failed to list tmux panes' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a new tmux session
|
||||
*/
|
||||
router.post('/sessions', async (req, res) => {
|
||||
try {
|
||||
const { name, command } = req.body;
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'Session name is required' });
|
||||
}
|
||||
await tmuxManager.createSession(name, command);
|
||||
res.json({ success: true, name });
|
||||
} catch (error) {
|
||||
logger.error('Failed to create tmux session', { error });
|
||||
res.status(500).json({ error: 'Failed to create tmux session' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Attach to a tmux session/window/pane
|
||||
*/
|
||||
router.post('/attach', async (req, res) => {
|
||||
try {
|
||||
const { sessionName, windowIndex, paneIndex, cols, rows, workingDir, titleMode } = req.body;
|
||||
|
||||
if (!sessionName) {
|
||||
return res.status(400).json({ error: 'Session name is required' });
|
||||
}
|
||||
|
||||
const options: Partial<SessionCreateOptions> = {
|
||||
cols,
|
||||
rows,
|
||||
workingDir,
|
||||
titleMode,
|
||||
};
|
||||
|
||||
const sessionId = await tmuxManager.attachToTmux(
|
||||
sessionName,
|
||||
windowIndex,
|
||||
paneIndex,
|
||||
options
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
sessionId,
|
||||
target: {
|
||||
session: sessionName,
|
||||
window: windowIndex,
|
||||
pane: paneIndex,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to attach to tmux session', { error });
|
||||
res.status(500).json({ error: 'Failed to attach to tmux session' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Send command to a tmux pane
|
||||
*/
|
||||
router.post('/sessions/:sessionName/send', async (req, res) => {
|
||||
try {
|
||||
const { sessionName } = req.params;
|
||||
const { command, windowIndex, paneIndex } = req.body;
|
||||
|
||||
if (!command) {
|
||||
return res.status(400).json({ error: 'Command is required' });
|
||||
}
|
||||
|
||||
await tmuxManager.sendToPane(sessionName, command, windowIndex, paneIndex);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Failed to send command to tmux pane', { error });
|
||||
res.status(500).json({ error: 'Failed to send command to tmux pane' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Kill a tmux session
|
||||
*/
|
||||
router.delete('/sessions/:sessionName', async (req, res) => {
|
||||
try {
|
||||
const { sessionName } = req.params;
|
||||
await tmuxManager.killSession(sessionName);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Failed to kill tmux session', { error });
|
||||
res.status(500).json({ error: 'Failed to kill tmux session' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get current tmux context (if inside tmux)
|
||||
*/
|
||||
router.get('/context', (_req, res) => {
|
||||
const insideTmux = tmuxManager.isInsideTmux();
|
||||
const currentSession = tmuxManager.getCurrentSession();
|
||||
|
||||
res.json({
|
||||
insideTmux,
|
||||
currentSession,
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
|
@ -23,11 +23,13 @@ import { createFileRoutes } from './routes/files.js';
|
|||
import { createFilesystemRoutes } from './routes/filesystem.js';
|
||||
import { createGitRoutes } from './routes/git.js';
|
||||
import { createLogRoutes } from './routes/logs.js';
|
||||
import { createMultiplexerRoutes } from './routes/multiplexer.js';
|
||||
import { createPushRoutes } from './routes/push.js';
|
||||
import { createRemoteRoutes } from './routes/remotes.js';
|
||||
import { createRepositoryRoutes } from './routes/repositories.js';
|
||||
import { createSessionRoutes } from './routes/sessions.js';
|
||||
import { createTestNotificationRouter } from './routes/test-notification.js';
|
||||
import { createTmuxRoutes } from './routes/tmux.js';
|
||||
import { WebSocketInputHandler } from './routes/websocket-input.js';
|
||||
import { createWorktreeRoutes } from './routes/worktrees.js';
|
||||
import { ActivityMonitor } from './services/activity-monitor.js';
|
||||
|
|
@ -902,6 +904,14 @@ export async function createApp(): Promise<AppInstance> {
|
|||
app.use('/api', createControlRoutes());
|
||||
logger.debug('Mounted control routes');
|
||||
|
||||
// Mount tmux routes
|
||||
app.use('/api/tmux', createTmuxRoutes({ ptyManager }));
|
||||
logger.debug('Mounted tmux routes');
|
||||
|
||||
// Mount multiplexer routes (unified tmux/zellij interface)
|
||||
app.use('/api/multiplexer', createMultiplexerRoutes({ ptyManager }));
|
||||
logger.debug('Mounted multiplexer routes');
|
||||
|
||||
// Mount push notification routes
|
||||
if (vapidManager) {
|
||||
app.use(
|
||||
|
|
|
|||
230
web/src/server/services/multiplexer-manager.ts
Normal file
230
web/src/server/services/multiplexer-manager.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
import type {
|
||||
MultiplexerSession,
|
||||
MultiplexerStatus,
|
||||
MultiplexerType,
|
||||
TmuxPane,
|
||||
TmuxWindow,
|
||||
} from '../../shared/multiplexer-types.js';
|
||||
import type { SessionCreateOptions } from '../../shared/types.js';
|
||||
import { TitleMode } from '../../shared/types.js';
|
||||
import type { PtyManager } from '../pty/pty-manager.js';
|
||||
import { createLogger } from '../utils/logger.js';
|
||||
import { ScreenManager } from './screen-manager.js';
|
||||
import { TmuxManager } from './tmux-manager.js';
|
||||
import { ZellijManager } from './zellij-manager.js';
|
||||
|
||||
const logger = createLogger('MultiplexerManager');
|
||||
|
||||
export class MultiplexerManager {
|
||||
private static instance: MultiplexerManager;
|
||||
private tmuxManager: TmuxManager;
|
||||
private zellijManager: ZellijManager;
|
||||
private screenManager: ScreenManager;
|
||||
private ptyManager: PtyManager;
|
||||
|
||||
private constructor(ptyManager: PtyManager) {
|
||||
this.ptyManager = ptyManager;
|
||||
this.tmuxManager = TmuxManager.getInstance(ptyManager);
|
||||
this.zellijManager = ZellijManager.getInstance(ptyManager);
|
||||
this.screenManager = ScreenManager.getInstance();
|
||||
}
|
||||
|
||||
static getInstance(ptyManager: PtyManager): MultiplexerManager {
|
||||
if (!MultiplexerManager.instance) {
|
||||
MultiplexerManager.instance = new MultiplexerManager(ptyManager);
|
||||
}
|
||||
return MultiplexerManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available multiplexers and their sessions
|
||||
*/
|
||||
async getAvailableMultiplexers(): Promise<MultiplexerStatus> {
|
||||
const [tmuxAvailable, zellijAvailable, screenAvailable] = await Promise.all([
|
||||
this.tmuxManager.isAvailable(),
|
||||
this.zellijManager.isAvailable(),
|
||||
this.screenManager.isAvailable(),
|
||||
]);
|
||||
|
||||
const result: MultiplexerStatus = {
|
||||
tmux: {
|
||||
available: tmuxAvailable,
|
||||
type: 'tmux' as MultiplexerType,
|
||||
sessions: [] as MultiplexerSession[],
|
||||
},
|
||||
zellij: {
|
||||
available: zellijAvailable,
|
||||
type: 'zellij' as MultiplexerType,
|
||||
sessions: [] as MultiplexerSession[],
|
||||
},
|
||||
screen: {
|
||||
available: screenAvailable,
|
||||
type: 'screen' as MultiplexerType,
|
||||
sessions: [] as MultiplexerSession[],
|
||||
},
|
||||
};
|
||||
|
||||
// Load sessions for available multiplexers
|
||||
if (tmuxAvailable) {
|
||||
try {
|
||||
const tmuxSessions = await this.tmuxManager.listSessions();
|
||||
result.tmux.sessions = tmuxSessions.map((session) => ({
|
||||
...session,
|
||||
type: 'tmux' as MultiplexerType,
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('Failed to list tmux sessions', { error });
|
||||
}
|
||||
}
|
||||
|
||||
if (zellijAvailable) {
|
||||
try {
|
||||
const zellijSessions = await this.zellijManager.listSessions();
|
||||
result.zellij.sessions = zellijSessions.map((session) => ({
|
||||
...session,
|
||||
type: 'zellij' as MultiplexerType,
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('Failed to list zellij sessions', { error });
|
||||
}
|
||||
}
|
||||
|
||||
if (screenAvailable) {
|
||||
try {
|
||||
const screenSessions = await this.screenManager.listSessions();
|
||||
result.screen.sessions = screenSessions.map((session) => ({
|
||||
...session,
|
||||
type: 'screen' as MultiplexerType,
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('Failed to list screen sessions', { error });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get windows for a tmux session
|
||||
*/
|
||||
async getTmuxWindows(sessionName: string): Promise<TmuxWindow[]> {
|
||||
return this.tmuxManager.listWindows(sessionName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get panes for a tmux window
|
||||
*/
|
||||
async getTmuxPanes(sessionName: string, windowIndex?: number): Promise<TmuxPane[]> {
|
||||
return this.tmuxManager.listPanes(sessionName, windowIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new session
|
||||
*/
|
||||
async createSession(
|
||||
type: MultiplexerType,
|
||||
name: string,
|
||||
options?: { command?: string[]; layout?: string }
|
||||
): Promise<void> {
|
||||
if (type === 'tmux') {
|
||||
await this.tmuxManager.createSession(name, options?.command);
|
||||
} else if (type === 'zellij') {
|
||||
await this.zellijManager.createSession(name, options?.layout);
|
||||
} else if (type === 'screen') {
|
||||
// Screen expects a single command string, not an array
|
||||
const command = options?.command ? options.command.join(' ') : undefined;
|
||||
await this.screenManager.createSession(name, command);
|
||||
} else {
|
||||
throw new Error(`Unknown multiplexer type: ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach to a session
|
||||
*/
|
||||
async attachToSession(
|
||||
type: MultiplexerType,
|
||||
sessionName: string,
|
||||
options?: Partial<SessionCreateOptions> & { windowIndex?: number; paneIndex?: number }
|
||||
): Promise<string> {
|
||||
if (type === 'tmux') {
|
||||
return this.tmuxManager.attachToTmux(
|
||||
sessionName,
|
||||
options?.windowIndex,
|
||||
options?.paneIndex,
|
||||
options
|
||||
);
|
||||
} else if (type === 'zellij') {
|
||||
return this.zellijManager.attachToZellij(sessionName, options);
|
||||
} else if (type === 'screen') {
|
||||
// Screen doesn't support programmatic attach like tmux/zellij
|
||||
// We need to create a new session that runs the attach command
|
||||
const attachCmd = await this.screenManager.attachToSession(sessionName);
|
||||
// Create a new PTY session that will run the screen attach command
|
||||
const result = await this.ptyManager.createSession(attachCmd, {
|
||||
...options,
|
||||
titleMode: TitleMode.DYNAMIC,
|
||||
});
|
||||
return result.sessionId;
|
||||
} else {
|
||||
throw new Error(`Unknown multiplexer type: ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill/delete a session
|
||||
*/
|
||||
async killSession(type: MultiplexerType, sessionName: string): Promise<void> {
|
||||
if (type === 'tmux') {
|
||||
await this.tmuxManager.killSession(sessionName);
|
||||
} else if (type === 'zellij') {
|
||||
await this.zellijManager.killSession(sessionName);
|
||||
} else if (type === 'screen') {
|
||||
await this.screenManager.killSession(sessionName);
|
||||
} else {
|
||||
throw new Error(`Unknown multiplexer type: ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a tmux window
|
||||
*/
|
||||
async killTmuxWindow(sessionName: string, windowIndex: number): Promise<void> {
|
||||
await this.tmuxManager.killWindow(sessionName, windowIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a tmux pane
|
||||
*/
|
||||
async killTmuxPane(sessionName: string, paneId: string): Promise<void> {
|
||||
await this.tmuxManager.killPane(sessionName, paneId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check which multiplexer we're currently inside
|
||||
*/
|
||||
getCurrentMultiplexer(): { type: MultiplexerType; session: string } | null {
|
||||
if (this.tmuxManager.isInsideTmux()) {
|
||||
const session = this.tmuxManager.getCurrentSession();
|
||||
if (session) {
|
||||
return { type: 'tmux', session };
|
||||
}
|
||||
}
|
||||
|
||||
if (this.zellijManager.isInsideZellij()) {
|
||||
const session = this.zellijManager.getCurrentSession();
|
||||
if (session) {
|
||||
return { type: 'zellij', session };
|
||||
}
|
||||
}
|
||||
|
||||
if (this.screenManager.isInsideScreen()) {
|
||||
const session = this.screenManager.getCurrentSession();
|
||||
if (session) {
|
||||
return { type: 'screen', session };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
297
web/src/server/services/screen-manager.ts
Normal file
297
web/src/server/services/screen-manager.ts
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import type { MultiplexerSession } from '../../shared/multiplexer-types.js';
|
||||
import { createLogger } from '../utils/logger.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const logger = createLogger('screen-manager');
|
||||
|
||||
/**
|
||||
* GNU Screen manager for terminal multiplexing
|
||||
*
|
||||
* Note: GNU Screen has a simpler model than tmux:
|
||||
* - Sessions (like tmux sessions)
|
||||
* - Windows (like tmux windows)
|
||||
* - No panes concept (screen uses split regions but they're not addressable like tmux panes)
|
||||
*/
|
||||
export class ScreenManager {
|
||||
private static instance: ScreenManager;
|
||||
|
||||
static getInstance(): ScreenManager {
|
||||
if (!ScreenManager.instance) {
|
||||
ScreenManager.instance = new ScreenManager();
|
||||
}
|
||||
return ScreenManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate session name to prevent command injection
|
||||
*/
|
||||
private validateSessionName(name: string): void {
|
||||
if (!name || typeof name !== 'string') {
|
||||
throw new Error('Session name must be a non-empty string');
|
||||
}
|
||||
// Allow dots for screen sessions (PID.name format), but still restrict dangerous chars
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
|
||||
throw new Error(
|
||||
'Session name can only contain letters, numbers, dots, dashes, and underscores'
|
||||
);
|
||||
}
|
||||
if (name.length > 100) {
|
||||
throw new Error('Session name too long (max 100 characters)');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate window index
|
||||
*/
|
||||
private validateWindowIndex(index: number): void {
|
||||
if (!Number.isInteger(index) || index < 0 || index > 999) {
|
||||
throw new Error('Window index must be an integer between 0 and 999');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if screen is available
|
||||
*/
|
||||
async isAvailable(): Promise<boolean> {
|
||||
try {
|
||||
await execFileAsync('which', ['screen']);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all screen sessions
|
||||
* Screen output format: <pid>.<sessionname>\t(<status>)
|
||||
* Example: 12345.my-session (Detached)
|
||||
*/
|
||||
async listSessions(): Promise<MultiplexerSession[]> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('screen', ['-ls']).catch((error) => {
|
||||
// Screen returns exit code 1 when there are sessions (non-zero means "has sessions")
|
||||
// We need to check the output to determine if it's a real error
|
||||
if (error.stdout && !error.stdout.includes('No Sockets found')) {
|
||||
return { stdout: error.stdout, stderr: error.stderr };
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
|
||||
const lines = stdout.split('\n');
|
||||
const sessions: MultiplexerSession[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
// Match lines like: 12345.session-name (Detached)
|
||||
// Note: session name may contain dots, so we match until tab character
|
||||
const match = line.match(/^\s*(\d+)\.([^\t]+)\s*\t\s*\(([^)]+)\)/);
|
||||
if (match) {
|
||||
const [, pid, name, status] = match;
|
||||
sessions.push({
|
||||
name: `${pid}.${name}`, // Use full name including PID for uniqueness
|
||||
type: 'screen',
|
||||
attached: status.toLowerCase().includes('attached'),
|
||||
exited: status.toLowerCase().includes('dead'),
|
||||
// Screen doesn't provide window count in list output
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return sessions;
|
||||
} catch (error) {
|
||||
// If no sessions exist, screen returns "No Sockets found"
|
||||
if (
|
||||
error instanceof Error &&
|
||||
'stdout' in error &&
|
||||
typeof error.stdout === 'string' &&
|
||||
error.stdout.includes('No Sockets found')
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
logger.error('Failed to list screen sessions', { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new screen session
|
||||
*/
|
||||
async createSession(sessionName: string, command?: string): Promise<void> {
|
||||
this.validateSessionName(sessionName);
|
||||
|
||||
try {
|
||||
// Remove PID prefix if present (for creating new sessions)
|
||||
const cleanName = sessionName.includes('.')
|
||||
? sessionName.split('.').slice(1).join('.')
|
||||
: sessionName;
|
||||
|
||||
const args = ['screen', '-dmS', cleanName];
|
||||
|
||||
// If command is provided, validate and add it
|
||||
if (command) {
|
||||
if (typeof command !== 'string') {
|
||||
throw new Error('Command must be a string');
|
||||
}
|
||||
// For screen, we need to pass the command as a single argument
|
||||
// Screen expects the command and its args as separate elements
|
||||
args.push(command);
|
||||
}
|
||||
|
||||
await execFileAsync(args[0], args.slice(1));
|
||||
logger.info('Created screen session', { sessionName: cleanName });
|
||||
} catch (error) {
|
||||
logger.error('Failed to create screen session', { sessionName, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach to a screen session
|
||||
* For programmatic use, we'll create a new window in the session
|
||||
*/
|
||||
async attachToSession(sessionName: string, command?: string): Promise<string[]> {
|
||||
try {
|
||||
// For newly created sessions, we might need to wait a bit or handle differently
|
||||
// First check if this looks like a full session name with PID
|
||||
const isFullName = /^\d+\./.test(sessionName);
|
||||
|
||||
if (!isFullName) {
|
||||
// This is a simple name, we need to find the full name with PID
|
||||
const sessions = await this.listSessions();
|
||||
const session = sessions.find((s) => {
|
||||
// Check if the session name ends with our provided name
|
||||
const parts = s.name.split('.');
|
||||
const simpleName = parts.slice(1).join('.');
|
||||
return simpleName === sessionName;
|
||||
});
|
||||
|
||||
if (session) {
|
||||
sessionName = session.name;
|
||||
} else {
|
||||
// Session might have just been created, use -R flag which is more forgiving
|
||||
return ['screen', '-R', sessionName];
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new window in the session if command is provided
|
||||
if (command) {
|
||||
if (typeof command !== 'string') {
|
||||
throw new Error('Command must be a string');
|
||||
}
|
||||
await execFileAsync('screen', ['-S', sessionName, '-X', 'screen', command]);
|
||||
}
|
||||
|
||||
// Return a command array that can be used to attach
|
||||
// Use -r for existing sessions with full name
|
||||
return ['screen', '-r', sessionName];
|
||||
} catch (error) {
|
||||
logger.error('Failed to attach to screen session', { sessionName, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a screen session
|
||||
*/
|
||||
async killSession(sessionName: string): Promise<void> {
|
||||
this.validateSessionName(sessionName);
|
||||
|
||||
try {
|
||||
// Screen can be killed using the full name with PID or just the PID
|
||||
await execFileAsync('screen', ['-S', sessionName, '-X', 'quit']);
|
||||
logger.info('Killed screen session', { sessionName });
|
||||
} catch (error) {
|
||||
logger.error('Failed to kill screen session', { sessionName, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if inside a screen session
|
||||
*/
|
||||
isInsideScreen(): boolean {
|
||||
return !!process.env.STY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current screen session name if inside screen
|
||||
*/
|
||||
getCurrentSession(): string | null {
|
||||
const sty = process.env.STY;
|
||||
if (!sty) return null;
|
||||
|
||||
// STY format is pid.sessionname or pid.tty.host
|
||||
const parts = sty.split('.');
|
||||
if (parts.length >= 2) {
|
||||
return parts.slice(1).join('.');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* List windows in a screen session
|
||||
* Note: This is more limited than tmux - screen doesn't provide easy machine-readable output
|
||||
*/
|
||||
async listWindows(sessionName: string): Promise<Array<{ index: number; name: string }>> {
|
||||
try {
|
||||
// Screen doesn't have a good way to list windows programmatically
|
||||
// We could parse the windowlist output but it's not reliable
|
||||
// For now, return empty array
|
||||
logger.warn('Window listing not fully implemented for screen');
|
||||
return [];
|
||||
} catch (error) {
|
||||
logger.error('Failed to list screen windows', { sessionName, error });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new window in a screen session
|
||||
*/
|
||||
async createWindow(sessionName: string, windowName?: string, command?: string): Promise<void> {
|
||||
this.validateSessionName(sessionName);
|
||||
|
||||
try {
|
||||
const args = ['screen', '-S', sessionName, '-X', 'screen'];
|
||||
|
||||
if (windowName) {
|
||||
if (typeof windowName !== 'string' || windowName.length > 50) {
|
||||
throw new Error('Window name must be a string (max 50 characters)');
|
||||
}
|
||||
args.push('-t', windowName);
|
||||
}
|
||||
|
||||
if (command) {
|
||||
if (typeof command !== 'string') {
|
||||
throw new Error('Command must be a string');
|
||||
}
|
||||
args.push(command);
|
||||
}
|
||||
|
||||
await execFileAsync(args[0], args.slice(1));
|
||||
logger.info('Created window in screen session', { sessionName, windowName });
|
||||
} catch (error) {
|
||||
logger.error('Failed to create window', { sessionName, windowName, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a window in a screen session
|
||||
* Note: Screen uses window numbers, not names for targeting
|
||||
*/
|
||||
async killWindow(sessionName: string, windowIndex: number): Promise<void> {
|
||||
this.validateSessionName(sessionName);
|
||||
this.validateWindowIndex(windowIndex);
|
||||
|
||||
try {
|
||||
// First select the window, then kill it
|
||||
await execFileAsync('screen', ['-S', sessionName, '-p', String(windowIndex), '-X', 'kill']);
|
||||
logger.info('Killed window in screen session', { sessionName, windowIndex });
|
||||
} catch (error) {
|
||||
logger.error('Failed to kill window', { sessionName, windowIndex, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
370
web/src/server/services/tmux-manager.ts
Normal file
370
web/src/server/services/tmux-manager.ts
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
import { execFile, execFileSync } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import type { TmuxPane, TmuxSession, TmuxWindow } from '../../shared/tmux-types.js';
|
||||
import { type SessionCreateOptions, TitleMode } from '../../shared/types.js';
|
||||
import type { PtyManager } from '../pty/pty-manager.js';
|
||||
import { createLogger } from '../utils/logger.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const logger = createLogger('TmuxManager');
|
||||
|
||||
export class TmuxManager {
|
||||
private static instance: TmuxManager;
|
||||
private ptyManager: PtyManager;
|
||||
|
||||
private constructor(ptyManager: PtyManager) {
|
||||
this.ptyManager = ptyManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate session name to prevent command injection
|
||||
*/
|
||||
private validateSessionName(name: string): void {
|
||||
if (!name || typeof name !== 'string') {
|
||||
throw new Error('Session name must be a non-empty string');
|
||||
}
|
||||
// Only allow alphanumeric, dash, underscore, and dot
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
|
||||
throw new Error(
|
||||
'Session name can only contain letters, numbers, dots, dashes, and underscores'
|
||||
);
|
||||
}
|
||||
if (name.length > 100) {
|
||||
throw new Error('Session name too long (max 100 characters)');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate window index
|
||||
*/
|
||||
private validateWindowIndex(index: number): void {
|
||||
if (!Number.isInteger(index) || index < 0 || index > 999) {
|
||||
throw new Error('Window index must be an integer between 0 and 999');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate pane index
|
||||
*/
|
||||
private validatePaneIndex(index: number): void {
|
||||
if (!Number.isInteger(index) || index < 0 || index > 999) {
|
||||
throw new Error('Pane index must be an integer between 0 and 999');
|
||||
}
|
||||
}
|
||||
|
||||
static getInstance(ptyManager: PtyManager): TmuxManager {
|
||||
if (!TmuxManager.instance) {
|
||||
TmuxManager.instance = new TmuxManager(ptyManager);
|
||||
}
|
||||
return TmuxManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tmux is installed and available
|
||||
*/
|
||||
async isAvailable(): Promise<boolean> {
|
||||
try {
|
||||
await execFileAsync('which', ['tmux']);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all tmux sessions
|
||||
*/
|
||||
async listSessions(): Promise<TmuxSession[]> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('tmux', [
|
||||
'list-sessions',
|
||||
'-F',
|
||||
'#{session_name}|#{session_windows}|#{session_created}|#{?session_attached,attached,detached}|#{session_activity}|#{?session_active,active,}',
|
||||
]);
|
||||
|
||||
return stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => line?.includes('|'))
|
||||
.map((line) => {
|
||||
const [name, windows, created, attached, activity, current] = line.split('|');
|
||||
return {
|
||||
name,
|
||||
windows: Number.parseInt(windows, 10),
|
||||
created,
|
||||
attached: attached === 'attached',
|
||||
activity,
|
||||
current: current === 'active',
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('no server running')) {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List windows in a tmux session
|
||||
*/
|
||||
async listWindows(sessionName: string): Promise<TmuxWindow[]> {
|
||||
this.validateSessionName(sessionName);
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync('tmux', [
|
||||
'list-windows',
|
||||
'-t',
|
||||
sessionName,
|
||||
'-F',
|
||||
'#{session_name}|#{window_index}|#{window_name}|#{?window_active,active,}|#{window_panes}',
|
||||
]);
|
||||
|
||||
return stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => line)
|
||||
.map((line) => {
|
||||
const [session, index, name, active, panes] = line.split('|');
|
||||
return {
|
||||
session,
|
||||
index: Number.parseInt(index, 10),
|
||||
name,
|
||||
active: active === 'active',
|
||||
panes: Number.parseInt(panes, 10),
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to list windows', { sessionName, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List panes in a window
|
||||
*/
|
||||
async listPanes(sessionName: string, windowIndex?: number): Promise<TmuxPane[]> {
|
||||
this.validateSessionName(sessionName);
|
||||
if (windowIndex !== undefined) {
|
||||
this.validateWindowIndex(windowIndex);
|
||||
}
|
||||
|
||||
try {
|
||||
const targetArgs =
|
||||
windowIndex !== undefined ? [sessionName, String(windowIndex)].join(':') : sessionName;
|
||||
|
||||
const { stdout } = await execFileAsync('tmux', [
|
||||
'list-panes',
|
||||
'-t',
|
||||
targetArgs,
|
||||
'-F',
|
||||
'#{session_name}|#{window_index}|#{pane_index}|#{?pane_active,active,}|#{pane_title}|#{pane_pid}|#{pane_current_command}|#{pane_width}|#{pane_height}|#{pane_current_path}',
|
||||
]);
|
||||
|
||||
return stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => line)
|
||||
.map((line) => {
|
||||
const [session, window, index, active, title, pid, command, width, height, currentPath] =
|
||||
line.split('|');
|
||||
return {
|
||||
session,
|
||||
window: Number.parseInt(window, 10),
|
||||
index: Number.parseInt(index, 10),
|
||||
active: active === 'active',
|
||||
title: title || undefined,
|
||||
pid: pid ? Number.parseInt(pid, 10) : undefined,
|
||||
command: command || undefined,
|
||||
width: Number.parseInt(width, 10),
|
||||
height: Number.parseInt(height, 10),
|
||||
currentPath: currentPath || undefined,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to list panes', { sessionName, windowIndex, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new tmux session
|
||||
*/
|
||||
async createSession(name: string, command?: string[]): Promise<void> {
|
||||
this.validateSessionName(name);
|
||||
|
||||
try {
|
||||
const args = ['new-session', '-d', '-s', name];
|
||||
|
||||
// If command is provided, add it as separate arguments
|
||||
if (command && command.length > 0) {
|
||||
// Validate command arguments
|
||||
for (const arg of command) {
|
||||
if (typeof arg !== 'string') {
|
||||
throw new Error('Command arguments must be strings');
|
||||
}
|
||||
}
|
||||
args.push(...command);
|
||||
}
|
||||
|
||||
await execFileAsync('tmux', args);
|
||||
logger.info('Created tmux session', { name, command });
|
||||
} catch (error) {
|
||||
logger.error('Failed to create tmux session', { name, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach to a tmux session/window/pane through VibeTunnel
|
||||
*/
|
||||
async attachToTmux(
|
||||
sessionName: string,
|
||||
windowIndex?: number,
|
||||
paneIndex?: number,
|
||||
options?: Partial<SessionCreateOptions>
|
||||
): Promise<string> {
|
||||
let target = sessionName;
|
||||
if (windowIndex !== undefined) {
|
||||
target = `${sessionName}:${windowIndex}`;
|
||||
if (paneIndex !== undefined) {
|
||||
target = `${target}.${paneIndex}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Always attach to session/window level, not individual panes
|
||||
// This gives users full control over pane management once attached
|
||||
const attachTarget = windowIndex !== undefined ? `${sessionName}:${windowIndex}` : sessionName;
|
||||
const tmuxCommand = ['tmux', 'attach-session', '-t', attachTarget];
|
||||
|
||||
// Create a new VibeTunnel session that runs tmux attach
|
||||
const sessionOptions: SessionCreateOptions = {
|
||||
name: `tmux: ${target}`,
|
||||
workingDir: options?.workingDir || process.env.HOME || '/',
|
||||
cols: options?.cols || 80,
|
||||
rows: options?.rows || 24,
|
||||
titleMode: options?.titleMode || TitleMode.DYNAMIC,
|
||||
};
|
||||
|
||||
const session = await this.ptyManager.createSession(tmuxCommand, sessionOptions);
|
||||
return session.sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a command to a specific tmux pane
|
||||
*/
|
||||
async sendToPane(
|
||||
sessionName: string,
|
||||
command: string,
|
||||
windowIndex?: number,
|
||||
paneIndex?: number
|
||||
): Promise<void> {
|
||||
this.validateSessionName(sessionName);
|
||||
if (windowIndex !== undefined) {
|
||||
this.validateWindowIndex(windowIndex);
|
||||
}
|
||||
if (paneIndex !== undefined) {
|
||||
this.validatePaneIndex(paneIndex);
|
||||
}
|
||||
|
||||
if (typeof command !== 'string') {
|
||||
throw new Error('Command must be a string');
|
||||
}
|
||||
|
||||
let targetArgs = sessionName;
|
||||
if (windowIndex !== undefined) {
|
||||
targetArgs = `${sessionName}:${windowIndex}`;
|
||||
if (paneIndex !== undefined) {
|
||||
targetArgs = `${targetArgs}.${paneIndex}`;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Use send-keys to send the command
|
||||
await execFileAsync('tmux', ['send-keys', '-t', targetArgs, command, 'Enter']);
|
||||
logger.info('Sent command to tmux pane', { target: targetArgs, command });
|
||||
} catch (error) {
|
||||
logger.error('Failed to send command to tmux pane', { target: targetArgs, command, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a tmux session
|
||||
*/
|
||||
async killSession(sessionName: string): Promise<void> {
|
||||
this.validateSessionName(sessionName);
|
||||
|
||||
try {
|
||||
await execFileAsync('tmux', ['kill-session', '-t', sessionName]);
|
||||
logger.info('Killed tmux session', { sessionName });
|
||||
} catch (error) {
|
||||
logger.error('Failed to kill tmux session', { sessionName, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a tmux window
|
||||
*/
|
||||
async killWindow(sessionName: string, windowIndex: number): Promise<void> {
|
||||
this.validateSessionName(sessionName);
|
||||
this.validateWindowIndex(windowIndex);
|
||||
|
||||
try {
|
||||
const target = `${sessionName}:${windowIndex}`;
|
||||
await execFileAsync('tmux', ['kill-window', '-t', target]);
|
||||
logger.info('Killed tmux window', { sessionName, windowIndex });
|
||||
} catch (error) {
|
||||
logger.error('Failed to kill tmux window', { sessionName, windowIndex, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a tmux pane
|
||||
*/
|
||||
async killPane(sessionName: string, paneId: string): Promise<void> {
|
||||
// Validate paneId format (should be session:window.pane)
|
||||
if (!paneId || typeof paneId !== 'string') {
|
||||
throw new Error('Pane ID must be a non-empty string');
|
||||
}
|
||||
|
||||
// Basic validation for pane ID format
|
||||
if (!/^[a-zA-Z0-9._:-]+$/.test(paneId)) {
|
||||
throw new Error('Invalid pane ID format');
|
||||
}
|
||||
|
||||
try {
|
||||
await execFileAsync('tmux', ['kill-pane', '-t', paneId]);
|
||||
logger.info('Killed tmux pane', { sessionName, paneId });
|
||||
} catch (error) {
|
||||
logger.error('Failed to kill tmux pane', { sessionName, paneId, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if inside a tmux session
|
||||
*/
|
||||
isInsideTmux(): boolean {
|
||||
return !!process.env.TMUX;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current tmux session name if inside tmux
|
||||
*/
|
||||
getCurrentSession(): string | null {
|
||||
if (!this.isInsideTmux()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const result = execFileSync('tmux', ['display-message', '-p', '#{session_name}'], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
return result.trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
234
web/src/server/services/zellij-manager.ts
Normal file
234
web/src/server/services/zellij-manager.ts
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { type SessionCreateOptions, TitleMode } from '../../shared/types.js';
|
||||
import type { PtyManager } from '../pty/pty-manager.js';
|
||||
import { createLogger } from '../utils/logger.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const logger = createLogger('ZellijManager');
|
||||
|
||||
export interface ZellijSession {
|
||||
name: string;
|
||||
created: string;
|
||||
exited: boolean;
|
||||
}
|
||||
|
||||
export class ZellijManager {
|
||||
private static instance: ZellijManager;
|
||||
private ptyManager: PtyManager;
|
||||
|
||||
private constructor(ptyManager: PtyManager) {
|
||||
this.ptyManager = ptyManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate session name to prevent command injection
|
||||
*/
|
||||
private validateSessionName(name: string): void {
|
||||
if (!name || typeof name !== 'string') {
|
||||
throw new Error('Session name must be a non-empty string');
|
||||
}
|
||||
// Only allow alphanumeric, dash, underscore, and dot
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
|
||||
throw new Error(
|
||||
'Session name can only contain letters, numbers, dots, dashes, and underscores'
|
||||
);
|
||||
}
|
||||
if (name.length > 100) {
|
||||
throw new Error('Session name too long (max 100 characters)');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip ANSI escape codes from text
|
||||
*/
|
||||
private stripAnsiCodes(text: string): string {
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape codes contain control characters
|
||||
return text.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
}
|
||||
|
||||
static getInstance(ptyManager: PtyManager): ZellijManager {
|
||||
if (!ZellijManager.instance) {
|
||||
ZellijManager.instance = new ZellijManager(ptyManager);
|
||||
}
|
||||
return ZellijManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if zellij is installed and available
|
||||
*/
|
||||
async isAvailable(): Promise<boolean> {
|
||||
try {
|
||||
await execFileAsync('which', ['zellij']);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all zellij sessions
|
||||
*/
|
||||
async listSessions(): Promise<ZellijSession[]> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('zellij', ['list-sessions']);
|
||||
|
||||
if (stdout.includes('No active zellij sessions found')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Parse zellij session output
|
||||
// Format: SESSION NAME [EXITED] (CREATED)
|
||||
const sessions: ZellijSession[] = [];
|
||||
const lines = stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => line.trim());
|
||||
|
||||
for (const line of lines) {
|
||||
// Strip ANSI codes first
|
||||
const cleanLine = this.stripAnsiCodes(line).trim();
|
||||
|
||||
if (!cleanLine) continue;
|
||||
|
||||
// Parse session info
|
||||
// Format: "session-name [Created 15s ago]" or "session-name [EXITED] [Created 1h ago]"
|
||||
const exited = cleanLine.includes('[EXITED]');
|
||||
|
||||
// Extract session name (everything before the first [)
|
||||
const nameMatch = cleanLine.match(/^([^[]+)/);
|
||||
if (!nameMatch) continue;
|
||||
|
||||
const name = nameMatch[1].trim();
|
||||
|
||||
// Extract created time if available
|
||||
const createdMatch = cleanLine.match(/\[Created ([^\]]+)\]/);
|
||||
const created = createdMatch ? createdMatch[1] : 'unknown';
|
||||
|
||||
if (name) {
|
||||
sessions.push({
|
||||
name,
|
||||
created,
|
||||
exited,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return sessions;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('No active zellij sessions found')) {
|
||||
return [];
|
||||
}
|
||||
logger.error('Failed to list zellij sessions', { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tabs for a session (requires being attached to query)
|
||||
* Note: Zellij doesn't provide a way to query tabs without being attached
|
||||
*/
|
||||
async getSessionTabs(sessionName: string): Promise<string[]> {
|
||||
// This would need to be run inside the session
|
||||
// For now, return empty as we can't query from outside
|
||||
logger.warn('Cannot query tabs for zellij session from outside', { sessionName });
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new zellij session
|
||||
* Note: Zellij requires a terminal, so we create sessions through attachToZellij instead
|
||||
*/
|
||||
async createSession(name: string, layout?: string): Promise<void> {
|
||||
// Zellij can't create detached sessions like tmux
|
||||
// Sessions are created when attaching to them
|
||||
logger.info('Zellij session will be created on first attach', { name, layout });
|
||||
|
||||
// Store the layout preference if provided
|
||||
if (layout) {
|
||||
// We could store this in a temporary map or config file
|
||||
// For now, we'll just log it
|
||||
logger.info('Layout preference noted for session', { name, layout });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach to a zellij session through VibeTunnel
|
||||
*/
|
||||
async attachToZellij(
|
||||
sessionName: string,
|
||||
options?: Partial<SessionCreateOptions> & { layout?: string }
|
||||
): Promise<string> {
|
||||
// Zellij attach command with -c flag to create if doesn't exist
|
||||
const zellijCommand = ['zellij', 'attach', '-c', sessionName];
|
||||
|
||||
// Add layout if provided and session doesn't exist yet
|
||||
if (options?.layout) {
|
||||
const sessions = await this.listSessions();
|
||||
const sessionExists = sessions.some((s) => s.name === sessionName && !s.exited);
|
||||
if (!sessionExists) {
|
||||
zellijCommand.push('-l', options.layout);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new VibeTunnel session that runs zellij attach
|
||||
const sessionOptions: SessionCreateOptions = {
|
||||
name: `zellij: ${sessionName}`,
|
||||
workingDir: options?.workingDir || process.env.HOME || '/',
|
||||
cols: options?.cols || 80,
|
||||
rows: options?.rows || 24,
|
||||
titleMode: options?.titleMode || TitleMode.DYNAMIC,
|
||||
};
|
||||
|
||||
const session = await this.ptyManager.createSession(zellijCommand, sessionOptions);
|
||||
return session.sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a zellij session
|
||||
*/
|
||||
async killSession(sessionName: string): Promise<void> {
|
||||
this.validateSessionName(sessionName);
|
||||
|
||||
try {
|
||||
// Use delete-session with --force flag to handle both running and exited sessions
|
||||
await execFileAsync('zellij', ['delete-session', '--force', sessionName]);
|
||||
logger.info('Killed zellij session', { sessionName });
|
||||
} catch (error) {
|
||||
logger.error('Failed to kill zellij session', { sessionName, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a zellij session
|
||||
*/
|
||||
async deleteSession(sessionName: string): Promise<void> {
|
||||
this.validateSessionName(sessionName);
|
||||
|
||||
try {
|
||||
await execFileAsync('zellij', ['delete-session', sessionName]);
|
||||
logger.info('Deleted zellij session', { sessionName });
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete zellij session', { sessionName, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if inside a zellij session
|
||||
*/
|
||||
isInsideZellij(): boolean {
|
||||
return !!process.env.ZELLIJ;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current zellij session name if inside zellij
|
||||
*/
|
||||
getCurrentSession(): string | null {
|
||||
if (!this.isInsideZellij()) {
|
||||
return null;
|
||||
}
|
||||
return process.env.ZELLIJ_SESSION_NAME || null;
|
||||
}
|
||||
}
|
||||
40
web/src/shared/multiplexer-types.ts
Normal file
40
web/src/shared/multiplexer-types.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
// Re-export tmux types for compatibility
|
||||
export type { TmuxPane, TmuxSession, TmuxTarget, TmuxWindow } from './tmux-types.js';
|
||||
|
||||
export type MultiplexerType = 'tmux' | 'zellij' | 'screen';
|
||||
|
||||
export interface MultiplexerSession {
|
||||
name: string;
|
||||
type: MultiplexerType;
|
||||
windows?: number; // tmux specific
|
||||
created?: string;
|
||||
attached?: boolean; // tmux specific
|
||||
exited?: boolean; // zellij specific
|
||||
activity?: string; // tmux specific
|
||||
current?: boolean; // tmux specific
|
||||
}
|
||||
|
||||
export interface MultiplexerStatus {
|
||||
tmux: {
|
||||
available: boolean;
|
||||
type: MultiplexerType;
|
||||
sessions: MultiplexerSession[];
|
||||
};
|
||||
zellij: {
|
||||
available: boolean;
|
||||
type: MultiplexerType;
|
||||
sessions: MultiplexerSession[];
|
||||
};
|
||||
screen: {
|
||||
available: boolean;
|
||||
type: MultiplexerType;
|
||||
sessions: MultiplexerSession[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface MultiplexerTarget {
|
||||
type: MultiplexerType;
|
||||
session: string;
|
||||
window?: number; // tmux specific
|
||||
pane?: number; // tmux specific
|
||||
}
|
||||
35
web/src/shared/tmux-types.ts
Normal file
35
web/src/shared/tmux-types.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
export interface TmuxSession {
|
||||
name: string;
|
||||
windows: number;
|
||||
created: string;
|
||||
attached: boolean;
|
||||
activity?: string;
|
||||
current?: boolean;
|
||||
}
|
||||
|
||||
export interface TmuxWindow {
|
||||
session: string;
|
||||
index: number;
|
||||
name: string;
|
||||
active: boolean;
|
||||
panes: number;
|
||||
}
|
||||
|
||||
export interface TmuxPane {
|
||||
session: string;
|
||||
window: number;
|
||||
index: number;
|
||||
active: boolean;
|
||||
title?: string;
|
||||
pid?: number;
|
||||
command?: string;
|
||||
width: number;
|
||||
height: number;
|
||||
currentPath?: string;
|
||||
}
|
||||
|
||||
export interface TmuxTarget {
|
||||
session: string;
|
||||
window?: number;
|
||||
pane?: number;
|
||||
}
|
||||
562
web/src/test/e2e/multiplexer-api.e2e.test.ts
Normal file
562
web/src/test/e2e/multiplexer-api.e2e.test.ts
Normal file
|
|
@ -0,0 +1,562 @@
|
|||
import type { Express } from 'express';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { PtyManager } from '../../server/pty/pty-manager.js';
|
||||
|
||||
// Mock logger to reduce noise
|
||||
vi.mock('../../server/utils/logger.js', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
log: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
interface MockMultiplexerManager {
|
||||
getAvailableMultiplexers: ReturnType<typeof vi.fn>;
|
||||
getTmuxWindows: ReturnType<typeof vi.fn>;
|
||||
getTmuxPanes: ReturnType<typeof vi.fn>;
|
||||
createSession: ReturnType<typeof vi.fn>;
|
||||
attachToSession: ReturnType<typeof vi.fn>;
|
||||
killSession: ReturnType<typeof vi.fn>;
|
||||
getCurrentMultiplexer: ReturnType<typeof vi.fn>;
|
||||
killTmuxWindow: ReturnType<typeof vi.fn>;
|
||||
killTmuxPane: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
describe('Multiplexer API Tests', () => {
|
||||
let app: Express;
|
||||
let mockMultiplexerManager: MockMultiplexerManager;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Initialize PtyManager
|
||||
await PtyManager.initialize();
|
||||
|
||||
// Create Express app
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// Create mock multiplexer manager
|
||||
mockMultiplexerManager = {
|
||||
getAvailableMultiplexers: vi.fn(),
|
||||
getTmuxWindows: vi.fn(),
|
||||
getTmuxPanes: vi.fn(),
|
||||
createSession: vi.fn(),
|
||||
attachToSession: vi.fn(),
|
||||
killSession: vi.fn(),
|
||||
getCurrentMultiplexer: vi.fn(),
|
||||
killTmuxWindow: vi.fn(),
|
||||
killTmuxPane: vi.fn(),
|
||||
};
|
||||
|
||||
// Create a mock PtyManager
|
||||
const _mockPtyManager = {} as PtyManager;
|
||||
|
||||
// Import and create routes with our mock
|
||||
const { Router } = await import('express');
|
||||
const router = Router();
|
||||
|
||||
// Manually implement the routes instead of using createMultiplexerRoutes
|
||||
// This gives us full control over the mocking
|
||||
|
||||
router.get('/status', async (_req, res) => {
|
||||
try {
|
||||
const status = await mockMultiplexerManager.getAvailableMultiplexers();
|
||||
res.json(status);
|
||||
} catch (_error) {
|
||||
res.status(500).json({ error: 'Failed to get multiplexer status' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/tmux/sessions/:sessionName/windows', async (req, res) => {
|
||||
try {
|
||||
const { sessionName } = req.params;
|
||||
const windows = await mockMultiplexerManager.getTmuxWindows(sessionName);
|
||||
res.json({ windows });
|
||||
} catch (_error) {
|
||||
res.status(500).json({ error: 'Failed to list tmux windows' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/tmux/sessions/:sessionName/panes', async (req, res) => {
|
||||
try {
|
||||
const { sessionName } = req.params;
|
||||
const windowIndex = req.query.window
|
||||
? Number.parseInt(req.query.window as string, 10)
|
||||
: undefined;
|
||||
const panes = await mockMultiplexerManager.getTmuxPanes(sessionName, windowIndex);
|
||||
res.json({ panes });
|
||||
} catch (_error) {
|
||||
res.status(500).json({ error: 'Failed to list tmux panes' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/sessions', async (req, res) => {
|
||||
try {
|
||||
const { type, name, options } = req.body;
|
||||
if (!type || !name) {
|
||||
return res.status(400).json({ error: 'Type and name are required' });
|
||||
}
|
||||
await mockMultiplexerManager.createSession(type, name, options);
|
||||
res.json({ success: true, type, name });
|
||||
} catch (_error) {
|
||||
res.status(500).json({ error: 'Failed to create session' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/attach', async (req, res) => {
|
||||
try {
|
||||
const { type, sessionName, windowIndex, paneIndex, cols, rows, workingDir, titleMode } =
|
||||
req.body;
|
||||
if (!type || !sessionName) {
|
||||
return res.status(400).json({ error: 'Type and session name are required' });
|
||||
}
|
||||
|
||||
const options = {
|
||||
cols,
|
||||
rows,
|
||||
workingDir,
|
||||
titleMode,
|
||||
windowIndex,
|
||||
paneIndex,
|
||||
};
|
||||
|
||||
const sessionId = await mockMultiplexerManager.attachToSession(type, sessionName, options);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
sessionId,
|
||||
target: {
|
||||
type,
|
||||
session: sessionName,
|
||||
window: windowIndex,
|
||||
pane: paneIndex,
|
||||
},
|
||||
});
|
||||
} catch (_error) {
|
||||
res.status(500).json({ error: 'Failed to attach to session' });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:type/sessions/:sessionName', async (req, res) => {
|
||||
try {
|
||||
const { type, sessionName } = req.params;
|
||||
await mockMultiplexerManager.killSession(type, sessionName);
|
||||
res.json({ success: true });
|
||||
} catch (_error) {
|
||||
res.status(500).json({ error: 'Failed to kill session' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/context', (_req, res) => {
|
||||
const context = mockMultiplexerManager.getCurrentMultiplexer();
|
||||
res.json({ context });
|
||||
});
|
||||
|
||||
router.delete('/tmux/sessions/:sessionName/windows/:windowIndex', async (req, res) => {
|
||||
try {
|
||||
const { sessionName, windowIndex } = req.params;
|
||||
await mockMultiplexerManager.killTmuxWindow(sessionName, Number.parseInt(windowIndex, 10));
|
||||
res.json({ success: true });
|
||||
} catch (_error) {
|
||||
res.status(500).json({ error: 'Failed to kill window' });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/tmux/sessions/:sessionName/panes/:paneId', async (req, res) => {
|
||||
try {
|
||||
const { sessionName, paneId } = req.params;
|
||||
await mockMultiplexerManager.killTmuxPane(sessionName, paneId);
|
||||
res.json({ success: true });
|
||||
} catch (_error) {
|
||||
res.status(500).json({ error: 'Failed to kill pane' });
|
||||
}
|
||||
});
|
||||
|
||||
// Mount multiplexer routes
|
||||
app.use('/api/multiplexer', router);
|
||||
|
||||
// Add legacy tmux routes
|
||||
app.get('/api/tmux/sessions', async (_req, res) => {
|
||||
try {
|
||||
const status = await mockMultiplexerManager.getAvailableMultiplexers();
|
||||
res.json({
|
||||
available: status.tmux.available,
|
||||
sessions: status.tmux.sessions,
|
||||
});
|
||||
} catch (_error) {
|
||||
res.status(500).json({ error: 'Failed to get tmux status' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/tmux/attach', async (req, res) => {
|
||||
try {
|
||||
const { sessionName, windowIndex, paneIndex, cols, rows } = req.body;
|
||||
if (!sessionName) {
|
||||
return res.status(400).json({ error: 'sessionName is required' });
|
||||
}
|
||||
const sessionId = await mockMultiplexerManager.attachToSession('tmux', sessionName, {
|
||||
windowIndex,
|
||||
paneIndex,
|
||||
cols,
|
||||
rows,
|
||||
});
|
||||
res.json({ success: true, sessionId });
|
||||
} catch (_error) {
|
||||
res.status(500).json({ error: 'Failed to attach to tmux session' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Cleanup
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /api/multiplexer/status', () => {
|
||||
it('should return multiplexer status', async () => {
|
||||
const mockStatus = {
|
||||
tmux: {
|
||||
available: true,
|
||||
type: 'tmux',
|
||||
sessions: [
|
||||
{ name: 'main', windows: 2, type: 'tmux' },
|
||||
{ name: 'dev', windows: 1, type: 'tmux' },
|
||||
],
|
||||
},
|
||||
zellij: {
|
||||
available: false,
|
||||
type: 'zellij',
|
||||
sessions: [],
|
||||
},
|
||||
screen: {
|
||||
available: false,
|
||||
type: 'screen',
|
||||
sessions: [],
|
||||
},
|
||||
};
|
||||
|
||||
mockMultiplexerManager.getAvailableMultiplexers.mockResolvedValue(mockStatus);
|
||||
|
||||
const response = await request(app).get('/api/multiplexer/status').expect(200);
|
||||
|
||||
expect(response.body).toEqual(mockStatus);
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
mockMultiplexerManager.getAvailableMultiplexers.mockRejectedValue(
|
||||
new Error('Failed to get status')
|
||||
);
|
||||
|
||||
const response = await request(app).get('/api/multiplexer/status').expect(500);
|
||||
|
||||
expect(response.body).toEqual({
|
||||
error: 'Failed to get multiplexer status',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/multiplexer/tmux/sessions/:session/windows', () => {
|
||||
it('should return windows for tmux session', async () => {
|
||||
const mockWindows = [
|
||||
{ index: 0, name: 'vim', panes: 1, active: true },
|
||||
{ index: 1, name: 'shell', panes: 2, active: false },
|
||||
];
|
||||
|
||||
mockMultiplexerManager.getTmuxWindows.mockResolvedValue(mockWindows);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/multiplexer/tmux/sessions/main/windows')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toEqual({ windows: mockWindows });
|
||||
expect(mockMultiplexerManager.getTmuxWindows).toHaveBeenCalledWith('main');
|
||||
});
|
||||
|
||||
it('should handle session name with special characters', async () => {
|
||||
mockMultiplexerManager.getTmuxWindows.mockResolvedValue([]);
|
||||
|
||||
await request(app).get('/api/multiplexer/tmux/sessions/my-session-123/windows').expect(200);
|
||||
|
||||
expect(mockMultiplexerManager.getTmuxWindows).toHaveBeenCalledWith('my-session-123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/multiplexer/tmux/sessions/:session/panes', () => {
|
||||
it('should return all panes for session', async () => {
|
||||
const mockPanes = [
|
||||
{ sessionName: 'main', windowIndex: 0, paneIndex: 0, active: true },
|
||||
{ sessionName: 'main', windowIndex: 0, paneIndex: 1, active: false },
|
||||
{ sessionName: 'main', windowIndex: 1, paneIndex: 0, active: false },
|
||||
];
|
||||
|
||||
mockMultiplexerManager.getTmuxPanes.mockResolvedValue(mockPanes);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/multiplexer/tmux/sessions/main/panes')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toEqual({ panes: mockPanes });
|
||||
expect(mockMultiplexerManager.getTmuxPanes).toHaveBeenCalledWith('main', undefined);
|
||||
});
|
||||
|
||||
it('should return panes for specific window', async () => {
|
||||
const mockPanes = [{ sessionName: 'main', windowIndex: 1, paneIndex: 0, active: true }];
|
||||
|
||||
mockMultiplexerManager.getTmuxPanes.mockResolvedValue(mockPanes);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/multiplexer/tmux/sessions/main/panes?window=1')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toEqual({ panes: mockPanes });
|
||||
expect(mockMultiplexerManager.getTmuxPanes).toHaveBeenCalledWith('main', 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/multiplexer/sessions', () => {
|
||||
it('should create tmux session', async () => {
|
||||
mockMultiplexerManager.createSession.mockResolvedValue(undefined);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/multiplexer/sessions')
|
||||
.send({
|
||||
type: 'tmux',
|
||||
name: 'new-session',
|
||||
options: {
|
||||
command: ['vim'],
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toEqual({ success: true, type: 'tmux', name: 'new-session' });
|
||||
expect(mockMultiplexerManager.createSession).toHaveBeenCalledWith('tmux', 'new-session', {
|
||||
command: ['vim'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should create zellij session', async () => {
|
||||
mockMultiplexerManager.createSession.mockResolvedValue(undefined);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/multiplexer/sessions')
|
||||
.send({
|
||||
type: 'zellij',
|
||||
name: 'new-session',
|
||||
options: {
|
||||
layout: 'compact',
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toEqual({ success: true, type: 'zellij', name: 'new-session' });
|
||||
expect(mockMultiplexerManager.createSession).toHaveBeenCalledWith('zellij', 'new-session', {
|
||||
layout: 'compact',
|
||||
});
|
||||
});
|
||||
|
||||
it('should require type and name', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/multiplexer/sessions')
|
||||
.send({ type: 'tmux' })
|
||||
.expect(400);
|
||||
|
||||
expect(response.body).toEqual({
|
||||
error: 'Type and name are required',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/multiplexer/attach', () => {
|
||||
it('should attach to tmux session', async () => {
|
||||
mockMultiplexerManager.attachToSession.mockResolvedValue('vt-123');
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/multiplexer/attach')
|
||||
.send({
|
||||
type: 'tmux',
|
||||
sessionName: 'main',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toEqual({
|
||||
success: true,
|
||||
sessionId: 'vt-123',
|
||||
target: {
|
||||
type: 'tmux',
|
||||
session: 'main',
|
||||
window: undefined,
|
||||
pane: undefined,
|
||||
},
|
||||
});
|
||||
expect(mockMultiplexerManager.attachToSession).toHaveBeenCalledWith('tmux', 'main', {
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
workingDir: undefined,
|
||||
titleMode: undefined,
|
||||
windowIndex: undefined,
|
||||
paneIndex: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should attach to tmux window and pane', async () => {
|
||||
mockMultiplexerManager.attachToSession.mockResolvedValue('vt-456');
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/multiplexer/attach')
|
||||
.send({
|
||||
type: 'tmux',
|
||||
sessionName: 'main',
|
||||
windowIndex: 1,
|
||||
paneIndex: 2,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toEqual({
|
||||
success: true,
|
||||
sessionId: 'vt-456',
|
||||
target: {
|
||||
type: 'tmux',
|
||||
session: 'main',
|
||||
window: 1,
|
||||
pane: 2,
|
||||
},
|
||||
});
|
||||
expect(mockMultiplexerManager.attachToSession).toHaveBeenCalledWith('tmux', 'main', {
|
||||
cols: undefined,
|
||||
rows: undefined,
|
||||
workingDir: undefined,
|
||||
titleMode: undefined,
|
||||
windowIndex: 1,
|
||||
paneIndex: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('should attach to zellij session', async () => {
|
||||
mockMultiplexerManager.attachToSession.mockResolvedValue('vt-789');
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/multiplexer/attach')
|
||||
.send({
|
||||
type: 'zellij',
|
||||
sessionName: 'dev',
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toEqual({
|
||||
success: true,
|
||||
sessionId: 'vt-789',
|
||||
target: {
|
||||
type: 'zellij',
|
||||
session: 'dev',
|
||||
window: undefined,
|
||||
pane: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should require type and sessionName', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/multiplexer/attach')
|
||||
.send({ type: 'tmux' })
|
||||
.expect(400);
|
||||
|
||||
expect(response.body).toEqual({
|
||||
error: 'Type and session name are required',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/multiplexer/sessions/:type/:sessionName', () => {
|
||||
it('should kill tmux session', async () => {
|
||||
mockMultiplexerManager.killSession.mockResolvedValue(true);
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/api/multiplexer/tmux/sessions/old-session')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toEqual({ success: true });
|
||||
expect(mockMultiplexerManager.killSession).toHaveBeenCalledWith('tmux', 'old-session');
|
||||
});
|
||||
|
||||
it('should kill zellij session', async () => {
|
||||
mockMultiplexerManager.killSession.mockResolvedValue(true);
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/api/multiplexer/zellij/sessions/old-session')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toEqual({ success: true });
|
||||
expect(mockMultiplexerManager.killSession).toHaveBeenCalledWith('zellij', 'old-session');
|
||||
});
|
||||
|
||||
it('should handle errors', async () => {
|
||||
mockMultiplexerManager.killSession.mockRejectedValue(new Error('Session not found'));
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/api/multiplexer/tmux/sessions/nonexistent')
|
||||
.expect(500);
|
||||
|
||||
expect(response.body).toEqual({
|
||||
error: 'Failed to kill session',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Legacy tmux routes', () => {
|
||||
it('should support legacy GET /api/tmux/sessions', async () => {
|
||||
const mockStatus = {
|
||||
tmux: {
|
||||
available: true,
|
||||
type: 'tmux',
|
||||
sessions: [{ name: 'main', windows: 2, type: 'tmux' }],
|
||||
},
|
||||
zellij: { available: false, type: 'zellij', sessions: [] },
|
||||
screen: { available: false, type: 'screen', sessions: [] },
|
||||
};
|
||||
|
||||
mockMultiplexerManager.getAvailableMultiplexers.mockResolvedValue(mockStatus);
|
||||
|
||||
const response = await request(app).get('/api/tmux/sessions').expect(200);
|
||||
|
||||
expect(response.body).toEqual({
|
||||
available: true,
|
||||
sessions: [{ name: 'main', windows: 2, type: 'tmux' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should support legacy POST /api/tmux/attach', async () => {
|
||||
mockMultiplexerManager.attachToSession.mockResolvedValue('vt-legacy');
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/tmux/attach')
|
||||
.send({
|
||||
sessionName: 'main',
|
||||
windowIndex: 0,
|
||||
paneIndex: 1,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toEqual({
|
||||
success: true,
|
||||
sessionId: 'vt-legacy',
|
||||
});
|
||||
expect(mockMultiplexerManager.attachToSession).toHaveBeenCalledWith('tmux', 'main', {
|
||||
windowIndex: 0,
|
||||
paneIndex: 1,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
341
web/src/test/unit/multiplexer-manager.test.ts
Normal file
341
web/src/test/unit/multiplexer-manager.test.ts
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { PtyManager } from '../../server/pty/pty-manager.js';
|
||||
import { MultiplexerManager } from '../../server/services/multiplexer-manager.js';
|
||||
import { ScreenManager } from '../../server/services/screen-manager.js';
|
||||
import { TmuxManager } from '../../server/services/tmux-manager.js';
|
||||
import { ZellijManager } from '../../server/services/zellij-manager.js';
|
||||
import { TitleMode } from '../../shared/types.js';
|
||||
|
||||
// Mock the managers
|
||||
vi.mock('../../server/services/tmux-manager.js');
|
||||
vi.mock('../../server/services/zellij-manager.js');
|
||||
vi.mock('../../server/services/screen-manager.js');
|
||||
|
||||
// Mock PtyManager
|
||||
const mockPtyManager = {
|
||||
createSession: vi.fn(),
|
||||
} as unknown as PtyManager;
|
||||
|
||||
describe('MultiplexerManager', () => {
|
||||
let multiplexerManager: MultiplexerManager;
|
||||
let mockTmuxManager: any;
|
||||
let mockZellijManager: any;
|
||||
let mockScreenManager: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Reset singleton instance
|
||||
(MultiplexerManager as any).instance = undefined;
|
||||
|
||||
// Setup mock instances
|
||||
mockTmuxManager = {
|
||||
isAvailable: vi.fn(),
|
||||
listSessions: vi.fn(),
|
||||
listWindows: vi.fn(),
|
||||
listPanes: vi.fn(),
|
||||
createSession: vi.fn(),
|
||||
attachToTmux: vi.fn(),
|
||||
killSession: vi.fn(),
|
||||
isInsideTmux: vi.fn(),
|
||||
getCurrentSession: vi.fn(),
|
||||
};
|
||||
|
||||
mockZellijManager = {
|
||||
isAvailable: vi.fn(),
|
||||
listSessions: vi.fn(),
|
||||
createSession: vi.fn(),
|
||||
attachToZellij: vi.fn(),
|
||||
killSession: vi.fn(),
|
||||
isInsideZellij: vi.fn(),
|
||||
getCurrentSession: vi.fn(),
|
||||
};
|
||||
|
||||
mockScreenManager = {
|
||||
isAvailable: vi.fn(),
|
||||
listSessions: vi.fn(),
|
||||
createSession: vi.fn(),
|
||||
attachToSession: vi.fn(),
|
||||
killSession: vi.fn(),
|
||||
isInsideScreen: vi.fn(),
|
||||
getCurrentSession: vi.fn(),
|
||||
};
|
||||
|
||||
// Mock getInstance methods
|
||||
vi.mocked(TmuxManager.getInstance).mockReturnValue(mockTmuxManager);
|
||||
vi.mocked(ZellijManager.getInstance).mockReturnValue(mockZellijManager);
|
||||
vi.mocked(ScreenManager.getInstance).mockReturnValue(mockScreenManager);
|
||||
|
||||
multiplexerManager = MultiplexerManager.getInstance(mockPtyManager);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('getAvailableMultiplexers', () => {
|
||||
it('should return status for all multiplexers', async () => {
|
||||
mockTmuxManager.isAvailable.mockResolvedValue(true);
|
||||
mockZellijManager.isAvailable.mockResolvedValue(false);
|
||||
mockScreenManager.isAvailable.mockResolvedValue(false);
|
||||
mockTmuxManager.listSessions.mockResolvedValue([
|
||||
{ name: 'main', windows: 2 },
|
||||
{ name: 'dev', windows: 1 },
|
||||
]);
|
||||
|
||||
const result = await multiplexerManager.getAvailableMultiplexers();
|
||||
|
||||
expect(result).toEqual({
|
||||
tmux: {
|
||||
available: true,
|
||||
type: 'tmux',
|
||||
sessions: [
|
||||
{ name: 'main', windows: 2, type: 'tmux' },
|
||||
{ name: 'dev', windows: 1, type: 'tmux' },
|
||||
],
|
||||
},
|
||||
zellij: {
|
||||
available: false,
|
||||
type: 'zellij',
|
||||
sessions: [],
|
||||
},
|
||||
screen: {
|
||||
available: false,
|
||||
type: 'screen',
|
||||
sessions: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors when listing sessions', async () => {
|
||||
mockTmuxManager.isAvailable.mockResolvedValue(true);
|
||||
mockZellijManager.isAvailable.mockResolvedValue(true);
|
||||
mockScreenManager.isAvailable.mockResolvedValue(false);
|
||||
mockTmuxManager.listSessions.mockRejectedValue(new Error('tmux error'));
|
||||
mockZellijManager.listSessions.mockResolvedValue([
|
||||
{ name: 'main', created: '1h ago', exited: false },
|
||||
]);
|
||||
|
||||
const result = await multiplexerManager.getAvailableMultiplexers();
|
||||
|
||||
expect(result.tmux.sessions).toEqual([]);
|
||||
expect(result.zellij.sessions).toEqual([
|
||||
{ name: 'main', created: '1h ago', exited: false, type: 'zellij' },
|
||||
]);
|
||||
expect(result.screen.sessions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTmuxWindows', () => {
|
||||
it('should return windows for tmux session', async () => {
|
||||
const mockWindows = [
|
||||
{ index: 0, name: 'vim', panes: 1, active: true },
|
||||
{ index: 1, name: 'shell', panes: 2, active: false },
|
||||
];
|
||||
mockTmuxManager.listWindows.mockResolvedValue(mockWindows);
|
||||
|
||||
const windows = await multiplexerManager.getTmuxWindows('main');
|
||||
|
||||
expect(windows).toEqual(mockWindows);
|
||||
expect(mockTmuxManager.listWindows).toHaveBeenCalledWith('main');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTmuxPanes', () => {
|
||||
it('should return panes for tmux session', async () => {
|
||||
const mockPanes = [
|
||||
{ sessionName: 'main', windowIndex: 0, paneIndex: 0, active: true },
|
||||
{ sessionName: 'main', windowIndex: 0, paneIndex: 1, active: false },
|
||||
];
|
||||
mockTmuxManager.listPanes.mockResolvedValue(mockPanes);
|
||||
|
||||
const panes = await multiplexerManager.getTmuxPanes('main');
|
||||
|
||||
expect(panes).toEqual(mockPanes);
|
||||
expect(mockTmuxManager.listPanes).toHaveBeenCalledWith('main', undefined);
|
||||
});
|
||||
|
||||
it('should return panes for specific window', async () => {
|
||||
const mockPanes = [{ sessionName: 'main', windowIndex: 1, paneIndex: 0, active: true }];
|
||||
mockTmuxManager.listPanes.mockResolvedValue(mockPanes);
|
||||
|
||||
const panes = await multiplexerManager.getTmuxPanes('main', 1);
|
||||
|
||||
expect(panes).toEqual(mockPanes);
|
||||
expect(mockTmuxManager.listPanes).toHaveBeenCalledWith('main', 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSession', () => {
|
||||
it('should create tmux session', async () => {
|
||||
await multiplexerManager.createSession('tmux', 'new-session', { command: 'vim' });
|
||||
|
||||
expect(mockTmuxManager.createSession).toHaveBeenCalledWith('new-session', 'vim');
|
||||
expect(mockZellijManager.createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create zellij session', async () => {
|
||||
await multiplexerManager.createSession('zellij', 'new-session', { layout: 'compact' });
|
||||
|
||||
expect(mockZellijManager.createSession).toHaveBeenCalledWith('new-session', 'compact');
|
||||
expect(mockTmuxManager.createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create screen session', async () => {
|
||||
await multiplexerManager.createSession('screen', 'new-session');
|
||||
|
||||
expect(mockScreenManager.createSession).toHaveBeenCalledWith('new-session', undefined);
|
||||
expect(mockTmuxManager.createSession).not.toHaveBeenCalled();
|
||||
expect(mockZellijManager.createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw error for unknown multiplexer type', async () => {
|
||||
await expect(
|
||||
multiplexerManager.createSession('unknown' as any, 'new-session')
|
||||
).rejects.toThrow('Unknown multiplexer type: unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('attachToSession', () => {
|
||||
it('should attach to tmux session', async () => {
|
||||
mockTmuxManager.attachToTmux.mockResolvedValue('vt-123');
|
||||
|
||||
const sessionId = await multiplexerManager.attachToSession('tmux', 'main');
|
||||
|
||||
expect(sessionId).toBe('vt-123');
|
||||
expect(mockTmuxManager.attachToTmux).toHaveBeenCalledWith(
|
||||
'main',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('should attach to tmux window and pane', async () => {
|
||||
mockTmuxManager.attachToTmux.mockResolvedValue('vt-456');
|
||||
|
||||
const sessionId = await multiplexerManager.attachToSession('tmux', 'main', {
|
||||
windowIndex: 1,
|
||||
paneIndex: 2,
|
||||
});
|
||||
|
||||
expect(sessionId).toBe('vt-456');
|
||||
expect(mockTmuxManager.attachToTmux).toHaveBeenCalledWith('main', 1, 2, {
|
||||
windowIndex: 1,
|
||||
paneIndex: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('should attach to zellij session', async () => {
|
||||
mockZellijManager.attachToZellij.mockResolvedValue('vt-789');
|
||||
|
||||
const sessionId = await multiplexerManager.attachToSession('zellij', 'main');
|
||||
|
||||
expect(sessionId).toBe('vt-789');
|
||||
expect(mockZellijManager.attachToZellij).toHaveBeenCalledWith('main', undefined);
|
||||
});
|
||||
|
||||
it('should attach to screen session', async () => {
|
||||
mockScreenManager.attachToSession.mockResolvedValue(['screen', '-r', 'main']);
|
||||
mockPtyManager.createSession.mockResolvedValue({ sessionId: 'vt-999' });
|
||||
|
||||
const sessionId = await multiplexerManager.attachToSession('screen', 'main');
|
||||
|
||||
expect(sessionId).toBe('vt-999');
|
||||
expect(mockScreenManager.attachToSession).toHaveBeenCalledWith('main');
|
||||
expect(mockPtyManager.createSession).toHaveBeenCalledWith(
|
||||
['screen', '-r', 'main'],
|
||||
expect.objectContaining({ titleMode: TitleMode.DYNAMIC })
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for unknown multiplexer type', async () => {
|
||||
await expect(multiplexerManager.attachToSession('unknown' as any, 'main')).rejects.toThrow(
|
||||
'Unknown multiplexer type: unknown'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('killSession', () => {
|
||||
it('should kill tmux session', async () => {
|
||||
await multiplexerManager.killSession('tmux', 'old-session');
|
||||
|
||||
expect(mockTmuxManager.killSession).toHaveBeenCalledWith('old-session');
|
||||
expect(mockZellijManager.killSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should kill zellij session', async () => {
|
||||
await multiplexerManager.killSession('zellij', 'old-session');
|
||||
|
||||
expect(mockZellijManager.killSession).toHaveBeenCalledWith('old-session');
|
||||
expect(mockTmuxManager.killSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should kill screen session', async () => {
|
||||
await multiplexerManager.killSession('screen', 'old-session');
|
||||
|
||||
expect(mockScreenManager.killSession).toHaveBeenCalledWith('old-session');
|
||||
expect(mockTmuxManager.killSession).not.toHaveBeenCalled();
|
||||
expect(mockZellijManager.killSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw error for unknown multiplexer type', async () => {
|
||||
await expect(multiplexerManager.killSession('unknown' as any, 'old-session')).rejects.toThrow(
|
||||
'Unknown multiplexer type: unknown'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCurrentMultiplexer', () => {
|
||||
it('should return tmux when inside tmux', () => {
|
||||
mockTmuxManager.isInsideTmux.mockReturnValue(true);
|
||||
mockTmuxManager.getCurrentSession.mockReturnValue('main');
|
||||
mockZellijManager.isInsideZellij.mockReturnValue(false);
|
||||
|
||||
const result = multiplexerManager.getCurrentMultiplexer();
|
||||
|
||||
expect(result).toEqual({ type: 'tmux', session: 'main' });
|
||||
});
|
||||
|
||||
it('should return zellij when inside zellij', () => {
|
||||
mockTmuxManager.isInsideTmux.mockReturnValue(false);
|
||||
mockZellijManager.isInsideZellij.mockReturnValue(true);
|
||||
mockZellijManager.getCurrentSession.mockReturnValue('dev');
|
||||
mockScreenManager.isInsideScreen.mockReturnValue(false);
|
||||
|
||||
const result = multiplexerManager.getCurrentMultiplexer();
|
||||
|
||||
expect(result).toEqual({ type: 'zellij', session: 'dev' });
|
||||
});
|
||||
|
||||
it('should return screen when inside screen', () => {
|
||||
mockTmuxManager.isInsideTmux.mockReturnValue(false);
|
||||
mockZellijManager.isInsideZellij.mockReturnValue(false);
|
||||
mockScreenManager.isInsideScreen.mockReturnValue(true);
|
||||
mockScreenManager.getCurrentSession.mockReturnValue('myscreen');
|
||||
|
||||
const result = multiplexerManager.getCurrentMultiplexer();
|
||||
|
||||
expect(result).toEqual({ type: 'screen', session: 'myscreen' });
|
||||
});
|
||||
|
||||
it('should return null when not inside any multiplexer', () => {
|
||||
mockTmuxManager.isInsideTmux.mockReturnValue(false);
|
||||
mockZellijManager.isInsideZellij.mockReturnValue(false);
|
||||
mockScreenManager.isInsideScreen.mockReturnValue(false);
|
||||
|
||||
const result = multiplexerManager.getCurrentMultiplexer();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when inside tmux but no session', () => {
|
||||
mockTmuxManager.isInsideTmux.mockReturnValue(true);
|
||||
mockTmuxManager.getCurrentSession.mockReturnValue(null);
|
||||
|
||||
const result = multiplexerManager.getCurrentMultiplexer();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
315
web/src/test/unit/tmux-manager.test.ts
Normal file
315
web/src/test/unit/tmux-manager.test.ts
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { PtyManager } from '../../server/pty/pty-manager.js';
|
||||
|
||||
// Hoist mock declarations
|
||||
const { mockExecFileAsync, mockExecFileSync } = vi.hoisted(() => {
|
||||
return {
|
||||
mockExecFileAsync: vi.fn(),
|
||||
mockExecFileSync: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock child_process
|
||||
vi.mock('child_process', () => ({
|
||||
execFile: vi.fn(),
|
||||
execFileSync: mockExecFileSync,
|
||||
}));
|
||||
|
||||
// Mock util.promisify to return our mock
|
||||
vi.mock('util', () => ({
|
||||
promisify: vi.fn(() => mockExecFileAsync),
|
||||
}));
|
||||
|
||||
// Import after mocks are set up
|
||||
import { TmuxManager } from '../../server/services/tmux-manager.js';
|
||||
|
||||
// Mock PtyManager
|
||||
const mockPtyManager = {
|
||||
createSession: vi.fn(),
|
||||
} as unknown as PtyManager;
|
||||
|
||||
describe('TmuxManager', () => {
|
||||
let tmuxManager: TmuxManager;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset singleton instance
|
||||
(TmuxManager as any).instance = undefined;
|
||||
tmuxManager = TmuxManager.getInstance(mockPtyManager);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('isAvailable', () => {
|
||||
it('should return true when tmux is installed', async () => {
|
||||
mockExecFileAsync.mockResolvedValue({ stdout: '/usr/local/bin/tmux', stderr: '' });
|
||||
|
||||
const result = await tmuxManager.isAvailable();
|
||||
expect(result).toBe(true);
|
||||
expect(mockExecFileAsync).toHaveBeenCalledWith('which', ['tmux']);
|
||||
});
|
||||
|
||||
it('should return false when tmux is not installed', async () => {
|
||||
mockExecFileAsync.mockRejectedValue(new Error('tmux not found'));
|
||||
|
||||
const result = await tmuxManager.isAvailable();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listSessions', () => {
|
||||
it('should parse tmux sessions correctly', async () => {
|
||||
const mockOutput = `main|1|Thu Jul 25 10:00:00 2024|attached||
|
||||
dev|2|Thu Jul 25 11:00:00 2024|detached||
|
||||
test|1|Thu Jul 25 12:00:00 2024|detached||`;
|
||||
|
||||
mockExecFileAsync.mockResolvedValue({ stdout: mockOutput, stderr: '' });
|
||||
|
||||
const sessions = await tmuxManager.listSessions();
|
||||
|
||||
expect(sessions).toHaveLength(3);
|
||||
expect(sessions[0]).toEqual({
|
||||
name: 'main',
|
||||
windows: 1,
|
||||
created: 'Thu Jul 25 10:00:00 2024',
|
||||
attached: true,
|
||||
activity: '',
|
||||
current: false,
|
||||
});
|
||||
expect(sessions[1]).toEqual({
|
||||
name: 'dev',
|
||||
windows: 2,
|
||||
created: 'Thu Jul 25 11:00:00 2024',
|
||||
attached: false,
|
||||
activity: '',
|
||||
current: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle shell output pollution', async () => {
|
||||
const mockOutput = `stty: stdin isn't a terminal
|
||||
main|1|Thu Jul 25 10:00:00 2024|attached||
|
||||
/Users/test/.profile: line 10: command not found
|
||||
dev|2|Thu Jul 25 11:00:00 2024|detached||`;
|
||||
|
||||
mockExecFileAsync.mockResolvedValue({ stdout: mockOutput, stderr: '' });
|
||||
|
||||
const sessions = await tmuxManager.listSessions();
|
||||
|
||||
expect(sessions).toHaveLength(2);
|
||||
expect(sessions[0].name).toBe('main');
|
||||
expect(sessions[1].name).toBe('dev');
|
||||
});
|
||||
|
||||
it('should return empty array when no sessions exist', async () => {
|
||||
const error = new Error('no server running on /tmp/tmux-501/default');
|
||||
mockExecFileAsync.mockRejectedValue(error);
|
||||
|
||||
const sessions = await tmuxManager.listSessions();
|
||||
expect(sessions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listWindows', () => {
|
||||
it('should parse tmux windows correctly', async () => {
|
||||
const mockOutput = `main|0|vim|active|1
|
||||
main|1|shell||1
|
||||
main|2|logs||2`;
|
||||
|
||||
mockExecFileAsync.mockResolvedValue({ stdout: mockOutput, stderr: '' });
|
||||
|
||||
const windows = await tmuxManager.listWindows('main');
|
||||
|
||||
expect(windows).toHaveLength(3);
|
||||
expect(windows[0]).toEqual({
|
||||
session: 'main',
|
||||
index: 0,
|
||||
name: 'vim',
|
||||
active: true,
|
||||
panes: 1,
|
||||
});
|
||||
expect(windows[2]).toEqual({
|
||||
session: 'main',
|
||||
index: 2,
|
||||
name: 'logs',
|
||||
active: false,
|
||||
panes: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('listPanes', () => {
|
||||
it('should parse tmux panes correctly', async () => {
|
||||
const mockOutput = `main|0|0|active|vim|1234|vim|80|24|/Users/test/project
|
||||
main|0|1||zsh|5678|npm|80|24|/Users/test/project
|
||||
main|1|0|active|zsh|9012|ls|80|24|/Users/test`;
|
||||
|
||||
mockExecFileAsync.mockResolvedValue({ stdout: mockOutput, stderr: '' });
|
||||
|
||||
const panes = await tmuxManager.listPanes('main');
|
||||
|
||||
expect(panes).toHaveLength(3);
|
||||
expect(panes[0]).toEqual({
|
||||
session: 'main',
|
||||
window: 0,
|
||||
index: 0,
|
||||
active: true,
|
||||
title: 'vim',
|
||||
pid: 1234,
|
||||
command: 'vim',
|
||||
width: 80,
|
||||
height: 24,
|
||||
currentPath: '/Users/test/project',
|
||||
});
|
||||
expect(panes[1]).toEqual({
|
||||
session: 'main',
|
||||
window: 0,
|
||||
index: 1,
|
||||
active: false,
|
||||
title: 'zsh',
|
||||
pid: 5678,
|
||||
command: 'npm',
|
||||
width: 80,
|
||||
height: 24,
|
||||
currentPath: '/Users/test/project',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle panes for specific window', async () => {
|
||||
const mockOutput = `main|1|0|active|zsh|1234|ls|80|24|/Users/test
|
||||
main|1|1||vim|5678|vim|80|24|/Users/test/docs`;
|
||||
|
||||
mockExecFileAsync.mockResolvedValue({ stdout: mockOutput, stderr: '' });
|
||||
|
||||
const panes = await tmuxManager.listPanes('main', 1);
|
||||
|
||||
expect(panes).toHaveLength(2);
|
||||
expect(panes[0].window).toBe(1);
|
||||
expect(panes[1].window).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSession', () => {
|
||||
it('should create a new tmux session', async () => {
|
||||
mockExecFileAsync.mockResolvedValue({ stdout: '', stderr: '' });
|
||||
|
||||
await tmuxManager.createSession('new-session');
|
||||
|
||||
expect(mockExecFileAsync).toHaveBeenCalledWith('tmux', [
|
||||
'new-session',
|
||||
'-d',
|
||||
'-s',
|
||||
'new-session',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should create a session with initial command', async () => {
|
||||
mockExecFileAsync.mockResolvedValue({ stdout: '', stderr: '' });
|
||||
|
||||
await tmuxManager.createSession('dev-session', ['npm', 'run', 'dev']);
|
||||
|
||||
expect(mockExecFileAsync).toHaveBeenCalledWith('tmux', [
|
||||
'new-session',
|
||||
'-d',
|
||||
'-s',
|
||||
'dev-session',
|
||||
'npm',
|
||||
'run',
|
||||
'dev',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('killSession', () => {
|
||||
it('should kill a tmux session', async () => {
|
||||
mockExecFileAsync.mockResolvedValue({ stdout: '', stderr: '' });
|
||||
|
||||
await tmuxManager.killSession('old-session');
|
||||
|
||||
expect(mockExecFileAsync).toHaveBeenCalledWith('tmux', ['kill-session', '-t', 'old-session']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('attachToTmux', () => {
|
||||
it('should create a PTY session for tmux attach', async () => {
|
||||
const mockSession = { sessionId: 'vt-123' };
|
||||
mockPtyManager.createSession.mockResolvedValue(mockSession);
|
||||
|
||||
const sessionId = await tmuxManager.attachToTmux('main');
|
||||
|
||||
expect(sessionId).toBe('vt-123');
|
||||
expect(mockPtyManager.createSession).toHaveBeenCalledWith(
|
||||
['tmux', 'attach-session', '-t', 'main'],
|
||||
expect.objectContaining({
|
||||
name: 'tmux: main',
|
||||
workingDir: expect.any(String),
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should attach to specific window', async () => {
|
||||
const mockSession = { sessionId: 'vt-456' };
|
||||
mockPtyManager.createSession.mockResolvedValue(mockSession);
|
||||
|
||||
const sessionId = await tmuxManager.attachToTmux('main', 2);
|
||||
|
||||
expect(sessionId).toBe('vt-456');
|
||||
expect(mockPtyManager.createSession).toHaveBeenCalledWith(
|
||||
['tmux', 'attach-session', '-t', 'main:2'],
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should attach to specific pane', async () => {
|
||||
const mockSession = { sessionId: 'vt-789' };
|
||||
mockPtyManager.createSession.mockResolvedValue(mockSession);
|
||||
|
||||
const sessionId = await tmuxManager.attachToTmux('main', 1, 2);
|
||||
|
||||
expect(sessionId).toBe('vt-789');
|
||||
expect(mockPtyManager.createSession).toHaveBeenCalledWith(
|
||||
['tmux', 'attach-session', '-t', 'main:1'],
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInsideTmux', () => {
|
||||
it('should return true when inside tmux', () => {
|
||||
process.env.TMUX = '/tmp/tmux-1000/default,12345,0';
|
||||
expect(tmuxManager.isInsideTmux()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when not inside tmux', () => {
|
||||
delete process.env.TMUX;
|
||||
expect(tmuxManager.isInsideTmux()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCurrentSession', () => {
|
||||
it('should return current session name when inside tmux', () => {
|
||||
process.env.TMUX = '/tmp/tmux-1000/default,12345,0';
|
||||
process.env.TMUX_PANE = '%0';
|
||||
|
||||
mockExecFileSync.mockReturnValue('main\n');
|
||||
|
||||
const session = tmuxManager.getCurrentSession();
|
||||
expect(session).toBe('main');
|
||||
expect(mockExecFileSync).toHaveBeenCalledWith(
|
||||
'tmux',
|
||||
['display-message', '-p', '#{session_name}'],
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should return null when not inside tmux', () => {
|
||||
delete process.env.TMUX;
|
||||
const session = tmuxManager.getCurrentSession();
|
||||
expect(session).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
287
web/src/test/unit/zellij-manager.test.ts
Normal file
287
web/src/test/unit/zellij-manager.test.ts
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { PtyManager } from '../../server/pty/pty-manager.js';
|
||||
|
||||
// Hoist mock declarations
|
||||
const { mockExecFileAsync, mockLogger } = vi.hoisted(() => {
|
||||
const mockLogger = {
|
||||
log: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
};
|
||||
return {
|
||||
mockExecFileAsync: vi.fn(),
|
||||
mockLogger,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock child_process
|
||||
vi.mock('child_process', () => ({
|
||||
execFile: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock util.promisify to return our mock
|
||||
vi.mock('util', () => ({
|
||||
promisify: vi.fn(() => mockExecFileAsync),
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock('../../server/utils/logger.js', () => ({
|
||||
createLogger: () => mockLogger,
|
||||
}));
|
||||
|
||||
// Import after mocks are set up
|
||||
import { ZellijManager } from '../../server/services/zellij-manager.js';
|
||||
|
||||
// Mock PtyManager
|
||||
const mockPtyManager = {
|
||||
createSession: vi.fn(),
|
||||
} as unknown as PtyManager;
|
||||
|
||||
describe('ZellijManager', () => {
|
||||
let zellijManager: ZellijManager;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset singleton instance
|
||||
(ZellijManager as any).instance = undefined;
|
||||
zellijManager = ZellijManager.getInstance(mockPtyManager);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('isAvailable', () => {
|
||||
it('should return true when zellij is installed', async () => {
|
||||
mockExecFileAsync.mockResolvedValue({ stdout: '/usr/local/bin/zellij', stderr: '' });
|
||||
|
||||
const result = await zellijManager.isAvailable();
|
||||
expect(result).toBe(true);
|
||||
expect(mockExecFileAsync).toHaveBeenCalledWith('which', ['zellij']);
|
||||
});
|
||||
|
||||
it('should return false when zellij is not installed', async () => {
|
||||
mockExecFileAsync.mockRejectedValue(new Error('zellij not found'));
|
||||
|
||||
const result = await zellijManager.isAvailable();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listSessions', () => {
|
||||
it('should parse active zellij sessions correctly', async () => {
|
||||
const mockOutput = `\x1b[32;1mmain [Created 2h ago]\x1b[0m
|
||||
\x1b[32;1mdev-session [Created 30m ago]\x1b[0m
|
||||
\x1b[31;1mold-session [EXITED] [Created 1d ago]\x1b[0m`;
|
||||
|
||||
mockExecFileAsync.mockResolvedValue({ stdout: mockOutput, stderr: '' });
|
||||
|
||||
const sessions = await zellijManager.listSessions();
|
||||
|
||||
expect(sessions).toHaveLength(3);
|
||||
expect(sessions[0]).toEqual({
|
||||
name: 'main',
|
||||
created: '2h ago',
|
||||
exited: false,
|
||||
});
|
||||
expect(sessions[1]).toEqual({
|
||||
name: 'dev-session',
|
||||
created: '30m ago',
|
||||
exited: false,
|
||||
});
|
||||
expect(sessions[2]).toEqual({
|
||||
name: 'old-session',
|
||||
created: '1d ago',
|
||||
exited: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should strip ANSI codes from session names', async () => {
|
||||
const mockOutput = `\x1b[32;1mcolor-session [Created 15s ago]\x1b[0m`;
|
||||
|
||||
mockExecFileAsync.mockResolvedValue({ stdout: mockOutput, stderr: '' });
|
||||
|
||||
const sessions = await zellijManager.listSessions();
|
||||
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0].name).toBe('color-session');
|
||||
expect(sessions[0].name).not.toContain('\x1b');
|
||||
});
|
||||
|
||||
it('should return empty array when no sessions exist', async () => {
|
||||
mockExecFileAsync.mockResolvedValue({
|
||||
stdout: 'No active zellij sessions found',
|
||||
stderr: '',
|
||||
});
|
||||
|
||||
const sessions = await zellijManager.listSessions();
|
||||
expect(sessions).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle error with "No active zellij sessions" message', async () => {
|
||||
mockExecFileAsync.mockRejectedValue(new Error('No active zellij sessions found'));
|
||||
|
||||
const sessions = await zellijManager.listSessions();
|
||||
expect(sessions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSessionTabs', () => {
|
||||
it('should return empty array and log warning', async () => {
|
||||
const tabs = await zellijManager.getSessionTabs('main');
|
||||
|
||||
expect(tabs).toEqual([]);
|
||||
expect(mockLogger.warn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSession', () => {
|
||||
it('should log that session will be created on attach', async () => {
|
||||
await zellijManager.createSession('new-session');
|
||||
|
||||
expect(mockExecFileAsync).not.toHaveBeenCalled();
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Zellij session will be created on first attach'),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should log layout preference if provided', async () => {
|
||||
await zellijManager.createSession('new-session', 'compact');
|
||||
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Layout preference noted'),
|
||||
expect.objectContaining({ name: 'new-session', layout: 'compact' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('attachToZellij', () => {
|
||||
it('should create a PTY session for zellij attach with -c flag', async () => {
|
||||
const mockSession = { sessionId: 'vt-123' };
|
||||
mockPtyManager.createSession.mockResolvedValue(mockSession);
|
||||
mockExecFileAsync.mockResolvedValue({ stdout: '', stderr: '' }); // No sessions exist
|
||||
|
||||
const sessionId = await zellijManager.attachToZellij('main');
|
||||
|
||||
expect(sessionId).toBe('vt-123');
|
||||
expect(mockPtyManager.createSession).toHaveBeenCalledWith(
|
||||
['zellij', 'attach', '-c', 'main'],
|
||||
expect.objectContaining({
|
||||
name: 'zellij: main',
|
||||
workingDir: expect.any(String),
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should add layout for new session', async () => {
|
||||
const mockSession = { sessionId: 'vt-456' };
|
||||
mockPtyManager.createSession.mockResolvedValue(mockSession);
|
||||
mockExecFileAsync.mockResolvedValue({ stdout: '', stderr: '' }); // No sessions exist
|
||||
|
||||
const sessionId = await zellijManager.attachToZellij('dev', { layout: 'compact' });
|
||||
|
||||
expect(sessionId).toBe('vt-456');
|
||||
expect(mockPtyManager.createSession).toHaveBeenCalledWith(
|
||||
['zellij', 'attach', '-c', 'dev', '-l', 'compact'],
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should not add layout for existing session', async () => {
|
||||
const mockSession = { sessionId: 'vt-789' };
|
||||
mockPtyManager.createSession.mockResolvedValue(mockSession);
|
||||
mockExecFileAsync.mockImplementation((cmd, args) => {
|
||||
if (cmd === 'zellij' && args[0] === 'list-sessions') {
|
||||
return Promise.resolve({ stdout: 'dev [Created 10m ago]', stderr: '' });
|
||||
} else {
|
||||
return Promise.resolve({ stdout: '', stderr: '' });
|
||||
}
|
||||
});
|
||||
|
||||
const sessionId = await zellijManager.attachToZellij('dev', { layout: 'compact' });
|
||||
|
||||
expect(sessionId).toBe('vt-789');
|
||||
expect(mockPtyManager.createSession).toHaveBeenCalledWith(
|
||||
['zellij', 'attach', '-c', 'dev'], // No layout flag
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('killSession', () => {
|
||||
it('should kill a zellij session', async () => {
|
||||
mockExecFileAsync.mockResolvedValue({ stdout: '', stderr: '' });
|
||||
|
||||
await zellijManager.killSession('old-session');
|
||||
|
||||
expect(mockExecFileAsync).toHaveBeenCalledWith('zellij', [
|
||||
'delete-session',
|
||||
'--force',
|
||||
'old-session',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteSession', () => {
|
||||
it('should delete a zellij session', async () => {
|
||||
mockExecFileAsync.mockResolvedValue({ stdout: '', stderr: '' });
|
||||
|
||||
await zellijManager.deleteSession('old-session');
|
||||
|
||||
expect(mockExecFileAsync).toHaveBeenCalledWith('zellij', ['delete-session', 'old-session']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInsideZellij', () => {
|
||||
it('should return true when inside zellij', () => {
|
||||
process.env.ZELLIJ = '1';
|
||||
expect(zellijManager.isInsideZellij()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when not inside zellij', () => {
|
||||
delete process.env.ZELLIJ;
|
||||
expect(zellijManager.isInsideZellij()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCurrentSession', () => {
|
||||
it('should return current session name when inside zellij', () => {
|
||||
process.env.ZELLIJ = '1';
|
||||
process.env.ZELLIJ_SESSION_NAME = 'main';
|
||||
|
||||
const session = zellijManager.getCurrentSession();
|
||||
expect(session).toBe('main');
|
||||
});
|
||||
|
||||
it('should return null when not inside zellij', () => {
|
||||
delete process.env.ZELLIJ;
|
||||
delete process.env.ZELLIJ_SESSION_NAME;
|
||||
|
||||
const session = zellijManager.getCurrentSession();
|
||||
expect(session).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when inside zellij but no session name', () => {
|
||||
process.env.ZELLIJ = '1';
|
||||
delete process.env.ZELLIJ_SESSION_NAME;
|
||||
|
||||
const session = zellijManager.getCurrentSession();
|
||||
expect(session).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripAnsiCodes', () => {
|
||||
it('should strip ANSI escape codes', () => {
|
||||
const input = '\x1b[32;1mGreen Bold Text\x1b[0m Normal \x1b[31mRed\x1b[0m';
|
||||
const result = (zellijManager as any).stripAnsiCodes(input);
|
||||
|
||||
expect(result).toBe('Green Bold Text Normal Red');
|
||||
expect(result).not.toContain('\x1b');
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue