chore: 项目分割线

This commit is contained in:
2026-03-21 20:01:31 +08:00
parent b5749d01ec
commit c265de6a84
21 changed files with 0 additions and 12615 deletions

4
.gitignore vendored
View File

@@ -1,4 +0,0 @@
node_modules/
dist/
.DS_Store
*.log

View File

@@ -1 +0,0 @@
3002

View File

@@ -1,72 +0,0 @@
# XCTerminal
基于 Electron 的无头终端应用,通过浏览器访问,支持 3x2 网格布局。
## 功能特性
- 网格布局终端面板3 列 x 2 行)
- 实时终端输出,支持 PowerShell
- 支持 WebSocket 和 HTTP 输入
- 自动选择空闲端口
- 跨平台Windows、macOS、Linux
## 快速开始
### 开发
```bash
npm install
npm run dev
```
### 构建
```bash
npm run electron:build
```
构建产物位于 `release/XCTerminal.exe`
### 使用
```bash
# 使用默认端口3002
./release/XCTerminal.exe
# 指定端口
./release/XCTerminal.exe --port=8080
# 或使用环境变量
PORT=8080 ./release/XCTerminal.exe
```
启动后访问 `http://localhost:3002`(或你指定的端口)。
## 项目结构
```
XCTerminal/
├── electron/ # Electron 主进程
│ ├── main.js # 入口文件
│ └── preload.js # 预加载脚本
├── src/ # React 前端
│ ├── App.tsx # 主组件
│ └── components/ # UI 组件
├── server/ # 独立服务器(可选)
│ └── index.js # 服务器入口
└── release/ # 构建产物
```
## 技术栈
- Electron无头模式
- React 19
- Vite
- Ghostty Web终端模拟
- node-ptyPTY 管理)
- ExpressHTTP 服务器)
- WebSocket实时 I/O
## 许可证
私有项目 - 保留所有权利。

View File

@@ -1,59 +0,0 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT_FILE = '.terminal-server-port';
async function waitForPortFile(timeout = 10000) {
const start = Date.now();
while (Date.now() - start < timeout) {
if (fs.existsSync(PORT_FILE)) {
return true;
}
await new Promise(r => setTimeout(r, 100));
}
return false;
}
async function main() {
// Clean up any existing port file
if (fs.existsSync(PORT_FILE)) {
fs.unlinkSync(PORT_FILE);
}
// Start server
console.log('Starting terminal server...');
const server = spawn('node', ['server/index.js'], {
stdio: 'inherit',
shell: true,
});
// Wait for server to be ready
await waitForPortFile();
const port = fs.readFileSync(PORT_FILE, 'utf-8').trim();
console.log(`Terminal server ready on port ${port}`);
// Set env for vite
process.env.TERMINAL_API_URL = `http://localhost:${port}`;
// Start vite
console.log('Starting Vite dev server...');
const vite = spawn('npx', ['vite'], {
stdio: 'inherit',
shell: true,
env: { ...process.env, TERMINAL_API_URL: `http://localhost:${port}` },
});
// Cleanup on exit
process.on('exit', () => {
server.kill();
vite.kill();
if (fs.existsSync(PORT_FILE)) {
fs.unlinkSync(PORT_FILE);
}
});
}
main().catch(console.error);

View File

@@ -1,408 +0,0 @@
import { app, BrowserWindow } from 'electron';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import express from 'express';
import { WebSocketServer } from 'ws';
import { createServer } from 'node:http';
import os from 'os';
import pty from 'node-pty';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const PORT_FILE = '.terminal-server-port';
const isDev = process.env.NODE_ENV !== 'production' && !app.isPackaged;
let server;
let wss;
const terminalSessions = new Map();
let sessionIdCounter = 1;
function getShell() {
if (process.platform === 'win32') {
return 'powershell.exe';
}
return process.env.SHELL || '/bin/bash';
}
function generateSessionId() {
return `term-${Date.now()}-${sessionIdCounter++}`;
}
function createTerminalSession(cwd, cols = 80, rows = 24) {
const shell = getShell();
let workingDir = cwd || os.homedir();
try {
const fs = import('fs');
if (!fs.existsSync(workingDir)) {
workingDir = os.homedir();
}
} catch {
workingDir = os.homedir();
}
const ptyProcess = pty.spawn(shell, [], {
name: 'xterm-256color',
cols,
rows,
cwd: workingDir,
env: process.env,
});
return ptyProcess;
}
async function findAvailablePort(startPort) {
const net = await import('net');
return new Promise((resolve) => {
const testServer = net.createServer();
testServer.listen(startPort, () => {
const port = testServer.address().port;
testServer.close(() => resolve(port));
});
testServer.on('error', () => {
resolve(findAvailablePort(startPort + 1));
});
});
}
function setupTerminalAPI(app) {
app.post('/api/terminal/create', async (req, res) => {
try {
const { cwd, cols, rows } = req.body;
const sessionId = generateSessionId();
const ptyProcess = createTerminalSession(cwd, cols || 80, rows || 24);
const session = {
id: sessionId,
pty: ptyProcess,
cwd: ptyProcess.cwd,
cols: cols || 80,
rows: rows || 24,
createdAt: Date.now(),
};
terminalSessions.set(sessionId, session);
ptyProcess.onExit(({ exitCode, signal }) => {
console.log(`Terminal exited: session=${sessionId} code=${exitCode} signal=${signal}`);
terminalSessions.delete(sessionId);
});
res.json({
sessionId,
cols: session.cols,
rows: session.rows,
capabilities: {
input: {
preferred: 'http',
transports: ['ws', 'http'],
ws: {
path: '/api/terminal/input-ws',
},
},
},
});
} catch (error) {
console.error('Failed to create terminal:', error);
res.status(500).json({ error: error.message });
}
});
app.get('/api/terminal/:sessionId/stream', (req, res) => {
const { sessionId } = req.params;
const session = terminalSessions.get(sessionId);
if (!session) {
return res.status(404).json({ error: 'Terminal session not found' });
}
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
const dataHandler = (data) => {
res.write(`data: ${JSON.stringify({ type: 'data', data })}\n\n`);
};
const exitHandler = ({ exitCode, signal }) => {
res.write(`data: ${JSON.stringify({ type: 'exit', exitCode, signal })}\n\n`);
res.end();
};
session.pty.onData(dataHandler);
session.pty.onExit(exitHandler);
res.write(`data: ${JSON.stringify({ type: 'connected' })}\n\n`);
req.on('close', () => {
session.pty.removeListener('data', dataHandler);
session.pty.removeListener('exit', exitHandler);
});
});
app.post('/api/terminal/:sessionId/input', (req, res) => {
const { sessionId } = req.params;
const session = terminalSessions.get(sessionId);
if (!session) {
return res.status(404).json({ error: 'Terminal session not found' });
}
try {
const data = req.body;
session.pty.write(data);
res.status(204).send();
} catch (error) {
console.error('Failed to write to terminal:', error);
res.status(500).json({ error: 'Failed to write to terminal' });
}
});
app.post('/api/terminal/:sessionId/resize', (req, res) => {
const { sessionId } = req.params;
const session = terminalSessions.get(sessionId);
if (!session) {
return res.status(404).json({ error: 'Terminal session not found' });
}
try {
const { cols, rows } = req.body;
session.pty.resize(cols, rows);
session.cols = cols;
session.rows = rows;
res.status(204).send();
} catch (error) {
console.error('Failed to resize terminal:', error);
res.status(500).json({ error: 'Failed to resize terminal' });
}
});
app.delete('/api/terminal/:sessionId', (req, res) => {
const { sessionId } = req.params;
const session = terminalSessions.get(sessionId);
if (!session) {
return res.status(404).json({ error: 'Terminal session not found' });
}
try {
session.pty.kill();
terminalSessions.delete(sessionId);
res.status(204).send();
} catch (error) {
console.error('Failed to close terminal:', error);
res.status(500).json({ error: 'Failed to close terminal' });
}
});
app.post('/api/terminal/:sessionId/restart', async (req, res) => {
const { sessionId } = req.params;
const session = terminalSessions.get(sessionId);
if (!session) {
return res.status(404).json({ error: 'Terminal session not found' });
}
try {
const { cwd, cols, rows } = req.body;
session.pty.kill();
terminalSessions.delete(sessionId);
const newSessionId = generateSessionId();
const ptyProcess = createTerminalSession(cwd, cols || 80, rows || 24);
const newSession = {
id: newSessionId,
pty: ptyProcess,
cwd: ptyProcess.cwd,
cols: cols || 80,
rows: rows || 24,
createdAt: Date.now(),
};
terminalSessions.set(newSessionId, newSession);
ptyProcess.onExit(() => {
terminalSessions.delete(newSessionId);
});
res.json({
sessionId: newSessionId,
cols: newSession.cols,
rows: newSession.rows,
capabilities: {
input: {
preferred: 'http',
transports: ['ws', 'http'],
ws: {
path: '/api/terminal/input-ws',
},
},
},
});
} catch (error) {
console.error('Failed to restart terminal:', error);
res.status(500).json({ error: error.message });
}
});
app.post('/api/terminal/force-kill', (req, res) => {
const { sessionId, cwd } = req.body;
let killedCount = 0;
if (sessionId) {
const session = terminalSessions.get(sessionId);
if (session) {
session.pty.kill();
terminalSessions.delete(sessionId);
killedCount = 1;
}
} else if (cwd) {
for (const [id, session] of terminalSessions) {
if (session.cwd === cwd) {
session.pty.kill();
terminalSessions.delete(id);
killedCount++;
}
}
}
res.json({ killed: killedCount });
});
}
function setupWebSocket() {
wss = new WebSocketServer({ server, path: '/api/terminal/input-ws' });
wss.on('connection', (ws) => {
console.log('WebSocket connected for terminal input');
let boundSessionId = null;
ws.on('message', (message) => {
try {
const data = new Uint8Array(message);
if (data[0] === 0x01) {
const jsonStr = new TextDecoder().decode(data.slice(1));
const payload = JSON.parse(jsonStr);
if (payload.t === 'b' && payload.s) {
boundSessionId = payload.s;
console.log(`WebSocket bound to session: ${boundSessionId}`);
}
if (payload.t === 'p') {
ws.send(Buffer.from([0x01, ...new TextEncoder().encode(JSON.stringify({ t: 'po' }))]));
}
return;
}
const text = new TextDecoder().decode(message);
if (boundSessionId) {
const session = terminalSessions.get(boundSessionId);
if (session) {
session.pty.write(text);
}
}
} catch (error) {
console.error('WebSocket message error:', error);
}
});
ws.on('close', () => {
console.log('WebSocket disconnected');
});
});
}
function getPortFromArgs() {
const portArg = process.argv.find(arg => arg.startsWith('--port='));
if (portArg) {
const port = parseInt(portArg.split('=')[1], 10);
if (!isNaN(port) && port > 0 && port < 65536) {
return port;
}
}
return null;
}
async function startServer() {
const app = express();
const fs = await import('fs');
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type, Accept');
if (req.method === 'OPTIONS') {
return res.status(204).send();
}
next();
});
app.use(express.json({ limit: '50mb' }));
app.use(express.text({ type: '*/*' }));
setupTerminalAPI(app);
if (isDev) {
app.use(express.static(path.join(__dirname, '../dist')));
} else {
app.use(express.static(path.join(__dirname, '../dist')));
}
app.use((req, res) => {
if (isDev) {
res.sendFile(path.join(__dirname, '../dist/index.html'));
} else {
res.sendFile(path.join(__dirname, '../dist/index.html'));
}
});
server = createServer(app);
setupWebSocket();
const startPort = getPortFromArgs() || parseInt(process.env.PORT || '3002', 10);
const availablePort = await findAvailablePort(startPort);
server.listen(availablePort, () => {
console.log(`Terminal server running on http://localhost:${availablePort}`);
fs.writeFileSync(PORT_FILE, String(availablePort));
});
process.on('exit', () => {
try {
fs.unlinkSync(PORT_FILE);
} catch {}
});
}
app.whenReady().then(() => {
startServer();
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
startServer();
}
});
app.on('before-quit', () => {
for (const [id, session] of terminalSessions) {
try {
session.pty.kill();
} catch {}
}
terminalSessions.clear();
});

View File

@@ -1,10 +0,0 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electron', {
platform: process.platform,
versions: {
node: process.versions.node,
chrome: process.versions.chrome,
electron: process.versions.electron,
},
});

View File

@@ -1,13 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>XCTerminal</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

8133
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,81 +0,0 @@
{
"name": "xcterminal",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "electron/main.js",
"scripts": {
"dev": "node dev-runner.js",
"dev:server": "node server/index.js",
"build": "tsc && vite build",
"preview": "vite preview",
"electron:dev": "npm run build && electron .",
"electron:build": "npm run build && electron-builder",
"electron:start": "electron ."
},
"dependencies": {
"@fontsource/ibm-plex-mono": "^5.2.7",
"clsx": "^2.1.1",
"express": "^5.1.0",
"ghostty-web": "^0.4.0",
"node-pty": "^1.1.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"ws": "^8.18.3",
"zustand": "^5.0.8"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.0",
"@types/express": "^5.0.1",
"@types/node": "^24.3.1",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@types/ws": "^8.18.0",
"@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.21",
"concurrently": "^9.2.1",
"electron": "^41.0.3",
"electron-builder": "^26.8.1",
"tailwindcss": "^4.0.0",
"typescript": "~5.8.3",
"vite": "^7.1.2"
},
"build": {
"appId": "com.xcterminal",
"productName": "XCTerminal",
"directories": {
"output": "release"
},
"files": [
"electron/**/*",
"dist/**/*",
"package.json"
],
"win": {
"target": [
{
"target": "portable",
"arch": [
"x64"
]
}
],
"artifactName": "XCTerminal.exe"
},
"mac": {
"target": [
"dmg"
]
},
"linux": {
"target": [
"AppImage"
]
},
"extraMetadata": {
"main": "electron/main.js"
},
"npmRebuild": false,
"nodeGypRebuild": false
}
}

View File

@@ -1,5 +0,0 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
}

View File

@@ -1,379 +0,0 @@
import express from 'express';
import { WebSocketServer } from 'ws';
import { createServer } from 'http';
import os from 'os';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const PORT = parseInt(process.env.PORT || '3002', 10);
const PORT_FILE = '.terminal-server-port';
// CORS middleware
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type, Accept');
if (req.method === 'OPTIONS') {
return res.status(204).send();
}
next();
});
// Middleware
app.use(express.json({ limit: '50mb' }));
app.use(express.text({ type: '*/*' }));
// Terminal sessions storage
const terminalSessions = new Map();
let sessionIdCounter = 1;
// Get shell based on platform
function getShell() {
if (process.platform === 'win32') {
// Use PowerShell on Windows
return 'powershell.exe';
}
return process.env.SHELL || '/bin/bash';
}
// Create terminal session
async function createTerminalSession(cwd, cols = 80, rows = 24) {
const pty = await import('node-pty');
const shell = getShell();
// Validate working directory
let workingDir = cwd || os.homedir();
try {
const fs = await import('fs');
if (!fs.existsSync(workingDir)) {
workingDir = os.homedir();
}
} catch {
workingDir = os.homedir();
}
const ptyProcess = pty.spawn(shell, [], {
name: 'xterm-256color',
cols,
rows,
cwd: workingDir,
env: process.env,
});
return ptyProcess;
}
// Generate session ID
function generateSessionId() {
return `term-${Date.now()}-${sessionIdCounter++}`;
}
// API: Create terminal session
app.post('/api/terminal/create', async (req, res) => {
try {
const { cwd, cols, rows } = req.body;
const sessionId = generateSessionId();
const ptyProcess = await createTerminalSession(cwd, cols || 80, rows || 24);
const session = {
id: sessionId,
pty: ptyProcess,
cwd: ptyProcess.cwd,
cols: cols || 80,
rows: rows || 24,
createdAt: Date.now(),
};
terminalSessions.set(sessionId, session);
// Handle PTY data
ptyProcess.onData((data) => {
// Data will be sent via SSE or WebSocket
});
ptyProcess.onExit(({ exitCode, signal }) => {
console.log(`Terminal exited: session=${sessionId} code=${exitCode} signal=${signal}`);
terminalSessions.delete(sessionId);
});
res.json({
sessionId,
cols: session.cols,
rows: session.rows,
capabilities: {
input: {
preferred: 'http',
transports: ['ws', 'http'],
ws: {
path: '/api/terminal/input-ws',
},
},
},
});
} catch (error) {
console.error('Failed to create terminal:', error);
res.status(500).json({ error: error.message });
}
});
// SSE: Terminal output stream
app.get('/api/terminal/:sessionId/stream', (req, res) => {
const { sessionId } = req.params;
const session = terminalSessions.get(sessionId);
if (!session) {
return res.status(404).json({ error: 'Terminal session not found' });
}
// Set SSE headers
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
const clientId = Date.now();
const dataHandler = (data) => {
res.write(`data: ${JSON.stringify({ type: 'data', data })}\n\n`);
};
const exitHandler = ({ exitCode, signal }) => {
res.write(`data: ${JSON.stringify({ type: 'exit', exitCode, signal })}\n\n`);
res.end();
};
session.pty.onData(dataHandler);
session.pty.onExit(exitHandler);
// Send initial connection event
res.write(`data: ${JSON.stringify({ type: 'connected' })}\n\n`);
// Handle client disconnect
req.on('close', () => {
session.pty.removeListener('data', dataHandler);
session.pty.removeListener('exit', exitHandler);
console.log(`Client disconnected: session=${sessionId} client=${clientId}`);
});
});
// HTTP: Send terminal input
app.post('/api/terminal/:sessionId/input', (req, res) => {
const { sessionId } = req.params;
const session = terminalSessions.get(sessionId);
if (!session) {
return res.status(404).json({ error: 'Terminal session not found' });
}
try {
const data = req.body;
session.pty.write(data);
res.status(204).send();
} catch (error) {
console.error('Failed to write to terminal:', error);
res.status(500).json({ error: 'Failed to write to terminal' });
}
});
// HTTP: Resize terminal
app.post('/api/terminal/:sessionId/resize', (req, res) => {
const { sessionId } = req.params;
const session = terminalSessions.get(sessionId);
if (!session) {
return res.status(404).json({ error: 'Terminal session not found' });
}
try {
const { cols, rows } = req.body;
session.pty.resize(cols, rows);
session.cols = cols;
session.rows = rows;
res.status(204).send();
} catch (error) {
console.error('Failed to resize terminal:', error);
res.status(500).json({ error: 'Failed to resize terminal' });
}
});
// HTTP: Close terminal session
app.delete('/api/terminal/:sessionId', (req, res) => {
const { sessionId } = req.params;
const session = terminalSessions.get(sessionId);
if (!session) {
return res.status(404).json({ error: 'Terminal session not found' });
}
try {
session.pty.kill();
terminalSessions.delete(sessionId);
res.status(204).send();
} catch (error) {
console.error('Failed to close terminal:', error);
res.status(500).json({ error: 'Failed to close terminal' });
}
});
// Restart terminal session
app.post('/api/terminal/:sessionId/restart', async (req, res) => {
const { sessionId } = req.params;
const session = terminalSessions.get(sessionId);
if (!session) {
return res.status(404).json({ error: 'Terminal session not found' });
}
try {
const { cwd, cols, rows } = req.body;
// Kill existing session
session.pty.kill();
terminalSessions.delete(sessionId);
// Create new session
const newSessionId = generateSessionId();
const ptyProcess = await createTerminalSession(cwd, cols || 80, rows || 24);
const newSession = {
id: newSessionId,
pty: ptyProcess,
cwd: ptyProcess.cwd,
cols: cols || 80,
rows: rows || 24,
createdAt: Date.now(),
};
terminalSessions.set(newSessionId, newSession);
ptyProcess.onExit(({ exitCode, signal }) => {
terminalSessions.delete(newSessionId);
});
res.json({
sessionId: newSessionId,
cols: newSession.cols,
rows: newSession.rows,
capabilities: {
input: {
preferred: 'http',
transports: ['ws', 'http'],
ws: {
path: '/api/terminal/input-ws',
},
},
},
});
} catch (error) {
console.error('Failed to restart terminal:', error);
res.status(500).json({ error: 'Failed to restart terminal' });
}
});
// Force kill terminal
app.post('/api/terminal/force-kill', (req, res) => {
const { sessionId, cwd } = req.body;
let killedCount = 0;
if (sessionId) {
const session = terminalSessions.get(sessionId);
if (session) {
session.pty.kill();
terminalSessions.delete(sessionId);
killedCount = 1;
}
} else if (cwd) {
for (const [id, session] of terminalSessions) {
if (session.cwd === cwd) {
session.pty.kill();
terminalSessions.delete(id);
killedCount++;
}
}
}
res.json({ killed: killedCount });
});
// WebSocket for terminal input
const server = createServer(app);
const wss = new WebSocketServer({ server, path: '/api/terminal/input-ws' });
wss.on('connection', (ws, req) => {
console.log('WebSocket connected for terminal input');
let boundSessionId = null;
ws.on('message', (message) => {
try {
const data = new Uint8Array(message);
// Control frame: JSON
if (data[0] === 0x01) {
const jsonStr = new TextDecoder().decode(data.slice(1));
const payload = JSON.parse(jsonStr);
// Bind session
if (payload.t === 'b' && payload.s) {
boundSessionId = payload.s;
console.log(`WebSocket bound to session: ${boundSessionId}`);
}
// Ping
if (payload.t === 'p') {
ws.send(Buffer.from([0x01, ...new TextEncoder().encode(JSON.stringify({ t: 'po' }))]));
}
return;
}
// Regular input data
const text = new TextDecoder().decode(message);
if (boundSessionId) {
const session = terminalSessions.get(boundSessionId);
if (session) {
session.pty.write(text);
}
}
} catch (error) {
console.error('WebSocket message error:', error);
}
});
ws.on('close', () => {
console.log('WebSocket disconnected');
});
});
async function findAvailablePort(startPort) {
const net = await import('net');
return new Promise((resolve) => {
const server = net.createServer();
server.listen(startPort, () => {
const port = server.address().port;
server.close(() => resolve(port));
});
server.on('error', () => {
resolve(findAvailablePort(startPort + 1));
});
});
}
(async () => {
const fs = await import('fs');
const availablePort = await findAvailablePort(PORT);
server.listen(availablePort, () => {
console.log(`Terminal server running on http://localhost:${availablePort}`);
fs.writeFileSync(PORT_FILE, String(availablePort));
});
process.on('exit', () => {
try {
fs.unlinkSync(PORT_FILE);
} catch {}
});
})();

View File

@@ -1,210 +0,0 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { TerminalViewport } from './components/TerminalViewport';
import {
createTerminalSession,
connectTerminalStream,
sendTerminalInput,
resizeTerminal,
closeTerminal,
} from './lib/terminalApi';
import { getDefaultTheme, type TerminalTheme } from './lib/terminalTheme';
import { type TerminalChunk } from './stores/useTerminalStore';
import { extractPowerShellPromptPath } from './lib/utils';
const DEFAULT_CWD = '/workspace';
const DEFAULT_FONT_SIZE = 14;
const DEFAULT_FONT_FAMILY = 'IBM Plex Mono';
function TerminalPanel({ initialCwd, connectDelay = 0, autoFocus = false }: { initialCwd: string; connectDelay?: number; autoFocus?: boolean }) {
const [theme] = useState<TerminalTheme>(getDefaultTheme());
const [fontSize] = useState(DEFAULT_FONT_SIZE);
const [fontFamily] = useState(DEFAULT_FONT_FAMILY);
const [chunks, setChunks] = useState<TerminalChunk[]>([]);
const [isConnected, setIsConnected] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [currentPath, setCurrentPath] = useState(initialCwd);
const [sessionId, setSessionId] = useState<string | null>(null);
const sessionIdRef = useRef<string | null>(null);
const cleanupRef = useRef<(() => void) | null>(null);
const nextChunkIdRef = useRef(1);
const isMountedRef = useRef(true);
const hasConnectedRef = useRef(false);
const handleInput = useCallback(async (data: string) => {
const sessionId = sessionIdRef.current;
if (!sessionId) {
return;
}
try {
await sendTerminalInput(sessionId, data);
} catch (err) {
console.error('[handleInput] Failed to send input:', err);
}
}, [initialCwd]);
const handleResize = useCallback(async (cols: number, rows: number) => {
const sessionId = sessionIdRef.current;
if (!sessionId) return;
try {
await resizeTerminal(sessionId, cols, rows);
} catch (err) {
console.error('Failed to resize terminal:', err);
}
}, []);
useEffect(() => {
// Skip if already connected (e.g., React strict mode double-mount)
if (hasConnectedRef.current) return;
isMountedRef.current = true;
const connectToTerminal = async (targetCwd: string) => {
// Cleanup previous session
if (cleanupRef.current) {
cleanupRef.current();
cleanupRef.current = null;
}
if (sessionIdRef.current) {
try {
await closeTerminal(sessionIdRef.current);
} catch {
// Ignore cleanup errors
}
}
setIsConnecting(true);
setError(null);
setChunks([]);
sessionIdRef.current = null;
nextChunkIdRef.current = 1;
try {
const session = await createTerminalSession({
cwd: targetCwd,
cols: 80,
rows: 24,
});
if (!isMountedRef.current) return;
sessionIdRef.current = session.sessionId;
setSessionId(session.sessionId);
cleanupRef.current = connectTerminalStream(
session.sessionId,
(event) => {
if (!isMountedRef.current) return;
if (event.type === 'connected') {
setIsConnected(true);
setIsConnecting(false);
} else if (event.type === 'data' && event.data) {
const osc7Path = extractPowerShellPromptPath(event.data);
if (osc7Path) {
setCurrentPath(osc7Path);
}
const newChunk: TerminalChunk = {
id: nextChunkIdRef.current++,
data: event.data,
};
setChunks((prev) => [...prev, newChunk]);
} else if (event.type === 'exit') {
setIsConnected(false);
if (event.exitCode !== 0) {
setError(`Terminal exited with code ${event.exitCode}`);
}
} else if (event.type === 'reconnecting') {
setIsConnecting(true);
}
},
(err, fatal) => {
console.error('Terminal error:', err);
setIsConnected(false);
if (fatal) {
setError(err.message);
}
setIsConnecting(false);
}
);
} catch (err) {
if (!isMountedRef.current) return;
setIsConnecting(false);
setError(err instanceof Error ? err.message : 'Failed to create terminal');
}
};
// Apply connection delay to stagger connections
const delay = connectDelay > 0 ? connectDelay : 0;
const timeoutId = setTimeout(() => {
hasConnectedRef.current = true;
connectToTerminal(initialCwd);
}, delay);
return () => {
clearTimeout(timeoutId);
isMountedRef.current = false;
if (cleanupRef.current) {
cleanupRef.current();
}
};
}, [initialCwd, connectDelay]);
return (
<div className="h-full w-full flex flex-col bg-[#1e1e1e] border border-[#3c3c3c]">
{/* Panel header */}
<div className="px-2 py-1 bg-[#2d2d2d] border-b border-[#3c3c3c] flex items-center justify-between">
<span className="truncate text-white text-xs">{currentPath}</span>
<span className={isConnected ? 'text-[#4caf50]' : 'text-[#808080]'}>
{isConnected ? '●' : isConnecting ? '○' : '○'}
</span>
</div>
{/* Error message */}
{error && (
<div className="px-2 py-1 bg-[#3c3c3c] text-[#f14c4c] text-xs">
{error}
</div>
)}
{/* Terminal viewport */}
<div className="flex-1 min-h-0">
{isConnected ? (
<TerminalViewport
sessionKey={`${initialCwd}-${sessionId}`}
chunks={chunks}
onInput={handleInput}
onResize={handleResize}
theme={theme}
fontFamily={fontFamily}
fontSize={fontSize}
autoFocus={autoFocus}
/>
) : (
<div className="h-full flex items-center justify-center text-[#808080] text-sm">
{isConnecting ? 'Connecting...' : 'Click to connect'}
</div>
)}
</div>
</div>
);
}
function App() {
return (
<div className="grid grid-cols-3 grid-rows-2 gap-1 h-screen w-screen bg-[#1e1e1e]">
<TerminalPanel initialCwd={DEFAULT_CWD} connectDelay={0} autoFocus={true} />
<TerminalPanel initialCwd={DEFAULT_CWD} connectDelay={500} />
<TerminalPanel initialCwd={DEFAULT_CWD} connectDelay={1000} />
<TerminalPanel initialCwd={DEFAULT_CWD} connectDelay={1500} />
<TerminalPanel initialCwd={DEFAULT_CWD} connectDelay={2000} />
<TerminalPanel initialCwd={DEFAULT_CWD} connectDelay={2500} />
</div>
);
}
export default App;

File diff suppressed because it is too large Load Diff

View File

@@ -1,28 +0,0 @@
@import "tailwindcss";
:root {
font-family: 'IBM Plex Sans', system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body, #root {
height: 100%;
width: 100%;
overflow: hidden;
}

View File

@@ -1,775 +0,0 @@
export interface TerminalSession {
sessionId: string;
cols: number;
rows: number;
capabilities?: {
input?: TerminalInputCapability;
};
}
export interface TerminalInputCapability {
preferred?: 'ws' | 'http';
transports?: Array<'ws' | 'http'>;
ws?: {
path: string;
v?: number;
enc?: string;
};
}
export interface TerminalStreamEvent {
type: 'connected' | 'data' | 'exit' | 'reconnecting';
data?: string;
exitCode?: number;
signal?: number | null;
attempt?: number;
maxAttempts?: number;
}
export interface CreateTerminalOptions {
cwd: string;
cols?: number;
rows?: number;
}
export interface ConnectStreamOptions {
maxRetries?: number;
initialRetryDelay?: number;
maxRetryDelay?: number;
connectionTimeout?: number;
}
type TerminalInputControlMessage = {
t: string;
s?: string;
c?: string;
f?: boolean;
v?: number;
};
const CONTROL_TAG_JSON = 0x01;
const WS_READY_STATE_OPEN = 1;
const DEFAULT_TERMINAL_INPUT_WS_PATH = '/api/terminal/input-ws';
const WS_SEND_WAIT_MS = 200;
const WS_RECONNECT_INITIAL_DELAY_MS = 1000;
const WS_RECONNECT_MAX_DELAY_MS = 30000;
const WS_RECONNECT_JITTER_MS = 250;
const WS_KEEPALIVE_INTERVAL_MS = 20000;
const WS_CONNECT_TIMEOUT_MS = 5000;
const GLOBAL_TERMINAL_INPUT_STATE_KEY = '__terminalInputWsState';
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
const normalizeWebSocketPath = (pathValue: string): string => {
if (/^wss?:\/\//i.test(pathValue)) {
return pathValue;
}
if (/^https?:\/\//i.test(pathValue)) {
const url = new URL(pathValue);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
return url.toString();
}
if (typeof window === 'undefined') {
return '';
}
const normalizedPath = pathValue.startsWith('/') ? pathValue : `/${pathValue}`;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${protocol}//${window.location.host}${normalizedPath}`;
};
const encodeControlFrame = (payload: TerminalInputControlMessage): Uint8Array => {
const jsonBytes = textEncoder.encode(JSON.stringify(payload));
const bytes = new Uint8Array(jsonBytes.length + 1);
bytes[0] = CONTROL_TAG_JSON;
bytes.set(jsonBytes, 1);
return bytes;
};
const isWsInputSupported = (capability: TerminalInputCapability | null): boolean => {
if (!capability) return false;
const transports = capability.transports ?? [];
const supportsTransport = transports.includes('ws') || capability.preferred === 'ws';
return supportsTransport && typeof capability.ws?.path === 'string' && capability.ws.path.length > 0;
};
class TerminalInputWsManager {
private socket: WebSocket | null = null;
private socketUrl = '';
private boundSessionId: string | null = null;
private openPromise: Promise<WebSocket | null> | null = null;
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
private reconnectAttempt = 0;
private keepaliveInterval: ReturnType<typeof setInterval> | null = null;
private closed = false;
configure(socketUrl: string): void {
if (!socketUrl) return;
if (this.socketUrl === socketUrl) {
this.closed = false;
if (this.isConnectedOrConnecting()) {
return;
}
this.ensureConnected();
return;
}
this.socketUrl = socketUrl;
this.closed = false;
this.resetConnection();
this.ensureConnected();
}
async sendInput(sessionId: string, data: string): Promise<boolean> {
if (!sessionId || !data || this.closed || !this.socketUrl) {
return false;
}
const socket = await this.getOpenSocket(WS_SEND_WAIT_MS);
if (!socket || socket.readyState !== WS_READY_STATE_OPEN) {
return false;
}
try {
if (this.boundSessionId !== sessionId) {
socket.send(encodeControlFrame({ t: 'b', s: sessionId, v: 1 }));
this.boundSessionId = sessionId;
}
socket.send(data);
return true;
} catch {
this.handleSocketFailure();
return false;
}
}
unbindSession(sessionId: string): void {
if (!sessionId) return;
if (this.boundSessionId === sessionId) {
this.boundSessionId = null;
}
}
close(): void {
this.closed = true;
this.clearReconnectTimeout();
this.resetConnection();
this.socketUrl = '';
}
prime(): void {
if (this.closed || !this.socketUrl) {
return;
}
if (this.isConnectedOrConnecting()) {
return;
}
this.ensureConnected();
}
isConnectedOrConnecting(socketUrl?: string): boolean {
if (this.closed) {
return false;
}
if (socketUrl && this.socketUrl !== socketUrl) {
return false;
}
if (this.socket && (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING)) {
return true;
}
return this.openPromise !== null;
}
private sendControl(payload: TerminalInputControlMessage): boolean {
if (!this.socket || this.socket.readyState !== WS_READY_STATE_OPEN) {
return false;
}
try {
this.socket.send(encodeControlFrame(payload));
return true;
} catch {
this.handleSocketFailure();
return false;
}
}
private startKeepalive(): void {
this.stopKeepalive();
this.keepaliveInterval = setInterval(() => {
if (this.closed) {
return;
}
this.sendControl({ t: 'p', v: 1 });
}, WS_KEEPALIVE_INTERVAL_MS);
}
private stopKeepalive(): void {
if (!this.keepaliveInterval) {
return;
}
clearInterval(this.keepaliveInterval);
this.keepaliveInterval = null;
}
private scheduleReconnect(): void {
if (this.closed || !this.socketUrl || this.reconnectTimeout) {
return;
}
const baseDelay = Math.min(
WS_RECONNECT_INITIAL_DELAY_MS * Math.pow(2, this.reconnectAttempt),
WS_RECONNECT_MAX_DELAY_MS
);
const jitter = Math.floor(Math.random() * WS_RECONNECT_JITTER_MS);
const delay = baseDelay + jitter;
this.reconnectTimeout = setTimeout(() => {
this.reconnectTimeout = null;
this.reconnectAttempt += 1;
this.ensureConnected();
}, delay);
}
private clearReconnectTimeout(): void {
if (!this.reconnectTimeout) {
return;
}
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
private async getOpenSocket(waitMs: number): Promise<WebSocket | null> {
if (this.socket && this.socket.readyState === WS_READY_STATE_OPEN) {
return this.socket;
}
this.ensureConnected();
if (this.socket && this.socket.readyState === WS_READY_STATE_OPEN) {
return this.socket;
}
const opened = await Promise.race([
this.openPromise ?? Promise.resolve(null),
new Promise<null>((resolve) => {
setTimeout(() => resolve(null), waitMs);
}),
]);
if (opened && opened.readyState === WS_READY_STATE_OPEN) {
return opened;
}
if (this.socket && this.socket.readyState === WS_READY_STATE_OPEN) {
return this.socket;
}
return null;
}
private ensureConnected(): void {
if (this.closed || !this.socketUrl) {
return;
}
if (this.socket && (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING)) {
return;
}
if (this.openPromise) {
return;
}
this.clearReconnectTimeout();
this.openPromise = new Promise<WebSocket | null>((resolve) => {
let settled = false;
let connectTimeout: ReturnType<typeof setTimeout> | null = null;
const settle = (value: WebSocket | null) => {
if (settled) {
return;
}
settled = true;
if (connectTimeout) {
clearTimeout(connectTimeout);
connectTimeout = null;
}
this.openPromise = null;
resolve(value);
};
try {
const socket = new WebSocket(this.socketUrl);
socket.binaryType = 'arraybuffer';
socket.onopen = () => {
this.socket = socket;
this.reconnectAttempt = 0;
this.startKeepalive();
settle(socket);
};
socket.onmessage = (event) => {
void this.handleSocketMessage(event.data);
};
socket.onclose = () => {
if (this.socket === socket) {
this.socket = null;
this.boundSessionId = null;
this.stopKeepalive();
if (!this.closed) {
this.scheduleReconnect();
}
}
settle(null);
};
this.socket = socket;
connectTimeout = setTimeout(() => {
if (socket.readyState === WebSocket.CONNECTING) {
socket.close();
settle(null);
}
}, WS_CONNECT_TIMEOUT_MS);
} catch {
settle(null);
if (!this.closed) {
this.scheduleReconnect();
}
}
});
}
private async handleSocketMessage(messageData: unknown): Promise<void> {
const bytes = await this.asUint8Array(messageData);
if (!bytes || bytes.length < 2) {
return;
}
if (bytes[0] !== CONTROL_TAG_JSON) {
return;
}
try {
const payload = JSON.parse(textDecoder.decode(bytes.subarray(1))) as TerminalInputControlMessage;
if (payload.t === 'po') {
return;
}
if (payload.t === 'e') {
if (payload.c === 'NOT_BOUND' || payload.c === 'SESSION_NOT_FOUND') {
this.boundSessionId = null;
}
if (payload.f === true) {
this.handleSocketFailure();
}
}
} catch {
this.handleSocketFailure();
}
}
private async asUint8Array(messageData: unknown): Promise<Uint8Array | null> {
if (messageData instanceof ArrayBuffer) {
return new Uint8Array(messageData);
}
if (messageData instanceof Uint8Array) {
return messageData;
}
if (typeof Blob !== 'undefined' && messageData instanceof Blob) {
const buffer = await messageData.arrayBuffer();
return new Uint8Array(buffer);
}
return null;
}
private handleSocketFailure(): void {
this.boundSessionId = null;
this.resetConnection();
this.scheduleReconnect();
}
private resetConnection(): void {
this.openPromise = null;
this.stopKeepalive();
if (this.socket) {
const socket = this.socket;
this.socket = null;
socket.onopen = null;
socket.onmessage = null;
socket.onerror = null;
socket.onclose = null;
if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) {
socket.close();
}
}
this.boundSessionId = null;
}
}
type TerminalInputWsGlobalState = {
capability: TerminalInputCapability | null;
manager: TerminalInputWsManager | null;
};
const getTerminalInputWsGlobalState = (): TerminalInputWsGlobalState => {
const globalScope = globalThis as typeof globalThis & {
[GLOBAL_TERMINAL_INPUT_STATE_KEY]?: TerminalInputWsGlobalState;
};
if (!globalScope[GLOBAL_TERMINAL_INPUT_STATE_KEY]) {
globalScope[GLOBAL_TERMINAL_INPUT_STATE_KEY] = {
capability: null,
manager: null,
};
}
return globalScope[GLOBAL_TERMINAL_INPUT_STATE_KEY];
};
const applyTerminalInputCapability = (capability: TerminalInputCapability | undefined): void => {
const globalState = getTerminalInputWsGlobalState();
globalState.capability = capability ?? null;
if (!isWsInputSupported(globalState.capability)) {
globalState.manager?.close();
globalState.manager = null;
return;
}
const wsPath = globalState.capability?.ws?.path;
if (!wsPath) {
return;
}
const socketUrl = normalizeWebSocketPath(wsPath);
if (!socketUrl) {
return;
}
if (!globalState.manager) {
globalState.manager = new TerminalInputWsManager();
}
globalState.manager.configure(socketUrl);
};
const sendTerminalInputHttp = async (sessionId: string, data: string): Promise<void> => {
const response = await fetch(`/api/terminal/${sessionId}/input`, {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: data,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Failed to send input' }));
throw new Error(error.error || 'Failed to send terminal input');
}
};
export async function createTerminalSession(
options: CreateTerminalOptions
): Promise<TerminalSession> {
const response = await fetch('/api/terminal/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
cwd: options.cwd,
cols: options.cols || 80,
rows: options.rows || 24,
}),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Failed to create terminal' }));
throw new Error(error.error || 'Failed to create terminal session');
}
const session = await response.json() as TerminalSession;
applyTerminalInputCapability(session.capabilities?.input);
return session;
}
export function connectTerminalStream(
sessionId: string,
onEvent: (event: TerminalStreamEvent) => void,
onError?: (error: Error, fatal?: boolean) => void,
options: ConnectStreamOptions = {}
): () => void {
const {
maxRetries = 3,
initialRetryDelay = 1000,
maxRetryDelay = 8000,
connectionTimeout = 10000,
} = options;
let eventSource: EventSource | null = null;
let retryCount = 0;
let retryTimeout: ReturnType<typeof setTimeout> | null = null;
let connectionTimeoutId: ReturnType<typeof setTimeout> | null = null;
let isClosed = false;
let hasDispatchedOpen = false;
let terminalExited = false;
const clearTimeouts = () => {
if (retryTimeout) {
clearTimeout(retryTimeout);
retryTimeout = null;
}
if (connectionTimeoutId) {
clearTimeout(connectionTimeoutId);
connectionTimeoutId = null;
}
};
const cleanup = () => {
isClosed = true;
clearTimeouts();
if (eventSource) {
eventSource.close();
eventSource = null;
}
};
const connect = () => {
if (isClosed || terminalExited) {
return;
}
if (eventSource && eventSource.readyState !== EventSource.CLOSED) {
console.warn('Attempted to create duplicate EventSource, skipping');
return;
}
hasDispatchedOpen = false;
eventSource = new EventSource(`/api/terminal/${sessionId}/stream`);
connectionTimeoutId = setTimeout(() => {
if (!hasDispatchedOpen && eventSource?.readyState !== EventSource.OPEN) {
console.error('Terminal connection timeout');
eventSource?.close();
handleError(new Error('Connection timeout'), false);
}
}, connectionTimeout);
eventSource.onopen = () => {
if (hasDispatchedOpen) {
return;
}
hasDispatchedOpen = true;
retryCount = 0;
clearTimeouts();
onEvent({ type: 'connected' });
};
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as TerminalStreamEvent;
if (data.type === 'exit') {
getTerminalInputWsGlobalState().manager?.unbindSession(sessionId);
terminalExited = true;
cleanup();
}
onEvent(data);
} catch (error) {
console.error('Failed to parse terminal event:', error);
onError?.(error as Error, false);
}
};
eventSource.onerror = (error) => {
console.error('Terminal stream error:', error, 'readyState:', eventSource?.readyState);
clearTimeouts();
const isFatalError = terminalExited || eventSource?.readyState === EventSource.CLOSED;
eventSource?.close();
eventSource = null;
if (!terminalExited) {
handleError(new Error('Terminal stream connection error'), isFatalError);
}
};
};
const handleError = (error: Error, isFatal: boolean) => {
if (isClosed || terminalExited) {
return;
}
if (retryCount < maxRetries && !isFatal) {
retryCount++;
const delay = Math.min(initialRetryDelay * Math.pow(2, retryCount - 1), maxRetryDelay);
console.log(`Reconnecting to terminal stream (attempt ${retryCount}/${maxRetries}) in ${delay}ms`);
onEvent({
type: 'reconnecting',
attempt: retryCount,
maxAttempts: maxRetries,
});
retryTimeout = setTimeout(() => {
if (!isClosed && !terminalExited) {
connect();
}
}, delay);
} else {
console.error(`Terminal connection failed after ${retryCount} attempts`);
onError?.(error, true);
cleanup();
}
};
connect();
return cleanup;
}
export async function sendTerminalInput(
sessionId: string,
data: string
): Promise<void> {
const globalState = getTerminalInputWsGlobalState();
const manager = globalState.manager;
if (manager && manager.isConnectedOrConnecting()) {
const sent = await manager.sendInput(sessionId, data);
if (sent) {
return;
}
}
await sendTerminalInputHttp(sessionId, data);
}
export async function resizeTerminal(
sessionId: string,
cols: number,
rows: number
): Promise<void> {
const response = await fetch(`/api/terminal/${sessionId}/resize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cols, rows }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Failed to resize terminal' }));
throw new Error(error.error || 'Failed to resize terminal');
}
}
export async function closeTerminal(sessionId: string): Promise<void> {
getTerminalInputWsGlobalState().manager?.unbindSession(sessionId);
const response = await fetch(`/api/terminal/${sessionId}`, {
method: 'DELETE',
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Failed to close terminal' }));
throw new Error(error.error || 'Failed to close terminal');
}
}
export async function restartTerminalSession(
currentSessionId: string,
options: { cwd: string; cols?: number; rows?: number }
): Promise<TerminalSession> {
getTerminalInputWsGlobalState().manager?.unbindSession(currentSessionId);
const response = await fetch(`/api/terminal/${currentSessionId}/restart`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
cwd: options.cwd,
cols: options.cols ?? 80,
rows: options.rows ?? 24,
}),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Failed to restart terminal' }));
throw new Error(error.error || 'Failed to restart terminal');
}
const session = await response.json() as TerminalSession;
applyTerminalInputCapability(session.capabilities?.input);
return session;
}
export async function forceKillTerminal(options: {
sessionId?: string;
cwd?: string;
}): Promise<void> {
const response = await fetch('/api/terminal/force-kill', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(options),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Failed to force kill terminal' }));
throw new Error(error.error || 'Failed to force kill terminal');
}
if (options.sessionId) {
getTerminalInputWsGlobalState().manager?.unbindSession(options.sessionId);
}
}
export function disposeTerminalInputTransport(): void {
const globalState = getTerminalInputWsGlobalState();
globalState.manager?.close();
globalState.manager = null;
globalState.capability = null;
}
export function primeTerminalInputTransport(): void {
const globalState = getTerminalInputWsGlobalState();
if (globalState.capability && !isWsInputSupported(globalState.capability)) {
return;
}
const wsPath = globalState.capability?.ws?.path ?? DEFAULT_TERMINAL_INPUT_WS_PATH;
const socketUrl = normalizeWebSocketPath(wsPath);
if (!socketUrl) {
return;
}
if (!globalState.manager) {
globalState.manager = new TerminalInputWsManager();
}
if (globalState.manager.isConnectedOrConnecting(socketUrl)) {
return;
}
globalState.manager.configure(socketUrl);
globalState.manager.prime();
}

View File

@@ -1,137 +0,0 @@
import type { Ghostty } from 'ghostty-web';
export interface TerminalTheme {
background: string;
foreground: string;
cursor: string;
cursorAccent: string;
selectionBackground: string;
selectionForeground?: string;
selectionInactiveBackground?: string;
black: string;
red: string;
green: string;
yellow: string;
blue: string;
magenta: string;
cyan: string;
white: string;
brightBlack: string;
brightRed: string;
brightGreen: string;
brightYellow: string;
brightBlue: string;
brightMagenta: string;
brightCyan: string;
brightWhite: string;
}
// Default dark theme
const defaultTheme: TerminalTheme = {
background: '#1e1e1e',
foreground: '#d4d4d4',
cursor: '#ffffff',
cursorAccent: '#1e1e1e',
selectionBackground: '#264f78',
selectionForeground: '#ffffff',
selectionInactiveBackground: '#264f7850',
black: '#000000',
red: '#cd3131',
green: '#0dbc79',
yellow: '#e5e510',
blue: '#2472c8',
magenta: '#bc3fbc',
cyan: '#11a8cd',
white: '#e5e5e5',
brightBlack: '#666666',
brightRed: '#f14c4c',
brightGreen: '#23d18b',
brightYellow: '#f5f543',
brightBlue: '#3b8eea',
brightMagenta: '#d670d6',
brightCyan: '#29b8db',
brightWhite: '#ffffff',
};
export function getDefaultTheme(): TerminalTheme {
return { ...defaultTheme };
}
export function getTerminalOptions(
fontFamily: string,
fontSize: number,
theme: TerminalTheme
) {
const powerlineFallbacks =
'"JetBrainsMonoNL Nerd Font", "FiraCode Nerd Font", "Cascadia Code PL", "Fira Code", "JetBrains Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", "Courier New", monospace';
const augmentedFontFamily = `${fontFamily}, ${powerlineFallbacks}`;
return {
fontFamily: augmentedFontFamily,
fontSize,
lineHeight: 1,
cursorBlink: false,
cursorStyle: 'bar' as const,
theme,
allowTransparency: false,
scrollback: 10_000,
minimumContrastRatio: 1,
fastScrollModifier: 'shift' as const,
fastScrollSensitivity: 5,
scrollSensitivity: 3,
macOptionIsMeta: true,
macOptionClickForcesSelection: false,
rightClickSelectsWord: true,
};
}
/**
* Get terminal options for Ghostty Web terminal
*/
export function getGhosttyTerminalOptions(
fontFamily: string,
fontSize: number,
theme: TerminalTheme,
ghostty: Ghostty,
disableStdin = false
) {
const powerlineFallbacks =
'"JetBrainsMonoNL Nerd Font", "FiraCode Nerd Font", "Cascadia Code PL", "Fira Code", "JetBrains Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", "Courier New", monospace';
const augmentedFontFamily = `${fontFamily}, ${powerlineFallbacks}`;
return {
cursorBlink: false,
cursorStyle: 'bar' as const,
fontSize,
lineHeight: 1.15,
fontFamily: augmentedFontFamily,
allowTransparency: false,
theme: {
background: theme.background,
foreground: theme.foreground,
cursor: theme.cursor,
cursorAccent: theme.cursorAccent,
selectionBackground: theme.selectionBackground,
selectionForeground: theme.selectionForeground,
black: theme.black,
red: theme.red,
green: theme.green,
yellow: theme.yellow,
blue: theme.blue,
magenta: theme.magenta,
cyan: theme.cyan,
white: theme.white,
brightBlack: theme.brightBlack,
brightRed: theme.brightRed,
brightGreen: theme.brightGreen,
brightYellow: theme.brightYellow,
brightBlue: theme.brightBlue,
brightMagenta: theme.brightMagenta,
brightCyan: theme.brightCyan,
brightWhite: theme.brightWhite,
},
scrollback: 10_000,
ghostty,
disableStdin,
};
}

View File

@@ -1,73 +0,0 @@
import { type ClassValue, clsx } from 'clsx';
export function cn(...inputs: ClassValue[]): string {
return clsx(inputs);
}
export async function copyTextToClipboard(text: string): Promise<void> {
if (typeof navigator !== 'undefined' && navigator.clipboard) {
try {
await navigator.clipboard.writeText(text);
return;
} catch {
// Fallback to older method
}
}
// Fallback for older browsers or when clipboard API fails
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
textarea.style.top = '-9999px';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
try {
document.execCommand('copy');
} finally {
document.body.removeChild(textarea);
}
}
export function getSafeSessionStorage(): Storage | null {
try {
if (typeof sessionStorage !== 'undefined') {
const testKey = '__session_storage_test__';
sessionStorage.setItem(testKey, testKey);
sessionStorage.removeItem(testKey);
return sessionStorage;
}
} catch {
// Session storage not available
}
return null;
}
export function isMobileDeviceViaCSS(): boolean {
if (typeof window === 'undefined') {
return false;
}
// Alternative check using media queries
if (typeof window.matchMedia === 'function') {
return window.matchMedia('(pointer: coarse)').matches;
}
return false;
}
const PS_PROMPT_REGEX = /\(([^)]+)\)\s*PS\s+([^\n>]+)>|PS\s+([^\n>]+)>/g;
export function extractPowerShellPromptPath(data: string): string | null {
let lastMatch: RegExpMatchArray | null = null;
const regex = new RegExp(PS_PROMPT_REGEX);
let match;
while ((match = regex.exec(data)) !== null) {
lastMatch = match;
}
if (!lastMatch) return null;
const path = lastMatch[3] || lastMatch[2];
return path.trim() || null;
}

View File

@@ -1,10 +0,0 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -1,547 +0,0 @@
import { create } from 'zustand';
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
import { closeTerminal } from '../lib/terminalApi';
export interface TerminalChunk {
id: number;
data: string;
}
export type TerminalTab = {
id: string;
terminalSessionId: string | null;
label: string;
bufferChunks: TerminalChunk[];
bufferLength: number;
isConnecting: boolean;
createdAt: number;
};
export type DirectoryTerminalState = {
tabs: TerminalTab[];
activeTabId: string | null;
};
interface TerminalStore {
sessions: Map<string, DirectoryTerminalState>;
nextChunkId: number;
nextTabId: number;
hasHydrated: boolean;
ensureDirectory: (directory: string) => void;
getDirectoryState: (directory: string) => DirectoryTerminalState | undefined;
getActiveTab: (directory: string) => TerminalTab | undefined;
createTab: (directory: string) => string;
setActiveTab: (directory: string, tabId: string) => void;
setTabLabel: (directory: string, tabId: string, label: string) => void;
closeTab: (directory: string, tabId: string) => Promise<void>;
setTabSessionId: (directory: string, tabId: string, sessionId: string | null) => void;
setConnecting: (directory: string, tabId: string, isConnecting: boolean) => void;
appendToBuffer: (directory: string, tabId: string, chunk: string) => void;
clearBuffer: (directory: string, tabId: string) => void;
removeDirectory: (directory: string) => void;
clearAll: () => void;
}
const TERMINAL_BUFFER_LIMIT = 1_000_000;
const TERMINAL_STORE_NAME = 'terminal-store';
type PersistedTerminalTab = Pick<TerminalTab, 'id' | 'label' | 'terminalSessionId' | 'createdAt'>;
type PersistedDirectoryTerminalState = {
tabs: PersistedTerminalTab[];
activeTabId: string | null;
};
type PersistedTerminalStoreState = {
sessions: Array<[string, PersistedDirectoryTerminalState]>;
nextTabId: number;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
const tabIdNumber = (tabId: string): number | null => {
const match = /^tab-(\d+)$/.exec(tabId);
if (!match) return null;
const num = Number(match[1]);
return Number.isFinite(num) ? num : null;
};
function normalizeDirectory(dir: string): string {
let normalized = dir.trim();
while (normalized.length > 1 && normalized.endsWith('/')) {
normalized = normalized.slice(0, -1);
}
return normalized;
}
const createEmptyTab = (id: string, label: string): TerminalTab => ({
id,
terminalSessionId: null,
label,
bufferChunks: [],
bufferLength: 0,
isConnecting: false,
createdAt: Date.now(),
});
const createEmptyDirectoryState = (firstTab: TerminalTab): DirectoryTerminalState => ({
tabs: [firstTab],
activeTabId: firstTab.id,
});
const findTabIndex = (state: DirectoryTerminalState, tabId: string): number =>
state.tabs.findIndex((t) => t.id === tabId);
export const useTerminalStore = create<TerminalStore>()(
devtools(
persist(
(set, get) => ({
sessions: new Map(),
nextChunkId: 1,
nextTabId: 1,
hasHydrated: typeof window === 'undefined',
ensureDirectory: (directory: string) => {
const key = normalizeDirectory(directory);
if (!key) return;
set((state) => {
if (state.sessions.has(key)) {
return state;
}
const newSessions = new Map(state.sessions);
const tabId = `tab-${state.nextTabId}`;
const firstTab = createEmptyTab(tabId, 'Terminal');
newSessions.set(key, createEmptyDirectoryState(firstTab));
return { sessions: newSessions, nextTabId: state.nextTabId + 1 };
});
},
getDirectoryState: (directory: string) => {
const key = normalizeDirectory(directory);
return get().sessions.get(key);
},
getActiveTab: (directory: string) => {
const key = normalizeDirectory(directory);
const entry = get().sessions.get(key);
if (!entry) return undefined;
const activeId = entry.activeTabId;
if (!activeId) return entry.tabs[0];
return entry.tabs.find((t) => t.id === activeId) ?? entry.tabs[0];
},
createTab: (directory: string) => {
const key = normalizeDirectory(directory);
if (!key) {
return 'tab-invalid';
}
const tabId = `tab-${get().nextTabId}`;
set((state) => {
const newSessions = new Map(state.sessions);
const existing = newSessions.get(key);
const nextTabId = state.nextTabId + 1;
const labelIndex = (existing?.tabs.length ?? 0) + 1;
const label = `Terminal ${labelIndex}`;
const tab = createEmptyTab(tabId, label);
if (!existing) {
newSessions.set(key, createEmptyDirectoryState(tab));
} else {
newSessions.set(key, {
...existing,
tabs: [...existing.tabs, tab],
});
}
return { sessions: newSessions, nextTabId };
});
return tabId;
},
setActiveTab: (directory: string, tabId: string) => {
const key = normalizeDirectory(directory);
set((state) => {
const newSessions = new Map(state.sessions);
const existing = newSessions.get(key);
if (!existing) {
return state;
}
if (existing.activeTabId === tabId) {
return state;
}
if (findTabIndex(existing, tabId) < 0) {
return state;
}
newSessions.set(key, { ...existing, activeTabId: tabId });
return { sessions: newSessions };
});
},
setTabLabel: (directory: string, tabId: string, label: string) => {
const key = normalizeDirectory(directory);
const normalizedLabel = label.trim();
if (!normalizedLabel) {
return;
}
set((state) => {
const newSessions = new Map(state.sessions);
const existing = newSessions.get(key);
if (!existing) {
return state;
}
const idx = findTabIndex(existing, tabId);
if (idx < 0) {
return state;
}
if (existing.tabs[idx]?.label === normalizedLabel) {
return state;
}
const nextTabs = [...existing.tabs];
nextTabs[idx] = {
...nextTabs[idx],
label: normalizedLabel,
};
newSessions.set(key, {
...existing,
tabs: nextTabs,
});
return { sessions: newSessions };
});
},
closeTab: async (directory: string, tabId: string) => {
const key = normalizeDirectory(directory);
const entry = get().sessions.get(key);
const tab = entry?.tabs.find((t) => t.id === tabId);
const sessionId = tab?.terminalSessionId ?? null;
if (sessionId) {
try {
await closeTerminal(sessionId);
} catch {
// ignore
}
}
set((state) => {
const newSessions = new Map(state.sessions);
const existing = newSessions.get(key);
if (!existing) {
return state;
}
const idx = findTabIndex(existing, tabId);
if (idx < 0) {
return state;
}
const nextTabs = existing.tabs.filter((t) => t.id !== tabId);
if (nextTabs.length === 0) {
const newTabId = `tab-${state.nextTabId}`;
const newTab = createEmptyTab(newTabId, 'Terminal');
newSessions.set(key, createEmptyDirectoryState(newTab));
return { sessions: newSessions, nextTabId: state.nextTabId + 1 };
}
let nextActive = existing.activeTabId;
if (existing.activeTabId === tabId) {
const fallback = nextTabs[Math.min(idx, nextTabs.length - 1)];
nextActive = fallback?.id ?? nextTabs[0]?.id ?? null;
}
newSessions.set(key, {
...existing,
tabs: nextTabs,
activeTabId: nextActive,
});
return { sessions: newSessions };
});
},
setTabSessionId: (directory: string, tabId: string, sessionId: string | null) => {
const key = normalizeDirectory(directory);
set((state) => {
const newSessions = new Map(state.sessions);
const existing = newSessions.get(key);
if (!existing) {
return state;
}
const idx = findTabIndex(existing, tabId);
if (idx < 0) {
return state;
}
const tab = existing.tabs[idx];
const shouldResetBuffer = sessionId !== null && tab.terminalSessionId !== sessionId;
const nextTab: TerminalTab = {
...tab,
terminalSessionId: sessionId,
isConnecting: false,
...(shouldResetBuffer ? { bufferChunks: [], bufferLength: 0 } : {}),
};
const nextTabs = [...existing.tabs];
nextTabs[idx] = nextTab;
newSessions.set(key, { ...existing, tabs: nextTabs });
return { sessions: newSessions };
});
},
setConnecting: (directory: string, tabId: string, isConnecting: boolean) => {
const key = normalizeDirectory(directory);
set((state) => {
const newSessions = new Map(state.sessions);
const existing = newSessions.get(key);
if (!existing) {
return state;
}
const idx = findTabIndex(existing, tabId);
if (idx < 0) {
return state;
}
const nextTabs = [...existing.tabs];
nextTabs[idx] = { ...nextTabs[idx], isConnecting };
newSessions.set(key, { ...existing, tabs: nextTabs });
return { sessions: newSessions };
});
},
appendToBuffer: (directory: string, tabId: string, chunk: string) => {
if (!chunk) {
return;
}
const key = normalizeDirectory(directory);
set((state) => {
const newSessions = new Map(state.sessions);
const existing = newSessions.get(key);
if (!existing) {
return state;
}
const idx = findTabIndex(existing, tabId);
if (idx < 0) {
return state;
}
const tab = existing.tabs[idx];
const chunkId = state.nextChunkId;
const chunkEntry: TerminalChunk = { id: chunkId, data: chunk };
const bufferChunks = [...tab.bufferChunks, chunkEntry];
let bufferLength = tab.bufferLength + chunk.length;
while (bufferLength > TERMINAL_BUFFER_LIMIT && bufferChunks.length > 1) {
const removed = bufferChunks.shift();
if (!removed) {
break;
}
bufferLength -= removed.data.length;
}
const nextTabs = [...existing.tabs];
nextTabs[idx] = {
...tab,
bufferChunks,
bufferLength,
};
newSessions.set(key, { ...existing, tabs: nextTabs });
return { sessions: newSessions, nextChunkId: chunkId + 1 };
});
},
clearBuffer: (directory: string, tabId: string) => {
const key = normalizeDirectory(directory);
set((state) => {
const newSessions = new Map(state.sessions);
const existing = newSessions.get(key);
if (!existing) {
return state;
}
const idx = findTabIndex(existing, tabId);
if (idx < 0) {
return state;
}
const nextTabs = [...existing.tabs];
nextTabs[idx] = {
...nextTabs[idx],
bufferChunks: [],
bufferLength: 0,
};
newSessions.set(key, { ...existing, tabs: nextTabs });
return { sessions: newSessions };
});
},
removeDirectory: (directory: string) => {
const key = normalizeDirectory(directory);
set((state) => {
const newSessions = new Map(state.sessions);
newSessions.delete(key);
return { sessions: newSessions };
});
},
clearAll: () => {
set({ sessions: new Map(), nextChunkId: 1, nextTabId: 1 });
},
}),
{
name: TERMINAL_STORE_NAME,
storage: createJSONStorage(() => sessionStorage),
partialize: (state): PersistedTerminalStoreState => ({
sessions: Array.from(state.sessions.entries()).map(([directory, dirState]) => [
directory,
{
activeTabId: dirState.activeTabId,
tabs: dirState.tabs.map((tab) => ({
id: tab.id,
label: tab.label,
terminalSessionId: tab.terminalSessionId,
createdAt: tab.createdAt,
})),
},
]),
nextTabId: state.nextTabId,
}),
merge: (persistedState, currentState) => {
if (!isRecord(persistedState)) {
return currentState;
}
const rawSessions = Array.isArray(persistedState.sessions)
? (persistedState.sessions as PersistedTerminalStoreState['sessions'])
: [];
const sessions = new Map<string, DirectoryTerminalState>();
let maxTabNum = 0;
for (const entry of rawSessions) {
if (!Array.isArray(entry) || entry.length !== 2) {
continue;
}
const [directory, rawState] = entry as [unknown, unknown];
if (typeof directory !== 'string' || !isRecord(rawState)) {
continue;
}
const rawTabs = Array.isArray(rawState.tabs) ? (rawState.tabs as unknown[]) : [];
const tabs: TerminalTab[] = [];
for (const rawTab of rawTabs) {
if (!isRecord(rawTab)) {
continue;
}
const id = typeof rawTab.id === 'string' ? rawTab.id : null;
if (!id) {
continue;
}
const num = tabIdNumber(id);
if (num !== null) {
maxTabNum = Math.max(maxTabNum, num);
}
tabs.push({
id,
label: typeof rawTab.label === 'string' ? rawTab.label : 'Terminal',
terminalSessionId:
typeof rawTab.terminalSessionId === 'string' || rawTab.terminalSessionId === null
? (rawTab.terminalSessionId as string | null)
: null,
createdAt: typeof rawTab.createdAt === 'number' ? rawTab.createdAt : Date.now(),
bufferChunks: [],
bufferLength: 0,
isConnecting: false,
});
}
if (tabs.length === 0) {
continue;
}
const activeTabId =
typeof rawState.activeTabId === 'string' ? (rawState.activeTabId as string) : null;
const activeExists = activeTabId ? tabs.some((t) => t.id === activeTabId) : false;
sessions.set(directory, {
tabs,
activeTabId: activeExists ? activeTabId : tabs[0].id,
});
}
const persistedNextTabId =
typeof persistedState.nextTabId === 'number' && Number.isFinite(persistedState.nextTabId)
? (persistedState.nextTabId as number)
: 1;
const nextTabId = Math.max(currentState.nextTabId, persistedNextTabId, maxTabNum + 1);
return {
...currentState,
sessions,
nextChunkId: 1,
nextTabId,
hasHydrated: true,
};
},
}
)
)
);
// Ensure hydration completes even when no persisted state exists.
if (typeof window !== 'undefined') {
const persistApi = (
useTerminalStore as unknown as {
persist?: {
hasHydrated?: () => boolean;
onFinishHydration?: (cb: () => void) => (() => void) | void;
};
}
).persist;
const markHydrated = () => {
if (!useTerminalStore.getState().hasHydrated) {
useTerminalStore.setState({ hasHydrated: true });
}
};
if (persistApi?.hasHydrated?.()) {
markHydrated();
} else if (persistApi?.onFinishHydration) {
persistApi.onFinishHydration(markHydrated);
} else {
markHydrated();
}
}

View File

@@ -1,24 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}

View File

@@ -1,49 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import fs from 'node:fs'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const PORT_FILE = '.terminal-server-port'
function getTerminalApiUrl() {
if (process.env.TERMINAL_API_URL) {
return process.env.TERMINAL_API_URL
}
try {
if (fs.existsSync(PORT_FILE)) {
const port = fs.readFileSync(PORT_FILE, 'utf-8').trim()
return `http://localhost:${port}`
}
} catch {}
return 'http://localhost:3002'
}
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
base: './',
build: {
outDir: 'dist',
emptyOutDir: true,
},
define: {
'process.env': {},
global: 'globalThis',
},
server: {
proxy: {
'/api': {
target: getTerminalApiUrl(),
changeOrigin: true,
ws: true,
rewrite: (path) => path,
},
},
},
})