import React, { useState, useEffect, useRef } from 'react' import { BaseBoxShapeUtil, TLBaseShape, HTMLContainer, Geometry2d, Rectangle2d, T, createShapePropsMigrationIds, createShapePropsMigrationSequence } from 'tldraw' import { StandardizedToolWrapper } from '../components/StandardizedToolWrapper' import { usePinnedToView } from '../hooks/usePinnedToView' import { Terminal } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' import '@xterm/xterm/css/xterm.css' export type IMultmuxShape = TLBaseShape< 'Multmux', { w: number h: number sessionId: string sessionName: string token: string serverUrl: string pinnedToView: boolean tags: string[] } > interface SessionResponse { id: string name: string token: string } interface SessionListItem { id: string name: string createdAt: string clientCount: number } // Helper to convert HTTP URL to WebSocket URL function httpToWs(httpUrl: string): string { return httpUrl .replace(/^http:/, 'ws:') .replace(/^https:/, 'wss:') .replace(/\/?$/, '/ws') } // Migration versions for Multmux shape const versions = createShapePropsMigrationIds('Multmux', { AddMissingProps: 1, RemoveWsUrl: 2, UpdateServerPort: 3, }) // Migrations to handle shapes with missing/undefined props export const multmuxShapeMigrations = createShapePropsMigrationSequence({ sequence: [ { id: versions.AddMissingProps, up: (props: any) => { return { w: props.w ?? 800, h: props.h ?? 600, sessionId: props.sessionId ?? '', sessionName: props.sessionName ?? 'New Terminal', token: props.token ?? '', serverUrl: props.serverUrl ?? 'http://localhost:3002', wsUrl: props.wsUrl ?? 'ws://localhost:3002', pinnedToView: props.pinnedToView ?? false, tags: Array.isArray(props.tags) ? props.tags : ['terminal', 'multmux'], } }, down: (props: any) => props, }, { id: versions.RemoveWsUrl, up: (props: any) => { // Remove wsUrl, it's now derived from serverUrl const { wsUrl, ...rest } = props return { ...rest, serverUrl: rest.serverUrl ?? 'http://localhost:3002', } }, down: (props: any) => ({ ...props, wsUrl: httpToWs(props.serverUrl || 'http://localhost:3002'), }), }, { id: versions.UpdateServerPort, up: (props: any) => { // Update old port 3000 to new port 3002 let serverUrl = props.serverUrl ?? 'http://localhost:3002' if (serverUrl === 'http://localhost:3000') { serverUrl = 'http://localhost:3002' } return { ...props, serverUrl, } }, down: (props: any) => props, }, ], }) export class MultmuxShape extends BaseBoxShapeUtil { static override type = 'Multmux' as const static override props = { w: T.number, h: T.number, sessionId: T.string, sessionName: T.string, token: T.string, serverUrl: T.string, pinnedToView: T.boolean, tags: T.arrayOf(T.string), } static override migrations = multmuxShapeMigrations // Terminal theme color: Dark purple/violet static readonly PRIMARY_COLOR = "#8b5cf6" getDefaultProps(): IMultmuxShape['props'] { return { w: 800, h: 600, sessionId: '', sessionName: '', token: '', serverUrl: 'http://localhost:3002', pinnedToView: false, tags: ['terminal', 'multmux'], } } getGeometry(shape: IMultmuxShape): Geometry2d { // Ensure minimum dimensions for proper hit testing return new Rectangle2d({ width: Math.max(shape.props.w, 1), height: Math.max(shape.props.h, 1), isFilled: true, }) } component(shape: IMultmuxShape) { const isSelected = this.editor.getSelectedShapeIds().includes(shape.id) const [isMinimized, setIsMinimized] = useState(false) const [ws, setWs] = useState(null) const [connected, setConnected] = useState(false) const [showAdvanced, setShowAdvanced] = useState(false) const [sessions, setSessions] = useState([]) const [loadingSessions, setLoadingSessions] = useState(false) const [sessionName, setSessionName] = useState('') const terminalRef = useRef(null) const xtermRef = useRef(null) const fitAddonRef = useRef(null) // Use the pinning hook usePinnedToView(this.editor, shape.id, shape.props.pinnedToView) // Runtime fix: correct old serverUrl port (3000 -> 3002) // This handles shapes that may not have been migrated yet useEffect(() => { if (shape.props.serverUrl === 'http://localhost:3000') { this.editor.updateShape({ id: shape.id, type: 'Multmux', props: { ...shape.props, serverUrl: 'http://localhost:3002', }, }) } }, [shape.props.serverUrl]) const handleClose = () => { if (ws) { ws.close() } this.editor.deleteShape(shape.id) } const handleMinimize = () => { setIsMinimized(!isMinimized) } const handlePinToggle = () => { this.editor.updateShape({ id: shape.id, type: shape.type, props: { ...shape.props, pinnedToView: !shape.props.pinnedToView, }, }) } // Fetch available sessions const fetchSessions = async () => { setLoadingSessions(true) try { const response = await fetch(`${shape.props.serverUrl}/api/sessions`) if (response.ok) { const data = await response.json() as { sessions?: SessionListItem[] } setSessions(data.sessions || []) } } catch (error) { console.error('Failed to fetch sessions:', error) } finally { setLoadingSessions(false) } } // Initialize xterm.js terminal useEffect(() => { if (!shape.props.token || !terminalRef.current || xtermRef.current) { return } const term = new Terminal({ cursorBlink: true, fontSize: 14, fontFamily: 'Menlo, Monaco, "Courier New", monospace', theme: { background: '#1e1e2e', foreground: '#cdd6f4', cursor: '#f5e0dc', cursorAccent: '#1e1e2e', black: '#45475a', red: '#f38ba8', green: '#a6e3a1', yellow: '#f9e2af', blue: '#89b4fa', magenta: '#cba6f7', cyan: '#94e2d5', white: '#bac2de', brightBlack: '#585b70', brightRed: '#f38ba8', brightGreen: '#a6e3a1', brightYellow: '#f9e2af', brightBlue: '#89b4fa', brightMagenta: '#cba6f7', brightCyan: '#94e2d5', brightWhite: '#a6adc8', }, }) const fitAddon = new FitAddon() term.loadAddon(fitAddon) term.open(terminalRef.current) // Small delay to ensure container is sized setTimeout(() => { fitAddon.fit() }, 100) xtermRef.current = term fitAddonRef.current = fitAddon return () => { term.dispose() xtermRef.current = null fitAddonRef.current = null } }, [shape.props.token]) // Fit terminal when shape resizes useEffect(() => { if (fitAddonRef.current && xtermRef.current) { setTimeout(() => { fitAddonRef.current?.fit() }, 50) } }, [shape.props.w, shape.props.h, isMinimized]) // WebSocket connection useEffect(() => { if (!shape.props.token || !shape.props.serverUrl) { return } const wsUrl = httpToWs(shape.props.serverUrl) const websocket = new WebSocket(`${wsUrl}?token=${shape.props.token}`) websocket.onopen = () => { setConnected(true) xtermRef.current?.writeln('\r\n\x1b[32m✓ Connected to terminal session\x1b[0m\r\n') } websocket.onmessage = (event) => { try { const message = JSON.parse(event.data) switch (message.type) { case 'output': // Write terminal output directly to xterm xtermRef.current?.write(message.data) break case 'joined': xtermRef.current?.writeln(`\r\n\x1b[32m✓ Joined session: ${message.sessionName}\x1b[0m\r\n`) break case 'presence': if (message.data.action === 'join') { xtermRef.current?.writeln(`\r\n\x1b[33m→ User joined (${message.data.totalClients} total)\x1b[0m`) } else if (message.data.action === 'leave') { xtermRef.current?.writeln(`\r\n\x1b[33m← User left (${message.data.totalClients} total)\x1b[0m`) } break case 'error': xtermRef.current?.writeln(`\r\n\x1b[31m✗ Error: ${message.message}\x1b[0m\r\n`) break // Ignore 'input' messages from other clients (they're just for awareness) } } catch (error) { console.error('Failed to parse WebSocket message:', error) } } websocket.onerror = (error) => { console.error('WebSocket error:', error) xtermRef.current?.writeln('\r\n\x1b[31m✗ Connection error\x1b[0m\r\n') setConnected(false) } websocket.onclose = () => { setConnected(false) xtermRef.current?.writeln('\r\n\x1b[31m✗ Connection closed\x1b[0m\r\n') } setWs(websocket) return () => { websocket.close() } }, [shape.props.token, shape.props.serverUrl]) // Handle terminal input - send keystrokes to server useEffect(() => { if (!xtermRef.current || !ws) return const disposable = xtermRef.current.onData((data) => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'input', data: data, timestamp: Date.now(), })) } }) return () => { disposable.dispose() } }, [ws]) const handleCreateSession = async () => { try { const response = await fetch(`${shape.props.serverUrl}/api/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: sessionName || `Terminal ${new Date().toLocaleTimeString()}`, }), }) if (!response.ok) { throw new Error('Failed to create session') } const session: SessionResponse = await response.json() // CRITICAL: Ensure all props are defined - undefined values cause ValidationError // Explicitly build props object with all required values to prevent undefined from slipping through this.editor.updateShape({ id: shape.id, type: 'Multmux', props: { w: shape.props.w ?? 800, h: shape.props.h ?? 600, sessionId: session.id ?? '', sessionName: session.name ?? '', token: session.token ?? '', serverUrl: shape.props.serverUrl ?? 'http://localhost:3002', pinnedToView: shape.props.pinnedToView ?? false, tags: Array.isArray(shape.props.tags) ? shape.props.tags : ['terminal', 'multmux'], }, }) // Session created - terminal will connect via WebSocket console.log('✓ Created session:', session.name) } catch (error) { console.error('Failed to create session:', error) } } const handleJoinSession = async (sessionId: string) => { try { const response = await fetch(`${shape.props.serverUrl}/api/sessions/${sessionId}/join`, { method: 'POST', }) if (!response.ok) { throw new Error('Failed to join session') } const data = await response.json() as { name?: string; token?: string } // CRITICAL: Ensure all props are defined - undefined values cause ValidationError this.editor.updateShape({ id: shape.id, type: 'Multmux', props: { w: shape.props.w ?? 800, h: shape.props.h ?? 600, sessionId: sessionId ?? '', sessionName: data.name ?? 'Joined Session', token: data.token ?? '', serverUrl: shape.props.serverUrl ?? 'http://localhost:3002', pinnedToView: shape.props.pinnedToView ?? false, tags: Array.isArray(shape.props.tags) ? shape.props.tags : ['terminal', 'multmux'], }, }) } catch (error) { console.error('Failed to join session:', error) } } // If no token, show setup UI if (!shape.props.token) { return ( { this.editor.updateShape({ id: shape.id, type: 'Multmux', props: { ...shape.props, tags: newTags } }) }} tagsEditable={true} >

mulTmux

Collaborative Terminal Sessions

{/* Create Session */}
setSessionName(e.target.value)} placeholder="Session name (optional)" style={{ padding: '12px 16px', backgroundColor: '#313244', border: '1px solid #45475a', borderRadius: '8px', color: '#cdd6f4', fontFamily: 'monospace', fontSize: '14px', }} onPointerDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} />
{/* Divider */}
OR JOIN EXISTING
{/* Join Session */}
{sessions.length === 0 ? (
No active sessions
) : ( sessions.map((session) => ( )) )}
{/* Advanced Settings */}
{showAdvanced && (
)}
) } // Show terminal UI when connected return ( { this.editor.updateShape({ id: shape.id, type: 'Multmux', props: { ...shape.props, tags: newTags } }) }} tagsEditable={true} >
{/* Status bar */}
{connected ? '🟢 Connected' : '🔴 Disconnected'} Session: {shape.props.sessionId.slice(0, 8)}...
{/* xterm.js Terminal */}
{ // Allow pointer events for text selection but stop propagation to prevent tldraw interactions e.stopPropagation() }} onClick={(e) => { e.stopPropagation() // Focus the terminal when clicked xtermRef.current?.focus() }} />
) } indicator(shape: IMultmuxShape) { return } override onDoubleClick = (shape: IMultmuxShape) => { setTimeout(() => { const input = document.querySelector(`[data-shape-id="${shape.id}"] input[type="text"]`) as HTMLInputElement input?.focus() }, 0) } }