feat: shell terminal, virtual keyboard fixes, settings font slider
- Add shell terminal: new tmux window spawned at project cwd, opened from the Terminal icon in the ChatView header; ShellTerminalView lets you navigate back to chat or jump to the Claude tmux terminal mode - Fix system keyboard appearing on first terminal launch: ghostty-web calls focus() on a contenteditable div inside open(), triggering iOS keyboard before we can suppress it; patch HTMLElement.prototype.focus as a no-op for the synchronous duration of terminal.open() - Add keyboard font-size slider in Settings - Fix virtual keyboard button corner radius and depth scaling with key height - Fix keybar gap/padding proportionality via heightScale factor - Cap keybar font size so it does not grow when bar height is increased Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+137
@@ -11,6 +11,7 @@ import {
|
||||
login,
|
||||
authMiddleware,
|
||||
resetLoginAttempts,
|
||||
verifyToken,
|
||||
} from './auth.js';
|
||||
import './adapters/init.js';
|
||||
import { initAll, installAllHooks, listAvailable, get as getAdapter, getAll as getAllAdapters, cleanupAll, DEFAULT_ADAPTER } from './adapters/registry.js';
|
||||
@@ -27,6 +28,10 @@ import { WebSocketTransport } from './transport/websocket-transport.js';
|
||||
import { loadConfig } from './config.js';
|
||||
import type { AppConfig } from './config.js';
|
||||
import { initDB, closeDB, sessionReviews, sessionAdapters, savedInstructions } from './db.js';
|
||||
import { ptyManager } from './terminal-session.js';
|
||||
import { tmuxManager } from './adapters/shared/tmux-manager.js';
|
||||
|
||||
const shellSessions = new Map<string, { windowId: string }>();
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -35,6 +40,8 @@ import { randomUUID } from 'crypto';
|
||||
import { mkdirSync, writeFileSync, unlinkSync, existsSync } from 'fs';
|
||||
import type { Server as HttpServer } from 'http';
|
||||
import type { Server as HttpsServer } from 'https';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import type WebSocket from 'ws';
|
||||
|
||||
// --- Start ---
|
||||
|
||||
@@ -432,6 +439,51 @@ async function start(): Promise<void> {
|
||||
res.json(getPendingSessions());
|
||||
});
|
||||
|
||||
// --- Shell Terminal API ---
|
||||
|
||||
app.post('/api/terminal/shell', authMiddleware, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { cwd: rawCwd } = req.body as { cwd?: string };
|
||||
const cwd = rawCwd && rawCwd !== '~'
|
||||
? rawCwd
|
||||
: homedir();
|
||||
const sessionId = randomUUID();
|
||||
const name = `shell-${sessionId.slice(0, 6)}`;
|
||||
const shell = process.env.SHELL || 'bash';
|
||||
const windowId = await tmuxManager.createWindow(name, cwd, shell);
|
||||
shellSessions.set(sessionId, { windowId });
|
||||
res.json({ sessionId });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/terminal/shell/:id', authMiddleware, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const entry = shellSessions.get(req.params.id as string);
|
||||
if (entry) {
|
||||
await tmuxManager.killWindow(entry.windowId);
|
||||
shellSessions.delete(req.params.id as string);
|
||||
}
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Terminal PTY Info ---
|
||||
|
||||
app.get('/api/sessions/:id/terminal-info', authMiddleware, (req: Request, res: Response) => {
|
||||
const sessionId = req.params.id as string;
|
||||
const { adapter: adapterName } = req.query as { adapter?: string };
|
||||
const adapter = getAdapter(adapterName || DEFAULT_ADAPTER);
|
||||
const session = adapter?.getSession(sessionId) as { windowId?: string } | null;
|
||||
if (!session?.windowId) {
|
||||
return res.status(404).json({ error: 'No active tmux window for this session' });
|
||||
}
|
||||
res.json({ windowId: session.windowId });
|
||||
});
|
||||
|
||||
// SPA fallback
|
||||
app.get('*path', (_req: Request, res: Response) => {
|
||||
res.sendFile(join(distPath, 'index.html'));
|
||||
@@ -460,6 +512,90 @@ async function start(): Promise<void> {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Terminal WebSocket server (/terminal/:sessionId) ---
|
||||
const terminalWss = new WebSocketServer({ noServer: true });
|
||||
|
||||
terminalWss.on('connection', (ws: WebSocket, sessionId: string, windowId: string, clientId: string) => {
|
||||
const entry = ptyManager.getOrCreate(sessionId, windowId, clientId);
|
||||
|
||||
const offData = ptyManager.onData(sessionId, clientId, (data: string) => {
|
||||
if (ws.readyState === 1) {
|
||||
ws.send(JSON.stringify(['stdout', data]));
|
||||
}
|
||||
});
|
||||
|
||||
const offExit = ptyManager.onExit(sessionId, clientId, () => {
|
||||
ws.close(1001, 'PTY exited');
|
||||
});
|
||||
|
||||
ws.on('message', (raw: Buffer | ArrayBuffer | Buffer[]) => {
|
||||
let msg: unknown;
|
||||
try { msg = JSON.parse(raw.toString()); } catch { return; }
|
||||
if (!Array.isArray(msg) || msg.length < 2) return;
|
||||
const [type, payload] = msg as [string, unknown];
|
||||
if (type === 'stdin' && typeof payload === 'string') {
|
||||
ptyManager.write(sessionId, clientId, payload);
|
||||
} else if (type === 'resize' && payload && typeof payload === 'object') {
|
||||
const { width, height } = payload as { width?: number; height?: number };
|
||||
if (width && height) ptyManager.resize(sessionId, clientId, width, height);
|
||||
} else if (type === 'ping') {
|
||||
if (ws.readyState === 1) ws.send(JSON.stringify(['pong', payload]));
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
offData();
|
||||
offExit();
|
||||
ptyManager.destroy(sessionId, clientId);
|
||||
});
|
||||
|
||||
ws.on('error', () => ws.close());
|
||||
});
|
||||
|
||||
// Central upgrade router — ws library with { server } destroys sockets for non-matching paths,
|
||||
// so we use noServer on all WSS instances and route manually here.
|
||||
server.on('upgrade', (req, socket, head) => {
|
||||
const url = new URL(req.url!, `http://${req.headers.host}`);
|
||||
const pathname = url.pathname;
|
||||
|
||||
if (pathname === '/ws') {
|
||||
wsTransport.handleUpgrade(req, socket, head);
|
||||
return;
|
||||
}
|
||||
|
||||
const terminalMatch = pathname.match(/^\/terminal\/([^/]+)$/);
|
||||
if (terminalMatch) {
|
||||
const sessionId = terminalMatch[1];
|
||||
const token = url.searchParams.get('token');
|
||||
if (!token || !verifyToken(token)) {
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const adapterName = url.searchParams.get('adapter') ?? undefined;
|
||||
const adapter = getAdapter(adapterName || DEFAULT_ADAPTER);
|
||||
const session = adapter?.getSession(sessionId) as { windowId?: string } | null;
|
||||
const adapterWindowId = session?.windowId;
|
||||
const shellWindowId = !adapterWindowId ? shellSessions.get(sessionId)?.windowId : undefined;
|
||||
const windowId = adapterWindowId ?? shellWindowId;
|
||||
if (!windowId) {
|
||||
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const clientId = randomUUID();
|
||||
terminalWss.handleUpgrade(req, socket, head, (ws) => {
|
||||
terminalWss.emit('connection', ws, sessionId, windowId, clientId);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Unknown path — reject
|
||||
socket.destroy();
|
||||
});
|
||||
|
||||
// Register adapter routes (before listen — routes don't depend on port)
|
||||
initAll(app);
|
||||
|
||||
@@ -508,6 +644,7 @@ async function start(): Promise<void> {
|
||||
async function shutdown(signal: string): Promise<void> {
|
||||
console.log(`\n[shutdown] ${signal} received, cleaning up...`);
|
||||
await cleanupAll();
|
||||
ptyManager.destroyAll();
|
||||
wsTransport.destroy();
|
||||
closeDB();
|
||||
try { unlinkSync(config.paths.pid); } catch {}
|
||||
|
||||
Reference in New Issue
Block a user