UNPKG

interm-mcp

Version:

MCP server for terminal applications and TUI automation with 127 tools

405 lines (404 loc) 16.5 kB
import { TerminalManager } from './terminal-manager.js'; import { createTerminalError, handleError } from './utils/error-utils.js'; export class TerminalNavigationManager { static instance; terminalManager; viewports = new Map(); tabs = new Map(); panes = new Map(); activeTabId = null; zoomLevels = new Map(); fullscreenSessions = new Set(); constructor() { this.terminalManager = TerminalManager.getInstance(); } static getInstance() { if (!TerminalNavigationManager.instance) { TerminalNavigationManager.instance = new TerminalNavigationManager(); } return TerminalNavigationManager.instance; } async dynamicResize(sessionId, cols, rows, maintainAspectRatio = false) { try { const session = this.terminalManager.getSession(sessionId); if (!session) { throw createTerminalError('SESSION_NOT_FOUND', `Session ${sessionId} not found`); } let finalCols = cols; let finalRows = rows; if (maintainAspectRatio) { const currentAspectRatio = session.cols / session.rows; if (cols / rows !== currentAspectRatio) { // Adjust rows to maintain aspect ratio finalRows = Math.round(cols / currentAspectRatio); } } // Ensure minimum dimensions finalCols = Math.max(finalCols, 20); finalRows = Math.max(finalRows, 5); // Ensure maximum dimensions for practical use finalCols = Math.min(finalCols, 300); finalRows = Math.min(finalRows, 100); await this.terminalManager.resizeSession(sessionId, finalCols, finalRows); // Update viewport if exists const viewport = this.viewports.get(sessionId); if (viewport) { viewport.width = finalCols; viewport.height = finalRows; } } catch (error) { throw handleError(error, `Failed to dynamically resize session ${sessionId}`); } } async toggleFullscreen(sessionId, enable) { try { const session = this.terminalManager.getSession(sessionId); if (!session) { throw createTerminalError('SESSION_NOT_FOUND', `Session ${sessionId} not found`); } const isCurrentlyFullscreen = this.fullscreenSessions.has(sessionId); const shouldEnable = enable !== undefined ? enable : !isCurrentlyFullscreen; if (shouldEnable && !isCurrentlyFullscreen) { // Enter fullscreen mode this.fullscreenSessions.add(sessionId); // Send ANSI sequence to request fullscreen await this.terminalManager.sendInput(sessionId, '\u001b[?1049h'); // Maximize terminal dimensions await this.dynamicResize(sessionId, 120, 40); } else if (!shouldEnable && isCurrentlyFullscreen) { // Exit fullscreen mode this.fullscreenSessions.delete(sessionId); // Send ANSI sequence to exit fullscreen await this.terminalManager.sendInput(sessionId, '\u001b[?1049l'); // Restore normal dimensions await this.dynamicResize(sessionId, 80, 24); } return this.fullscreenSessions.has(sessionId); } catch (error) { throw handleError(error, `Failed to toggle fullscreen for session ${sessionId}`); } } async createTab(title, sessionId) { try { let targetSessionId = sessionId; if (!targetSessionId) { // Create new session for the tab const session = await this.terminalManager.createSession(); targetSessionId = session.id; } const tabId = `tab_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; const tab = { id: tabId, title, sessionId: targetSessionId, active: this.tabs.size === 0, // First tab is active panes: [] }; // Add tab to map first so createPane can find it this.tabs.set(tabId, tab); // Create default pane for the tab const paneId = await this.createPane(tabId, targetSessionId, 0, 0, 80, 24); if (tab.active) { this.activeTabId = tabId; } return tabId; } catch (error) { throw handleError(error, `Failed to create tab ${title}`); } } async switchTab(tabId) { try { const tab = this.tabs.get(tabId); if (!tab) { throw createTerminalError('RESOURCE_ERROR', `Tab ${tabId} not found`); } // Deactivate current active tab if (this.activeTabId) { const currentTab = this.tabs.get(this.activeTabId); if (currentTab) { currentTab.active = false; } } // Activate new tab tab.active = true; this.activeTabId = tabId; // Focus the active pane in the tab const activePanes = tab.panes.filter(p => p.active); if (activePanes.length > 0) { await this.focusPane(activePanes[0].id); } } catch (error) { throw handleError(error, `Failed to switch to tab ${tabId}`); } } async createPane(tabId, sessionId, x, y, width, height) { try { const tab = this.tabs.get(tabId); if (!tab) { throw createTerminalError('RESOURCE_ERROR', `Tab ${tabId} not found`); } const session = this.terminalManager.getSession(sessionId); if (!session) { throw createTerminalError('SESSION_NOT_FOUND', `Session ${sessionId} not found`); } const paneId = `pane_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; const pane = { id: paneId, sessionId, x, y, width, height, active: tab.panes.length === 0, // First pane in tab is active title: session.title }; tab.panes.push(pane); this.panes.set(paneId, pane); // Resize the session to fit the pane await this.terminalManager.resizeSession(sessionId, width, height); return paneId; } catch (error) { throw handleError(error, `Failed to create pane in tab ${tabId}`); } } async splitPane(paneId, direction, sessionId) { try { const pane = this.panes.get(paneId); if (!pane) { throw createTerminalError('RESOURCE_ERROR', `Pane ${paneId} not found`); } const tab = Array.from(this.tabs.values()).find(t => t.panes.some(p => p.id === paneId)); if (!tab) { throw createTerminalError('RESOURCE_ERROR', `Tab for pane ${paneId} not found`); } let newSessionId = sessionId; if (!newSessionId) { const newSession = await this.terminalManager.createSession(); newSessionId = newSession.id; } let newX, newY, newWidth, newHeight, adjustedWidth, adjustedHeight; if (direction === 'horizontal') { // Split horizontally (side by side) adjustedWidth = Math.floor(pane.width / 2); newX = pane.x + adjustedWidth; newY = pane.y; newWidth = pane.width - adjustedWidth; newHeight = pane.height; adjustedHeight = pane.height; } else { // Split vertically (top and bottom) adjustedHeight = Math.floor(pane.height / 2); newX = pane.x; newY = pane.y + adjustedHeight; newWidth = pane.width; newHeight = pane.height - adjustedHeight; adjustedWidth = pane.width; } // Adjust original pane size pane.width = adjustedWidth || pane.width; pane.height = adjustedHeight || pane.height; // Resize original session await this.terminalManager.resizeSession(pane.sessionId, pane.width, pane.height); // Create new pane return await this.createPane(tab.id, newSessionId, newX, newY, newWidth, newHeight); } catch (error) { throw handleError(error, `Failed to split pane ${paneId}`); } } async focusPane(paneId) { try { const pane = this.panes.get(paneId); if (!pane) { throw createTerminalError('RESOURCE_ERROR', `Pane ${paneId} not found`); } const tab = Array.from(this.tabs.values()).find(t => t.panes.some(p => p.id === paneId)); if (!tab) { throw createTerminalError('RESOURCE_ERROR', `Tab for pane ${paneId} not found`); } // Deactivate all panes in the tab tab.panes.forEach(p => p.active = false); // Activate the target pane pane.active = true; // Switch to the tab if not already active if (!tab.active) { await this.switchTab(tab.id); } } catch (error) { throw handleError(error, `Failed to focus pane ${paneId}`); } } async setZoomLevel(sessionId, zoomLevel) { try { const session = this.terminalManager.getSession(sessionId); if (!session) { throw createTerminalError('SESSION_NOT_FOUND', `Session ${sessionId} not found`); } // Clamp zoom level between 0.5 and 3.0 const clampedZoom = Math.max(0.5, Math.min(3.0, zoomLevel)); this.zoomLevels.set(sessionId, clampedZoom); // Calculate new dimensions based on zoom const baseWidth = 80; const baseHeight = 24; const newWidth = Math.round(baseWidth / clampedZoom); const newHeight = Math.round(baseHeight / clampedZoom); // Send font size change sequence (if supported by terminal) const fontSizeSequence = `\u001b]50;FontSize=${Math.round(12 * clampedZoom)}\u0007`; await this.terminalManager.sendInput(sessionId, fontSizeSequence); // Adjust terminal dimensions to compensate for zoom await this.dynamicResize(sessionId, newWidth, newHeight); } catch (error) { throw handleError(error, `Failed to set zoom level for session ${sessionId}`); } } async scrollViewport(sessionId, direction, amount = 1) { try { const session = this.terminalManager.getSession(sessionId); if (!session) { throw createTerminalError('SESSION_NOT_FOUND', `Session ${sessionId} not found`); } let scrollSequence = ''; switch (direction) { case 'up': scrollSequence = `\u001b[${amount}S`; // Scroll up break; case 'down': scrollSequence = `\u001b[${amount}T`; // Scroll down break; case 'left': // Horizontal scrolling (if supported) scrollSequence = `\u001b[${amount}D`; break; case 'right': scrollSequence = `\u001b[${amount}C`; break; case 'home': scrollSequence = '\u001b[H'; // Move to home break; case 'end': scrollSequence = '\u001b[F'; // Move to end break; } await this.terminalManager.sendInput(sessionId, scrollSequence); // Update viewport tracking let viewport = this.viewports.get(sessionId); if (!viewport) { viewport = { x: 0, y: 0, width: session.cols, height: session.rows, scrollTop: 0, scrollLeft: 0 }; this.viewports.set(sessionId, viewport); } switch (direction) { case 'up': viewport.scrollTop = Math.max(0, viewport.scrollTop - amount); break; case 'down': viewport.scrollTop += amount; break; case 'left': viewport.scrollLeft = Math.max(0, viewport.scrollLeft - amount); break; case 'right': viewport.scrollLeft += amount; break; case 'home': viewport.scrollTop = 0; viewport.scrollLeft = 0; break; } } catch (error) { throw handleError(error, `Failed to scroll viewport for session ${sessionId}`); } } async setOpacity(sessionId, opacity) { try { const session = this.terminalManager.getSession(sessionId); if (!session) { throw createTerminalError('SESSION_NOT_FOUND', `Session ${sessionId} not found`); } // Clamp opacity between 0.1 and 1.0 const clampedOpacity = Math.max(0.1, Math.min(1.0, opacity)); // This is a simplified implementation - actual opacity control would depend on the terminal emulator // Send a sequence that might be recognized by some terminals const opacitySequence = `\u001b]11;rgba(0,0,0,${clampedOpacity})\u0007`; await this.terminalManager.sendInput(sessionId, opacitySequence); } catch (error) { throw handleError(error, `Failed to set opacity for session ${sessionId}`); } } getActiveTab() { return this.activeTabId ? this.tabs.get(this.activeTabId) || null : null; } getAllTabs() { return Array.from(this.tabs.values()); } getPane(paneId) { return this.panes.get(paneId) || null; } getViewport(sessionId) { return this.viewports.get(sessionId) || null; } getZoomLevel(sessionId) { return this.zoomLevels.get(sessionId) || 1.0; } isFullscreen(sessionId) { return this.fullscreenSessions.has(sessionId); } async closeTab(tabId) { try { const tab = this.tabs.get(tabId); if (!tab) { throw createTerminalError('RESOURCE_ERROR', `Tab ${tabId} not found`); } // Close all panes in the tab for (const pane of tab.panes) { await this.terminalManager.closeSession(pane.sessionId); this.panes.delete(pane.id); } this.tabs.delete(tabId); // If this was the active tab, activate another tab if (this.activeTabId === tabId) { this.activeTabId = null; const remainingTabs = Array.from(this.tabs.values()); if (remainingTabs.length > 0) { await this.switchTab(remainingTabs[0].id); } } } catch (error) { throw handleError(error, `Failed to close tab ${tabId}`); } } async cleanup() { try { // Close all tabs (which closes all panes and sessions) const tabIds = Array.from(this.tabs.keys()); for (const tabId of tabIds) { await this.closeTab(tabId); } this.viewports.clear(); this.zoomLevels.clear(); this.fullscreenSessions.clear(); this.activeTabId = null; } catch (error) { console.error('Error during navigation manager cleanup:', error); } } }