UNPKG

one

Version:

One is a new React Framework that makes Vite serve both native and web.

799 lines (695 loc) 23.9 kB
// minimal terminal UI for daemon - no deps, just ANSI import type { DaemonState, ServerRegistration } from './types' import { getAllServers, setRoute, clearRoute, getLastActiveServer } from './registry' import { getBootedSimulators } from './picker' import { setRouteMode, setPendingMapping, clearMappingsForSimulator, getSimulatorMappings, setSimulatorMapping, } from './server' import colors from 'picocolors' interface Simulator { name: string udid: string iosVersion?: string } type RouteMode = 'most-recent' | 'ask' interface Point { x: number y: number } // cable is purely visual - derived from route state interface Cable { serverIndex: number | null // null = disconnected/dragging controlPoint: Point velocity: Point } interface TUIState { simulators: Simulator[] servers: ServerRegistration[] // per-simulator cables keyed by simulator index cables: Map<number, Cable> // which simulator is currently being dragged (null if none) draggingSimIndex: number | null // remember mode before dragging to restore after modeBeforeDrag: RouteMode | null selectedCol: 0 | 1 selectedRow: number routeMode: RouteMode lastRender: string width: number height: number simEndX: number serverStartX: number rowStartY: number popup: { message: string; timeout: NodeJS.Timeout } | null } const ESC = '\x1b' const CSI = `${ESC}[` const ansi = { hideCursor: `${CSI}?25l`, showCursor: `${CSI}?25h`, clearScreen: `${CSI}2J`, home: `${CSI}H`, } let tuiState: TUIState | null = null let daemonState: DaemonState | null = null let refreshInterval: NodeJS.Timeout | null = null let physicsInterval: NodeJS.Timeout | null = null let stdinListener: ((key: Buffer) => void) | null = null let resizeListener: (() => void) | null = null export function getRouteMode(): RouteMode { return tuiState?.routeMode || 'ask' } function calcLayout(width: number) { const simEndX = Math.floor(width * 0.25) // narrower sim column for more cable room const serverStartX = Math.floor(width * 0.65) return { simEndX, serverStartX } } function showPopup(message: string, durationMs = 2000): void { if (!tuiState) return if (tuiState.popup) { clearTimeout(tuiState.popup.timeout) } const timeout = setTimeout(() => { if (tuiState) { tuiState.popup = null render() } }, durationMs) tuiState.popup = { message, timeout } render() } export function startTUI(state: DaemonState): void { daemonState = state const width = process.stdout.columns || 80 const height = process.stdout.rows || 24 const { simEndX, serverStartX } = calcLayout(width) tuiState = { simulators: [], servers: [], cables: new Map(), draggingSimIndex: null, modeBeforeDrag: null, selectedCol: 0, selectedRow: 0, routeMode: 'most-recent', lastRender: '', width, height, simEndX, serverStartX, rowStartY: 5, popup: null, } process.stdout.write(ansi.clearScreen + ansi.home + ansi.hideCursor) if (process.stdin.isTTY) { process.stdin.setRawMode(true) } process.stdin.resume() let resizePending = false resizeListener = () => { if (!tuiState) return tuiState.width = process.stdout.columns || 80 tuiState.height = process.stdout.rows || 24 const layout = calcLayout(tuiState.width) tuiState.simEndX = layout.simEndX tuiState.serverStartX = layout.serverStartX tuiState.lastRender = '' if (!resizePending) { resizePending = true setImmediate(() => { resizePending = false process.stdout.write(ansi.clearScreen + ansi.home) render() }) } } process.stdout.on('resize', resizeListener) stdinListener = (key: Buffer) => { const str = key.toString() if (str === '\u0003' || str === 'q') { stopTUI() process.exit(0) } if (!tuiState || !daemonState) return if (str === '\u001b[A') { // up tuiState.selectedRow = Math.max(0, tuiState.selectedRow - 1) } else if (str === '\u001b[B') { // down const max = tuiState.selectedCol === 0 ? Math.max(0, tuiState.simulators.length - 1) : Math.max(0, tuiState.servers.length - 1) tuiState.selectedRow = Math.min(max, tuiState.selectedRow + 1) } else if (str === '\u001b[C') { // right if (tuiState.selectedCol === 0) { tuiState.selectedCol = 1 tuiState.selectedRow = Math.min( tuiState.selectedRow, Math.max(0, tuiState.servers.length - 1) ) } } else if (str === '\u001b[D') { // left if (tuiState.selectedCol === 1) { tuiState.selectedCol = 0 tuiState.selectedRow = Math.min( tuiState.selectedRow, Math.max(0, tuiState.simulators.length - 1) ) } } else if (str === ' ' || str === '\r') { handleAction() } else if (str === 'd') { handleDisconnect() } else if (str === 'm') { tuiState.routeMode = tuiState.routeMode === 'most-recent' ? 'ask' : 'most-recent' setRouteMode(tuiState.routeMode) } else if (str === 'b') { stopTUI() console.log(colors.dim('\nDaemon running in background.')) return } render() } process.stdin.on('data', stdinListener) // ensure terminal is restored on signals const signalHandler = () => { stopTUI() process.exit(0) } process.on('SIGINT', signalHandler) process.on('SIGTERM', signalHandler) physicsInterval = setInterval(updatePhysics, 50) refreshInterval = setInterval(refreshData, 1000) refreshData() } function getRouteKey(sim: Simulator): string { return `sim:${sim.udid}` } function handleAction(): void { if (!tuiState || !daemonState) return const isDragging = tuiState.draggingSimIndex !== null if (isDragging) { // connecting cable to a server if (tuiState.selectedCol === 1 && tuiState.servers.length > 0) { const simIndex = tuiState.draggingSimIndex! const sim = tuiState.simulators[simIndex] const serverIndex = tuiState.selectedRow const server = tuiState.servers[serverIndex] if (server && sim) { // set the simulator -> server mapping directly setSimulatorMapping(sim.udid, server.id) // also set pending mapping so if a NEW client identifier comes in, we learn it setPendingMapping(server.id, sim.udid) // set registry route for fallback setRoute(daemonState, getRouteKey(sim), server.id) // update cable const cable = tuiState.cables.get(simIndex) if (cable) { cable.serverIndex = serverIndex } tuiState.draggingSimIndex = null // restore mode if was auto before if (tuiState.modeBeforeDrag === 'most-recent') { tuiState.routeMode = 'most-recent' setRouteMode('most-recent') } tuiState.modeBeforeDrag = null } } return } // start dragging - pick up cable from selected simulator if (tuiState.selectedCol === 0 && tuiState.simulators.length > 0) { const simIndex = tuiState.selectedRow const sim = tuiState.simulators[simIndex] if (!sim) return // remember mode and switch to manual while editing tuiState.modeBeforeDrag = tuiState.routeMode if (tuiState.routeMode === 'most-recent') { tuiState.routeMode = 'ask' setRouteMode('ask') } // clear the route for this simulator clearRoute(daemonState, getRouteKey(sim)) // clear TUI-learned mappings for this simulator only clearMappingsForSimulator(sim.udid) // update cable state let cable = tuiState.cables.get(simIndex) if (!cable) { cable = { serverIndex: null, controlPoint: { x: tuiState.simEndX + 5, y: tuiState.rowStartY + simIndex }, velocity: { x: 0, y: 0 }, } tuiState.cables.set(simIndex, cable) } cable.serverIndex = null cable.velocity = { x: 3, y: -2 } tuiState.draggingSimIndex = simIndex } else if (tuiState.selectedCol === 1 && tuiState.servers.length > 0) { // clicking on server side - find if any cable is connected here and disconnect it const serverIndex = tuiState.selectedRow // find which sim is connected to this server for (const [simIndex, cable] of tuiState.cables) { if (cable.serverIndex === serverIndex) { const sim = tuiState.simulators[simIndex] if (sim) { // remember mode and switch to manual while editing tuiState.modeBeforeDrag = tuiState.routeMode if (tuiState.routeMode === 'most-recent') { tuiState.routeMode = 'ask' setRouteMode('ask') } clearRoute(daemonState, getRouteKey(sim)) cable.serverIndex = null cable.velocity = { x: -3, y: 2 } tuiState.draggingSimIndex = simIndex } break } } } } function handleDisconnect(): void { if (!tuiState || !daemonState) return // disconnect based on current selection if (tuiState.selectedCol === 0) { // disconnect the selected simulator const simIndex = tuiState.selectedRow const sim = tuiState.simulators[simIndex] const cable = tuiState.cables.get(simIndex) if (!sim || !cable || cable.serverIndex === null) return // switch to manual mode if (tuiState.routeMode === 'most-recent') { tuiState.routeMode = 'ask' setRouteMode('ask') showPopup('Switched to manual mode', 1500) } clearRoute(daemonState, getRouteKey(sim)) clearMappingsForSimulator(sim.udid) cable.serverIndex = null cable.velocity = { x: -4, y: 3 } } else { // disconnect whatever is connected to the selected server const serverIndex = tuiState.selectedRow for (const [simIndex, cable] of tuiState.cables) { if (cable.serverIndex === serverIndex) { const sim = tuiState.simulators[simIndex] if (sim) { // switch to manual mode if (tuiState.routeMode === 'most-recent') { tuiState.routeMode = 'ask' setRouteMode('ask') showPopup('Switched to manual mode', 1500) } clearRoute(daemonState, getRouteKey(sim)) clearMappingsForSimulator(sim.udid) cable.serverIndex = null cable.velocity = { x: -4, y: 3 } } break } } } } function updatePhysics(): void { if (!tuiState) return const gravity = 0.3 const damping = 0.85 let needsRender = false for (const [simIndex, cable] of tuiState.cables) { const simY = tuiState.rowStartY + simIndex if (cable.serverIndex !== null) { // connected - settle into catenary with variable sag // sag goes: high (0) -> low (4) -> high again (8+) const sagCurve = (i: number) => { if (i <= 4) return 6 - i // 6, 5, 4, 3, 2 return 2 + (i - 4) * 0.8 // 2.8, 3.6, 4.4, ... } const sag = sagCurve(simIndex) const serverY = tuiState.rowStartY + cable.serverIndex const targetX = (tuiState.simEndX + tuiState.serverStartX) / 2 const targetY = (simY + serverY) / 2 + sag const dx = targetX - cable.controlPoint.x const dy = targetY - cable.controlPoint.y cable.velocity.x += dx * 0.15 cable.velocity.y += dy * 0.15 cable.velocity.x *= damping cable.velocity.y *= damping cable.controlPoint.x += cable.velocity.x cable.controlPoint.y += cable.velocity.y if (Math.abs(cable.velocity.x) > 0.05 || Math.abs(cable.velocity.y) > 0.05) { needsRender = true } } else { // disconnected - swing with gravity cable.velocity.y += gravity cable.velocity.x *= damping cable.velocity.y *= damping cable.controlPoint.x += cable.velocity.x cable.controlPoint.y += cable.velocity.y // constrain const anchorX = tuiState.simEndX const anchorY = simY if (cable.controlPoint.x < anchorX) { cable.controlPoint.x = anchorX cable.velocity.x = Math.abs(cable.velocity.x) * 0.5 } if (cable.controlPoint.x > tuiState.serverStartX) { cable.controlPoint.x = tuiState.serverStartX cable.velocity.x = -Math.abs(cable.velocity.x) * 0.5 } if (cable.controlPoint.y < anchorY) { cable.controlPoint.y = anchorY cable.velocity.y = Math.abs(cable.velocity.y) * 0.3 } if (cable.controlPoint.y > tuiState.height - 5) { cable.controlPoint.y = tuiState.height - 5 cable.velocity.y = -Math.abs(cable.velocity.y) * 0.5 } needsRender = true } } if (needsRender) render() } async function refreshData(): Promise<void> { if (!tuiState || !daemonState) return const newSims = await getBootedSimulators() const newServers = getAllServers(daemonState) tuiState.simulators = newSims tuiState.servers = newServers const isDragging = tuiState.draggingSimIndex !== null // get actual simulator -> server mappings from routing state const simMappings = getSimulatorMappings() // sync each simulator's cable with actual routing state for (let simIndex = 0; simIndex < newSims.length; simIndex++) { const sim = newSims[simIndex] // check if we have a known mapping for this simulator const mappedServerId = simMappings.get(sim.udid) let routedServerIndex: number | null = null if (mappedServerId) { routedServerIndex = newServers.findIndex((s) => s.id === mappedServerId) if (routedServerIndex === -1) routedServerIndex = null } // get or create cable for this simulator let cable = tuiState.cables.get(simIndex) if (!cable) { cable = { serverIndex: routedServerIndex, controlPoint: { x: tuiState.simEndX + 5, y: tuiState.rowStartY + simIndex }, velocity: { x: 0, y: 0 }, } tuiState.cables.set(simIndex, cable) } // sync visual state with actual routing state (unless this sim is being dragged) if (tuiState.draggingSimIndex !== simIndex) { if (routedServerIndex !== cable.serverIndex) { cable.serverIndex = routedServerIndex // give it a little bounce when connection changes if (routedServerIndex !== null) { cable.velocity = { x: 0, y: -2 } } } } } // remove cables for simulators that no longer exist for (const simIndex of tuiState.cables.keys()) { if (simIndex >= newSims.length) { tuiState.cables.delete(simIndex) } } // clamp selection if (tuiState.selectedCol === 0) { tuiState.selectedRow = Math.min(tuiState.selectedRow, Math.max(0, newSims.length - 1)) } else { tuiState.selectedRow = Math.min( tuiState.selectedRow, Math.max(0, newServers.length - 1) ) } render() } function render(): void { if (!tuiState) return const { width, height, simEndX, serverStartX } = tuiState const lines: string[] = [] // header const title = ' one daemon ' const headerPad = Math.max(0, width - title.length - 10) lines.push(colors.cyan(`┌─${title}${'─'.repeat(headerPad)}─:8081─┐`)) // toggle switch row const isAuto = tuiState.routeMode === 'most-recent' const toggleLeft = isAuto ? colors.green('▶') : colors.dim('▷') const toggleRight = isAuto ? colors.dim('◁') : colors.yellow('◀') const autoLabel = isAuto ? colors.green('AUTO') : colors.dim('auto') const askLabel = isAuto ? colors.dim('ask') : colors.yellow('ASK') const toggle = ` ${autoLabel} ${toggleLeft}═══${toggleRight} ${askLabel} [m] toggle` const togglePad = Math.max(0, width - stripAnsi(toggle).length - 2) lines.push(colors.cyan('│') + toggle + ' '.repeat(togglePad) + colors.cyan('│')) // column headers const simHeader = ' SIMULATORS' const srvHeader = 'SERVERS ' const gap = ' '.repeat(Math.max(0, serverStartX - simEndX)) lines.push( colors.cyan('│') + colors.bold(simHeader.padEnd(simEndX - 1)) + gap + colors.bold(srvHeader.padStart(width - serverStartX - 1)) + colors.cyan('│') ) // separator lines.push(colors.cyan('│') + colors.dim('─'.repeat(width - 2)) + colors.cyan('│')) // content area const contentRows = height - 7 for (let row = 0; row < contentRows; row++) { const y = tuiState.rowStartY + row let line = '' line += colors.cyan('│') // sim column - right aligned with dot on right const sim = tuiState.simulators[row] let simText = '' if (sim) { const isSelected = tuiState.selectedCol === 0 && tuiState.selectedRow === row const cable = tuiState.cables.get(row) const hasConnection = cable?.serverIndex !== null // unique color per cable const cableColors = [ colors.green, colors.cyan, colors.magenta, colors.blue, colors.yellow, ] const cableColor = cableColors[row % cableColors.length] const plug = hasConnection ? cableColor('●') : colors.dim('○') const name = truncate(sim.name, simEndX - 5) simText = `${name} ${plug}` if (isSelected) simText = colors.inverse(simText) } // right-align the sim text const simTextLen = stripAnsi(simText).length const simPad = Math.max(0, simEndX - 1 - simTextLen) line += ' '.repeat(simPad) + simText // cable zone let cableZone = '' for (let x = simEndX; x < serverStartX; x++) { const char = getCableCharAt(x, y) cableZone += char || ' ' } line += cableZone // server column - dot and folder left, port right-aligned bold yellow const server = tuiState.servers[row] let srvLeft = '' let srvRight = '' if (server) { const isSelected = tuiState.selectedCol === 1 && tuiState.selectedRow === row // check which cables are connected to this server and get their colors const cableColors = [ colors.green, colors.cyan, colors.magenta, colors.blue, colors.yellow, ] let connectedColor: ((s: string) => string) | null = null for (const [simIndex, cable] of tuiState.cables) { if (cable.serverIndex === row) { connectedColor = cableColors[simIndex % cableColors.length] break } } const lastActive = daemonState ? getLastActiveServer(daemonState) : null const isLastActive = lastActive?.id === server.id const plug = connectedColor ? connectedColor('●') : colors.dim('○') const star = isLastActive ? colors.yellow('★') : ' ' const shortRoot = truncate( server.root.replace(process.env.HOME || '', '~'), width - serverStartX - 14 ) srvLeft = `${plug} ${star}${shortRoot}` srvRight = colors.bold(colors.yellow(`:${server.port}`)) if (isSelected) { srvLeft = colors.inverse(srvLeft) srvRight = colors.inverse(srvRight) } } const srvLeftLen = stripAnsi(srvLeft).length const srvRightLen = stripAnsi(srvRight).length const srvColWidth = width - serverStartX - 2 const srvGap = Math.max(1, srvColWidth - srvLeftLen - srvRightLen) line += srvLeft + ' '.repeat(srvGap) + srvRight line += colors.cyan('│') lines.push(line) } // popup or help if (tuiState.popup) { const msg = tuiState.popup.message const padLeft = Math.floor((width - msg.length - 4) / 2) const padRight = width - msg.length - padLeft - 4 lines.push( colors.cyan('│') + ' '.repeat(Math.max(0, padLeft)) + colors.bgYellow(colors.black(` ${msg} `)) + ' '.repeat(Math.max(0, padRight)) + colors.cyan('│') ) } else { lines.push( colors.cyan('│') + colors .dim(' ↑↓ select ←→ move space grab/plug d disconnect b bg q quit') .padEnd(width - 2) + colors.cyan('│') ) } // footer lines.push(colors.cyan(`└${'─'.repeat(width - 2)}┘`)) const output = lines.join('\n') if (output !== tuiState.lastRender) { tuiState.lastRender = output process.stdout.write(ansi.home + output) } } function getCableCharAt(x: number, y: number): string | null { if (!tuiState) return null if (tuiState.simulators.length === 0) return null // check each cable for (const [simIndex, cable] of tuiState.cables) { const startX = tuiState.simEndX const startY = tuiState.rowStartY + simIndex let endX: number, endY: number if (cable.serverIndex !== null) { endX = tuiState.serverStartX endY = tuiState.rowStartY + cable.serverIndex } else { endX = Math.round(cable.controlPoint.x) endY = Math.round(cable.controlPoint.y) } const ctrlX = Math.round(cable.controlPoint.x) const ctrlY = Math.round(cable.controlPoint.y) // sample bezier curve const steps = 30 for (let i = 0; i <= steps; i++) { const t = i / steps const invT = 1 - t const px = Math.round(invT * invT * startX + 2 * invT * t * ctrlX + t * t * endX) const py = Math.round(invT * invT * startY + 2 * invT * t * ctrlY + t * t * endY) if (px === x && py === y) { const connected = cable.serverIndex !== null // unique color per cable based on sim index const cableColors = [ colors.green, colors.cyan, colors.magenta, colors.blue, colors.yellow, ] const baseColor = cableColors[simIndex % cableColors.length] const color = connected ? baseColor : colors.dim // determine character based on curve direction const tPrev = Math.max(0, (i - 1) / steps) const tNext = Math.min(1, (i + 1) / steps) const prevX = Math.round( (1 - tPrev) * (1 - tPrev) * startX + 2 * (1 - tPrev) * tPrev * ctrlX + tPrev * tPrev * endX ) const prevY = Math.round( (1 - tPrev) * (1 - tPrev) * startY + 2 * (1 - tPrev) * tPrev * ctrlY + tPrev * tPrev * endY ) const nextX = Math.round( (1 - tNext) * (1 - tNext) * startX + 2 * (1 - tNext) * tNext * ctrlX + tNext * tNext * endX ) const nextY = Math.round( (1 - tNext) * (1 - tNext) * startY + 2 * (1 - tNext) * tNext * ctrlY + tNext * tNext * endY ) const dx = nextX - prevX const dy = nextY - prevY let char: string if (Math.abs(dx) > Math.abs(dy) * 2) { char = '─' } else if (Math.abs(dy) > Math.abs(dx) * 2) { char = '│' } else if ((dx > 0 && dy > 0) || (dx < 0 && dy < 0)) { char = '╲' } else { char = '╱' } return color(char) } } } return null } function truncate(str: string, maxLen: number): string { if (maxLen <= 0) return '' if (str.length <= maxLen) return str return str.slice(0, maxLen - 1) + '…' } function stripAnsi(str: string): string { return str.replace(/\x1b\[[0-9;]*m/g, '') } export function stopTUI(): void { if (refreshInterval) { clearInterval(refreshInterval) refreshInterval = null } if (physicsInterval) { clearInterval(physicsInterval) physicsInterval = null } if (stdinListener) { process.stdin.removeListener('data', stdinListener) stdinListener = null } if (resizeListener) { process.stdout.removeListener('resize', resizeListener) resizeListener = null } process.stdout.write(ansi.clearScreen + ansi.home + ansi.showCursor) if (process.stdin.isTTY) { process.stdin.setRawMode(false) } tuiState = null daemonState = null } // for pulse animations (called from server.ts) export function triggerPulse( _serverId: string, _direction: 'request' | 'response' ): void { // TODO: implement pulse animations }