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:
2026-06-19 18:04:44 -04:00
parent 2ded310472
commit c61712d8c4
15 changed files with 4959 additions and 25 deletions
+137
View File
@@ -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 {}
+106
View File
@@ -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();