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 {}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import * as nodePty from 'node-pty';
|
||||
import type { IPty } from 'node-pty';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
interface PtyEntry {
|
||||
pty: IPty;
|
||||
sessionId: string;
|
||||
windowId: string;
|
||||
dataListeners: Set<(data: string) => void>;
|
||||
exitListeners: Set<() => void>;
|
||||
onDataHandler: (data: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages PTY processes for terminal sessions. Each call to getOrCreate
|
||||
* spawns `tmux attach-session -t clawtap:<windowId>` as a node-pty process,
|
||||
* giving a full bidirectional terminal stream into the existing tmux pane.
|
||||
*
|
||||
* One PTY per (sessionId, clientId) tuple — multiple clients attach
|
||||
* independently via separate tmux attach processes.
|
||||
*/
|
||||
export class PtySessionManager extends EventEmitter {
|
||||
private ptys = new Map<string, PtyEntry>(); // key = `${sessionId}:${clientId}`
|
||||
|
||||
getOrCreate(sessionId: string, windowId: string, clientId: string, cols = 80, rows = 24): PtyEntry {
|
||||
const key = `${sessionId}:${clientId}`;
|
||||
const existing = this.ptys.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const pty = nodePty.spawn('tmux', ['attach-session', '-t', `clawtap:${windowId}`], {
|
||||
name: 'xterm-256color',
|
||||
cols,
|
||||
rows,
|
||||
env: process.env as Record<string, string>,
|
||||
});
|
||||
|
||||
const entry: PtyEntry = {
|
||||
pty,
|
||||
sessionId,
|
||||
windowId,
|
||||
dataListeners: new Set(),
|
||||
exitListeners: new Set(),
|
||||
onDataHandler: () => {},
|
||||
};
|
||||
|
||||
entry.onDataHandler = (data: string) => {
|
||||
for (const cb of entry.dataListeners) cb(data);
|
||||
};
|
||||
|
||||
pty.onData(entry.onDataHandler);
|
||||
pty.onExit(() => {
|
||||
this.ptys.delete(key);
|
||||
for (const cb of entry.exitListeners) cb();
|
||||
});
|
||||
|
||||
this.ptys.set(key, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
onData(sessionId: string, clientId: string, cb: (data: string) => void): () => void {
|
||||
const key = `${sessionId}:${clientId}`;
|
||||
const entry = this.ptys.get(key);
|
||||
if (!entry) return () => {};
|
||||
entry.dataListeners.add(cb);
|
||||
return () => entry.dataListeners.delete(cb);
|
||||
}
|
||||
|
||||
onExit(sessionId: string, clientId: string, cb: () => void): () => void {
|
||||
const key = `${sessionId}:${clientId}`;
|
||||
const entry = this.ptys.get(key);
|
||||
if (!entry) return () => {};
|
||||
entry.exitListeners.add(cb);
|
||||
return () => entry.exitListeners.delete(cb);
|
||||
}
|
||||
|
||||
write(sessionId: string, clientId: string, data: string): void {
|
||||
const key = `${sessionId}:${clientId}`;
|
||||
this.ptys.get(key)?.pty.write(data);
|
||||
}
|
||||
|
||||
resize(sessionId: string, clientId: string, cols: number, rows: number): void {
|
||||
const key = `${sessionId}:${clientId}`;
|
||||
this.ptys.get(key)?.pty.resize(cols, rows);
|
||||
}
|
||||
|
||||
destroy(sessionId: string, clientId: string): void {
|
||||
const key = `${sessionId}:${clientId}`;
|
||||
const entry = this.ptys.get(key);
|
||||
if (!entry) return;
|
||||
entry.dataListeners.clear();
|
||||
entry.exitListeners.clear();
|
||||
try { entry.pty.kill(); } catch {}
|
||||
this.ptys.delete(key);
|
||||
}
|
||||
|
||||
destroyAll(): void {
|
||||
for (const [key, entry] of this.ptys) {
|
||||
entry.dataListeners.clear();
|
||||
entry.exitListeners.clear();
|
||||
try { entry.pty.kill(); } catch {}
|
||||
this.ptys.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const ptyManager = new PtySessionManager();
|
||||
Reference in New Issue
Block a user