mcpretentious
Version:
MCPretentious - Universal Terminal MCP. High-performance terminal automation for iTerm2 (WebSocket) and tmux (control mode). Cross-platform support with cursor position, colors, and layered screenshots.
330 lines (269 loc) • 10.3 kB
JavaScript
/**
* TMux backend implementation for MCPretentious
* Provides cross-platform terminal control via tmux control mode
*/
import { TerminalBackend } from './terminal-backend.js';
import { TmuxClientSimple } from './tmux-client-simple.js';
import { convertToLayers } from './ansi-parser.js';
import { ITERM_DEFAULTS } from './constants.js';
import { generateMouseEvent } from './mouse-sgr-protocol.js';
import { execSync } from 'child_process';
export class TmuxBackend extends TerminalBackend {
constructor() {
super();
this.client = null;
}
async init() {
if (!this.client) {
this.client = new TmuxClientSimple();
}
return this.client;
}
async isAvailable() {
try {
// Check if tmux is installed
execSync('which tmux', { stdio: 'ignore' });
// Try to initialize client
await this.init();
return true;
} catch (error) {
return false;
}
}
getName() {
return 'TMux';
}
getType() {
return 'tmux';
}
async createSession(options = {}) {
await this.init();
const columns = options.columns || ITERM_DEFAULTS.COLUMNS;
const rows = options.rows || ITERM_DEFAULTS.ROWS;
// Create new tmux session
const sessionId = await this.client.createSession(columns, rows);
if (!sessionId) {
throw new Error('Failed to create TMux session');
}
// Return terminal ID in new format
return TerminalBackend.generateTerminalId('tmux', sessionId);
}
async closeSession(sessionId) {
await this.init();
// Extract actual session ID if full terminal ID was passed
const parsed = TerminalBackend.parseTerminalId(sessionId);
const actualSessionId = parsed ? parsed.sessionId : sessionId;
return await this.client.closeSession(actualSessionId);
}
async listSessions() {
await this.init();
const sessions = await this.client.listSessions();
// Convert to terminal IDs
return sessions.map(session => ({
terminalId: TerminalBackend.generateTerminalId('tmux', session.uniqueIdentifier),
sessionId: session.uniqueIdentifier,
backend: this.getName(),
attached: session.attached,
windowCount: session.windowCount
}));
}
async sendText(sessionId, text) {
await this.init();
// Extract actual session ID if full terminal ID was passed
const parsed = TerminalBackend.parseTerminalId(sessionId);
const actualSessionId = parsed ? parsed.sessionId : sessionId;
return await this.client.sendText(actualSessionId, text);
}
async getScreenContents(sessionId, includeStyles = false) {
await this.init();
// Extract actual session ID if full terminal ID was passed
const parsed = TerminalBackend.parseTerminalId(sessionId);
const actualSessionId = parsed ? parsed.sessionId : sessionId;
const screenData = await this.client.getScreenContents(actualSessionId, includeStyles);
// Ensure format matches iTerm2's structure
if (!screenData.lines) {
// Convert simple text format to lines array
const text = screenData.text || '';
screenData.lines = text.split('\n').map(line => ({ text: line }));
}
return screenData;
}
async getSessionInfo(sessionId) {
await this.init();
// Extract actual session ID if full terminal ID was passed
const parsed = TerminalBackend.parseTerminalId(sessionId);
const actualSessionId = parsed ? parsed.sessionId : sessionId;
return await this.client.getSessionInfo(actualSessionId);
}
async setSessionSize(sessionId, columns, rows) {
await this.init();
// Extract actual session ID if full terminal ID was passed
const parsed = TerminalBackend.parseTerminalId(sessionId);
const actualSessionId = parsed ? parsed.sessionId : sessionId;
return await this.client.setSessionSize(actualSessionId, columns, rows);
}
async getProperty(sessionId, property) {
await this.init();
// Extract actual session ID if full terminal ID was passed
const parsed = TerminalBackend.parseTerminalId(sessionId);
const actualSessionId = parsed ? parsed.sessionId : sessionId;
return await this.client.getProperty(actualSessionId, property);
}
async close() {
if (this.client) {
this.client.close();
this.client = null;
}
}
isValidSessionId(sessionId) {
// Extract actual session ID if full terminal ID was passed
const parsed = TerminalBackend.parseTerminalId(sessionId);
const actualSessionId = parsed ? parsed.sessionId : sessionId;
// TMux session names: alphanumeric, dash, underscore
// Our format: mcp-{8-char-uuid}
const sessionRegex = /^[a-zA-Z0-9_-]+$/;
return sessionRegex.test(actualSessionId);
}
/**
* Check if a session exists
* @param {string} sessionId - Session to check
* @returns {Promise<boolean>}
*/
async sessionExists(sessionId) {
await this.init();
// Extract actual session ID if full terminal ID was passed
const parsed = TerminalBackend.parseTerminalId(sessionId);
const actualSessionId = parsed ? parsed.sessionId : sessionId;
const sessions = await this.client.listSessions();
return sessions.some(s => s.uniqueIdentifier === actualSessionId);
}
/**
* Send mouse event using SGR protocol
* @param {string} sessionId - Target session
* @param {string} event - Event type ('press', 'release', 'drag')
* @param {number} x - X coordinate (0-based)
* @param {number} y - Y coordinate (0-based)
* @param {string|number} button - Button name or code
* @param {Object} modifiers - Modifier keys ({shift: bool, alt: bool, ctrl: bool})
* @returns {Promise<boolean>} Success status
*/
async sendMouseEvent(sessionId, event, x, y, button, modifiers = {}) {
await this.init();
// Extract actual session ID if full terminal ID was passed
const parsed = TerminalBackend.parseTerminalId(sessionId);
const actualSessionId = parsed ? parsed.sessionId : sessionId;
// Generate SGR escape sequence using the shared protocol function
const sequence = generateMouseEvent(event, x, y, button, modifiers);
// Send the sequence to the terminal
return await this.client.sendText(actualSessionId, sequence);
}
/**
* Get screenshot in layered format (matching iTerm2 API)
* @param {string} sessionId - Target session
* @param {Object} options - Screenshot options
* @returns {Promise<Object>} Layered screenshot data
*/
async getScreenshot(sessionId, options = {}) {
await this.init();
// Extract actual session ID if full terminal ID was passed
const parsed = TerminalBackend.parseTerminalId(sessionId);
const actualSessionId = parsed ? parsed.sessionId : sessionId;
// Get screen contents with styles if needed
const needsStyles = options.layers?.some(l =>
['fgColors', 'bgColors', 'styles', 'bold', 'italic', 'underline'].includes(l)
);
const screen = await this.client.getScreenContents(actualSessionId, needsStyles);
// Get terminal dimensions
const info = await this.client.getSessionInfo(actualSessionId);
const terminal = {
width: info.dimensions.columns,
height: info.dimensions.rows
};
// Calculate viewport
let viewport = {
mode: 'full',
left: 0,
top: 0,
width: terminal.width,
height: terminal.height
};
// Apply viewport options
if (options.region) {
viewport = {
mode: 'region',
left: options.region.left || 0,
top: options.region.top || 0,
width: options.region.width,
height: options.region.height
};
} else if (options.aroundCursor !== undefined) {
const n = options.aroundCursor;
const cursorY = screen.cursor?.y || 0;
viewport = {
mode: 'aroundCursor',
left: 0,
top: Math.max(0, cursorY - n),
width: terminal.width,
height: Math.min(terminal.height, n * 2 + 1)
};
}
// Convert to layered format
const layers = convertToLayers(screen, options.layers || ['text', 'cursor']);
// Apply viewport slicing
if (viewport.mode !== 'full') {
const endRow = Math.min(layers.text.length, viewport.top + viewport.height);
// Slice text and other array-based layers
const arrayLayers = ['text', 'fgColors', 'bgColors', 'styles', 'bold', 'italic', 'underline'];
for (const layer of arrayLayers) {
if (layers[layer]) {
layers[layer] = layers[layer]
.slice(viewport.top, endRow)
.map(line => {
if (typeof line === 'string' && viewport.left > 0) {
return line.substring(viewport.left, viewport.left + viewport.width);
}
return line;
});
}
}
}
// Build response
const response = {
terminal,
viewport,
...layers
};
// Adjust cursor position
if (layers.cursor) {
response.cursor = {
left: layers.cursor.x || 0,
top: layers.cursor.y || 0,
relLeft: (layers.cursor.x || 0) - viewport.left,
relTop: (layers.cursor.y || 0) - viewport.top
};
// Mark cursor as outside viewport if necessary
if (response.cursor.relLeft < 0 || response.cursor.relLeft >= viewport.width ||
response.cursor.relTop < 0 || response.cursor.relTop >= viewport.height) {
response.cursor.relLeft = -1;
response.cursor.relTop = -1;
}
}
// Apply compact mode if requested
if (options.compact) {
const nonEmptyIndices = [];
response.text.forEach((line, idx) => {
if (line.trim()) {
nonEmptyIndices.push(idx);
}
});
// Filter all array-based layers
const arrayLayers = ['text', 'fgColors', 'bgColors', 'styles', 'bold', 'italic', 'underline'];
for (const layer of arrayLayers) {
if (response[layer]) {
response[layer] = nonEmptyIndices.map(idx => response[layer][idx]);
}
}
}
return response;
}
}