Add tmux integration to VibeTunnel (#460)

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Helmut Januschka 2025-07-30 02:25:54 +02:00 committed by GitHub
parent 40f4a6f413
commit dbba6127df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 4116 additions and 4 deletions

View 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

View file

@ -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>
`;
}
}

View file

@ -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}

View file

@ -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}

View file

@ -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'));

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

View file

@ -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"

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

View file

@ -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;

View file

@ -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

View file

@ -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

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

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

View file

@ -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(

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

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

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

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

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

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

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

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

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

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