Compare commits
2 Commits
2ded310472
...
edbecd2c83
| Author | SHA1 | Date | |
|---|---|---|---|
| edbecd2c83 | |||
| c61712d8c4 |
@@ -11,3 +11,8 @@ docs/
|
|||||||
.server.pid
|
.server.pid
|
||||||
|
|
||||||
.bkit/
|
.bkit/
|
||||||
|
|
||||||
|
# Large assets copied from webterm project — not tracked in git
|
||||||
|
public/sherpa-moonshine-v2-base-en/
|
||||||
|
public/ghostty-vt.wasm
|
||||||
|
public/fonts/
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Large Assets
|
||||||
|
|
||||||
|
`public/sherpa-moonshine-v2-base-en/` (~147MB), `public/ghostty-vt.wasm` (~416KB), and `public/fonts/` (~2.6MB) are copied from the **webterm** project and are excluded from git. Copy them manually from `../webterm/public/` when setting up.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+4
-1
@@ -47,9 +47,11 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"ghostty-web": "^0.4.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
|
"node-pty": "^1.1.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
@@ -84,6 +86,7 @@
|
|||||||
"bcrypt@6.0.0": true,
|
"bcrypt@6.0.0": true,
|
||||||
"better-sqlite3@12.10.0": true,
|
"better-sqlite3@12.10.0": true,
|
||||||
"esbuild@0.28.0": true,
|
"esbuild@0.28.0": true,
|
||||||
"esbuild@0.25.12": true
|
"esbuild@0.25.12": true,
|
||||||
|
"node-pty@1.1.0": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ export class TmuxAdapter extends EventEmitter {
|
|||||||
this._clientChecker = null;
|
this._clientChecker = null;
|
||||||
this._cleanupInterval = null;
|
this._cleanupInterval = null;
|
||||||
this._startSessionCleanup();
|
this._startSessionCleanup();
|
||||||
|
this._recoverSessions().catch((err) => console.error('[tmux] Recovery error:', err));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Set a function that checks if WS clients are connected for a session */
|
/** Set a function that checks if WS clients are connected for a session */
|
||||||
@@ -806,6 +807,48 @@ export class TmuxAdapter extends EventEmitter {
|
|||||||
|
|
||||||
// === Helpers ===
|
// === Helpers ===
|
||||||
|
|
||||||
|
private async _recoverSessions(): Promise<void> {
|
||||||
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
let windows: TmuxWindow[];
|
||||||
|
try {
|
||||||
|
windows = await tmuxManager.listWindows();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[tmux] Recovery: listWindows failed:', err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[tmux] Recovery: found ${windows.length} window(s):`, windows.map(w => `${w.name}(${w.command})`).join(', '));
|
||||||
|
const candidates = windows.filter(w => UUID_RE.test(w.name));
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
console.log('[tmux] Recovery: no UUID-named windows to recover');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(candidates.map(async (win) => {
|
||||||
|
const sessionId = win.name;
|
||||||
|
if (this.sessions.has(sessionId)) return;
|
||||||
|
|
||||||
|
// Find the JSONL file to recover real cwd and firstPrompt
|
||||||
|
let cwd = win.cwd;
|
||||||
|
let firstPrompt: string | null = null;
|
||||||
|
const jsonlPath = await this._findJsonlPath(sessionId);
|
||||||
|
if (jsonlPath) {
|
||||||
|
try {
|
||||||
|
const header = await parseSessionHeader(jsonlPath, sessionId);
|
||||||
|
if (header.cwd) cwd = header.cwd;
|
||||||
|
firstPrompt = header.firstPrompt;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = this._createSession(win.id, cwd, sessionId, 'bypassPermissions');
|
||||||
|
state.firstPrompt = firstPrompt;
|
||||||
|
this.sessions.set(sessionId, state);
|
||||||
|
this._startMonitor(sessionId, win.id);
|
||||||
|
await this._ensureWatcher(sessionId);
|
||||||
|
console.log(`[tmux] Recovered session ${sessionId} (${cwd})`);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
private _createSession(windowId: string, cwd: string, cliSessionId: string, permissionMode: string): SessionState {
|
private _createSession(windowId: string, cwd: string, cliSessionId: string, permissionMode: string): SessionState {
|
||||||
return {
|
return {
|
||||||
windowId,
|
windowId,
|
||||||
|
|||||||
+137
@@ -11,6 +11,7 @@ import {
|
|||||||
login,
|
login,
|
||||||
authMiddleware,
|
authMiddleware,
|
||||||
resetLoginAttempts,
|
resetLoginAttempts,
|
||||||
|
verifyToken,
|
||||||
} from './auth.js';
|
} from './auth.js';
|
||||||
import './adapters/init.js';
|
import './adapters/init.js';
|
||||||
import { initAll, installAllHooks, listAvailable, get as getAdapter, getAll as getAllAdapters, cleanupAll, DEFAULT_ADAPTER } from './adapters/registry.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 { loadConfig } from './config.js';
|
||||||
import type { AppConfig } from './config.js';
|
import type { AppConfig } from './config.js';
|
||||||
import { initDB, closeDB, sessionReviews, sessionAdapters, savedInstructions } from './db.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));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
@@ -35,6 +40,8 @@ import { randomUUID } from 'crypto';
|
|||||||
import { mkdirSync, writeFileSync, unlinkSync, existsSync } from 'fs';
|
import { mkdirSync, writeFileSync, unlinkSync, existsSync } from 'fs';
|
||||||
import type { Server as HttpServer } from 'http';
|
import type { Server as HttpServer } from 'http';
|
||||||
import type { Server as HttpsServer } from 'https';
|
import type { Server as HttpsServer } from 'https';
|
||||||
|
import { WebSocketServer } from 'ws';
|
||||||
|
import type WebSocket from 'ws';
|
||||||
|
|
||||||
// --- Start ---
|
// --- Start ---
|
||||||
|
|
||||||
@@ -432,6 +439,51 @@ async function start(): Promise<void> {
|
|||||||
res.json(getPendingSessions());
|
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
|
// SPA fallback
|
||||||
app.get('*path', (_req: Request, res: Response) => {
|
app.get('*path', (_req: Request, res: Response) => {
|
||||||
res.sendFile(join(distPath, 'index.html'));
|
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)
|
// Register adapter routes (before listen — routes don't depend on port)
|
||||||
initAll(app);
|
initAll(app);
|
||||||
|
|
||||||
@@ -508,6 +644,7 @@ async function start(): Promise<void> {
|
|||||||
async function shutdown(signal: string): Promise<void> {
|
async function shutdown(signal: string): Promise<void> {
|
||||||
console.log(`\n[shutdown] ${signal} received, cleaning up...`);
|
console.log(`\n[shutdown] ${signal} received, cleaning up...`);
|
||||||
await cleanupAll();
|
await cleanupAll();
|
||||||
|
ptyManager.destroyAll();
|
||||||
wsTransport.destroy();
|
wsTransport.destroy();
|
||||||
closeDB();
|
closeDB();
|
||||||
try { unlinkSync(config.paths.pid); } catch {}
|
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();
|
||||||
@@ -22,11 +22,10 @@ export class WebSocketTransport extends EventEmitter {
|
|||||||
// connection is never terminated before it has a chance to respond.
|
// connection is never terminated before it has a chance to respond.
|
||||||
private alive = new WeakMap<WebSocket, boolean>();
|
private alive = new WeakMap<WebSocket, boolean>();
|
||||||
|
|
||||||
/** Create WebSocketServer on /ws path with JWT verification and ping/pong keepalive. */
|
/** Create WebSocketServer in noServer mode. Call handleUpgrade() from your server upgrade handler. */
|
||||||
setup(server: HttpServer | HttpsServer): void {
|
setup(_server: HttpServer | HttpsServer): void {
|
||||||
this.wss = new WebSocketServer({
|
this.wss = new WebSocketServer({
|
||||||
server,
|
noServer: true,
|
||||||
path: '/ws',
|
|
||||||
verifyClient: ({ req }, cb) => {
|
verifyClient: ({ req }, cb) => {
|
||||||
const url = new URL(req.url!, `http://${req.headers.host}`);
|
const url = new URL(req.url!, `http://${req.headers.host}`);
|
||||||
const token = url.searchParams.get('token');
|
const token = url.searchParams.get('token');
|
||||||
@@ -73,6 +72,13 @@ export class WebSocketTransport extends EventEmitter {
|
|||||||
this.pingInterval.unref();
|
this.pingInterval.unref();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Route an HTTP upgrade request into this WSS (called from server's upgrade handler). */
|
||||||
|
handleUpgrade(req: import('http').IncomingMessage, socket: import('stream').Duplex, head: Buffer): void {
|
||||||
|
this.wss!.handleUpgrade(req, socket, head, (ws) => {
|
||||||
|
this.wss!.emit('connection', ws, req);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Shut down the WebSocket server and stop keepalive. */
|
/** Shut down the WebSocket server and stop keepalive. */
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
if (this.pingInterval) {
|
if (this.pingInterval) {
|
||||||
|
|||||||
+56
-2
@@ -9,6 +9,8 @@ import { SettingsView } from './components/SettingsView';
|
|||||||
import { NewChatView } from './components/NewChatView';
|
import { NewChatView } from './components/NewChatView';
|
||||||
import { OfflineView } from './components/OfflineView';
|
import { OfflineView } from './components/OfflineView';
|
||||||
import { LoadingAnimation } from './components/ui/LoadingAnimation';
|
import { LoadingAnimation } from './components/ui/LoadingAnimation';
|
||||||
|
import { ShellTerminalView } from './components/ShellTerminalView';
|
||||||
|
import { api } from './lib/api';
|
||||||
|
|
||||||
interface BeforeInstallPromptEvent extends Event {
|
interface BeforeInstallPromptEvent extends Event {
|
||||||
prompt(): Promise<void>;
|
prompt(): Promise<void>;
|
||||||
@@ -18,8 +20,9 @@ interface BeforeInstallPromptEvent extends Event {
|
|||||||
type View =
|
type View =
|
||||||
| { name: 'sessions' }
|
| { name: 'sessions' }
|
||||||
| { name: 'newchat'; cwd: string }
|
| { name: 'newchat'; cwd: string }
|
||||||
| { name: 'chat'; sessionId?: string; cwd?: string; initialPrompt?: string; adapter?: string }
|
| { name: 'chat'; sessionId?: string; cwd?: string; initialPrompt?: string; adapter?: string; initialMode?: 'chat' | 'terminal' }
|
||||||
| { name: 'settings' };
|
| { name: 'settings' }
|
||||||
|
| { name: 'shell-terminal'; sessionId: string; cwd: string; fromChat?: { sessionId?: string; cwd?: string; adapter?: string } };
|
||||||
|
|
||||||
function loadView(): View {
|
function loadView(): View {
|
||||||
try {
|
try {
|
||||||
@@ -108,6 +111,18 @@ export function App() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const openTerminal = useCallback(async (cwd: string, fromChat?: { sessionId?: string; cwd?: string; adapter?: string }) => {
|
||||||
|
try {
|
||||||
|
const { sessionId } = await api.newShellTerminal(cwd);
|
||||||
|
const v: View = { name: 'shell-terminal', sessionId, cwd, fromChat };
|
||||||
|
navigateTo(v);
|
||||||
|
setView(v);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[openTerminal]', err);
|
||||||
|
alert(`Failed to open terminal: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// PWA: Android install prompt
|
// PWA: Android install prompt
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: Event) => {
|
const handler = (e: Event) => {
|
||||||
@@ -363,7 +378,46 @@ export function App() {
|
|||||||
cwd={view.cwd}
|
cwd={view.cwd}
|
||||||
initialPrompt={view.initialPrompt}
|
initialPrompt={view.initialPrompt}
|
||||||
adapter={view.adapter}
|
adapter={view.adapter}
|
||||||
|
initialMode={view.initialMode}
|
||||||
onBack={backToSessions}
|
onBack={backToSessions}
|
||||||
|
onOpenTerminal={(cwd) => openTerminal(cwd, { sessionId: view.sessionId, cwd: view.cwd, adapter: view.adapter })}
|
||||||
|
/>
|
||||||
|
{updateBanner}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (view.name === 'shell-terminal') {
|
||||||
|
const fromChat = view.fromChat;
|
||||||
|
const goBack = fromChat
|
||||||
|
? () => {
|
||||||
|
const v: View = { name: 'chat', sessionId: fromChat.sessionId, cwd: fromChat.cwd, adapter: fromChat.adapter };
|
||||||
|
navigateTo(v);
|
||||||
|
setView(v);
|
||||||
|
}
|
||||||
|
: backToSessions;
|
||||||
|
const goToChat = fromChat
|
||||||
|
? () => {
|
||||||
|
const v: View = { name: 'chat', sessionId: fromChat.sessionId, cwd: fromChat.cwd, adapter: fromChat.adapter };
|
||||||
|
navigateTo(v);
|
||||||
|
setView(v);
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
const goToClaudeTerminal = fromChat
|
||||||
|
? () => {
|
||||||
|
const v: View = { name: 'chat', sessionId: fromChat.sessionId, cwd: fromChat.cwd, adapter: fromChat.adapter, initialMode: 'terminal' };
|
||||||
|
navigateTo(v);
|
||||||
|
setView(v);
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ShellTerminalView
|
||||||
|
sessionId={view.sessionId}
|
||||||
|
cwd={view.cwd}
|
||||||
|
onBack={goBack}
|
||||||
|
onOpenChat={goToChat}
|
||||||
|
onOpenClaudeTerminal={goToClaudeTerminal}
|
||||||
/>
|
/>
|
||||||
{updateBanner}
|
{updateBanner}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ import { patchAdapterPrefs } from '../lib/adapter-prefs';
|
|||||||
import { LoadingAnimation } from './ui/LoadingAnimation';
|
import { LoadingAnimation } from './ui/LoadingAnimation';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Badge } from './ui/badge';
|
import { Badge } from './ui/badge';
|
||||||
import { ChevronLeft, Copy, Check, X } from 'lucide-react';
|
import { ChevronLeft, Copy, Check, X, Terminal, MessageSquare, SquareTerminal } from 'lucide-react';
|
||||||
|
import { TerminalView, type TerminalViewHandle } from './TerminalView';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
|
||||||
|
|
||||||
@@ -143,18 +144,25 @@ export function ChatView({
|
|||||||
cwd,
|
cwd,
|
||||||
initialPrompt,
|
initialPrompt,
|
||||||
adapter,
|
adapter,
|
||||||
|
initialMode,
|
||||||
onBack,
|
onBack,
|
||||||
|
onOpenTerminal,
|
||||||
}: {
|
}: {
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
initialPrompt?: string;
|
initialPrompt?: string;
|
||||||
adapter?: string;
|
adapter?: string;
|
||||||
|
initialMode?: 'chat' | 'terminal';
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
|
onOpenTerminal?: (cwd: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const chatScrollRef = useRef<HTMLDivElement>(null);
|
const chatScrollRef = useRef<HTMLDivElement>(null);
|
||||||
const reviewPanelRef = useRef<ReviewPanelHandle>(null);
|
const reviewPanelRef = useRef<ReviewPanelHandle>(null);
|
||||||
const reviewRefetchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const reviewRefetchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const headerHidden = useAutoHideHeader(chatScrollRef);
|
const headerHidden = useAutoHideHeader(chatScrollRef);
|
||||||
|
const [mode, setMode] = useState<'chat' | 'terminal'>(initialMode ?? 'chat');
|
||||||
|
const [terminalEverOpened, setTerminalEverOpened] = useState(initialMode === 'terminal');
|
||||||
|
const terminalViewRef = useRef<TerminalViewHandle>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
messages, toolStatuses, streaming, pendingResponse, wsStatus, sessionId, liveStatus,
|
messages, toolStatuses, streaming, pendingResponse, wsStatus, sessionId, liveStatus,
|
||||||
@@ -502,8 +510,8 @@ export function ChatView({
|
|||||||
className="flex flex-col bg-bg relative overflow-hidden safe-top"
|
className="flex flex-col bg-bg relative overflow-hidden safe-top"
|
||||||
style={{ height: 'var(--app-height, 100dvh)' }}
|
style={{ height: 'var(--app-height, 100dvh)' }}
|
||||||
>
|
>
|
||||||
{/* Header — overlays content, slides up when scrolling */}
|
{/* Header — overlays content, slides up when scrolling (disabled in terminal mode) */}
|
||||||
<div className={`absolute top-0 left-0 right-0 flex items-center gap-2 px-4 py-3 border-b border-border bg-bg z-10 safe-top transition-transform duration-200 ${headerHidden ? '-translate-y-full' : 'translate-y-0'}`}>
|
<div className={`absolute top-0 left-0 right-0 flex items-center gap-2 px-4 py-3 border-b border-border bg-bg z-10 safe-top transition-transform duration-200 ${headerHidden && mode === 'chat' ? '-translate-y-full' : 'translate-y-0'}`}>
|
||||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||||
<ChevronLeft className="w-5 h-5" />
|
<ChevronLeft className="w-5 h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -511,9 +519,34 @@ export function ChatView({
|
|||||||
{wsStatus === 'reconnecting' && (
|
{wsStatus === 'reconnecting' && (
|
||||||
<span className="text-warning text-xs ml-auto shrink-0">Reconnecting...</span>
|
<span className="text-warning text-xs ml-auto shrink-0">Reconnecting...</span>
|
||||||
)}
|
)}
|
||||||
|
{onOpenTerminal && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => onOpenTerminal(cwd || '~')}
|
||||||
|
title="New terminal"
|
||||||
|
>
|
||||||
|
<SquareTerminal className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{mode === 'chat' ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
disabled={!sessionId && !initialSessionId}
|
||||||
|
onClick={() => { setTerminalEverOpened(true); setMode('terminal'); }}
|
||||||
|
>
|
||||||
|
<Terminal className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => { terminalViewRef.current?.closeKeyboard(); setMode('chat'); }}>
|
||||||
|
<MessageSquare className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chat body — messages, tools, input */}
|
{/* Chat body — messages, tools, input */}
|
||||||
|
<div className="flex-1 flex flex-col min-h-0" style={{ display: mode === 'chat' ? undefined : 'none' }}>
|
||||||
<ChatBody
|
<ChatBody
|
||||||
messages={messages}
|
messages={messages}
|
||||||
streaming={streaming}
|
streaming={streaming}
|
||||||
@@ -533,6 +566,28 @@ export function ChatView({
|
|||||||
scrollContainerRef={chatScrollRef}
|
scrollContainerRef={chatScrollRef}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Terminal view — lazily mounted on first open, kept alive when switching back to chat */}
|
||||||
|
{terminalEverOpened && (sessionId || initialSessionId) && (
|
||||||
|
<TerminalView
|
||||||
|
ref={terminalViewRef}
|
||||||
|
sessionId={(sessionId || initialSessionId)!}
|
||||||
|
adapter={adapter}
|
||||||
|
style={{
|
||||||
|
display: mode === 'terminal' ? undefined : 'none',
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
paddingTop: '56px', // header height
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* All overlays belong to chat mode only */}
|
||||||
|
{mode === 'chat' && <>
|
||||||
|
|
||||||
{/* Floating review panel — CSS-hidden when minimized to keep hooks alive */}
|
{/* Floating review panel — CSS-hidden when minimized to keep hooks alive */}
|
||||||
{activeReviews.length > 0 && (
|
{activeReviews.length > 0 && (
|
||||||
@@ -623,6 +678,8 @@ export function ChatView({
|
|||||||
onRespond={respondPrompt}
|
onRespond={respondPrompt}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
</>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { ChevronLeft, ChevronRight, ClipboardList, Bell, Info, ShieldCheck } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, ClipboardList, Bell, Info, ShieldCheck, Terminal } from 'lucide-react';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { AdapterIcon } from './AdapterIcon';
|
import { AdapterIcon } from './AdapterIcon';
|
||||||
import { SavedInstructionsView } from './SavedInstructionsView';
|
import { SavedInstructionsView } from './SavedInstructionsView';
|
||||||
import { AdapterSettingsSection } from './AdapterSettingsSection';
|
import { AdapterSettingsSection } from './AdapterSettingsSection';
|
||||||
import { usePushNotifications } from '@/hooks/usePushNotifications';
|
import { usePushNotifications } from '@/hooks/usePushNotifications';
|
||||||
|
import { STORAGE } from '@/lib/storage-keys';
|
||||||
|
|
||||||
interface Adapter {
|
interface Adapter {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -19,6 +20,16 @@ export function SettingsView({ onBack }: { onBack: () => void }) {
|
|||||||
const [certAvailable, setCertAvailable] = useState(false);
|
const [certAvailable, setCertAvailable] = useState(false);
|
||||||
const { supported, subscribed, subscribe, unsubscribe } = usePushNotifications();
|
const { supported, subscribed, subscribe, unsubscribe } = usePushNotifications();
|
||||||
const [toggling, setToggling] = useState(false);
|
const [toggling, setToggling] = useState(false);
|
||||||
|
const [kbRatio, setKbRatio] = useState(() => {
|
||||||
|
const stored = localStorage.getItem(STORAGE.TERMINAL_KB_RATIO);
|
||||||
|
const val = stored ? Math.round(parseFloat(stored) * 100) : 30;
|
||||||
|
return Math.min(40, Math.max(10, val));
|
||||||
|
});
|
||||||
|
const [kbFontScale, setKbFontScale] = useState(() => {
|
||||||
|
const stored = localStorage.getItem(STORAGE.TERMINAL_KB_FONT_SCALE);
|
||||||
|
const val = stored ? Math.round(parseFloat(stored) * 10) : 10;
|
||||||
|
return Math.min(20, Math.max(5, val));
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.adapters().then(setAdapters).catch(() => {});
|
api.adapters().then(setAdapters).catch(() => {});
|
||||||
@@ -136,6 +147,60 @@ export function SettingsView({ onBack }: { onBack: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Terminal keyboard height */}
|
||||||
|
<div className="w-full px-4 py-3 border-b border-border">
|
||||||
|
<div className="flex items-center mb-2">
|
||||||
|
<span className="mr-3"><Terminal className="w-5 h-5 text-text-dim" /></span>
|
||||||
|
<span className="flex-1 text-text text-sm">Terminal Keyboard Height</span>
|
||||||
|
<span className="text-text-dim text-xs font-mono">{kbRatio}%</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={10}
|
||||||
|
max={40}
|
||||||
|
step={5}
|
||||||
|
value={kbRatio}
|
||||||
|
onChange={e => {
|
||||||
|
const val = Number(e.target.value);
|
||||||
|
setKbRatio(val);
|
||||||
|
localStorage.setItem(STORAGE.TERMINAL_KB_RATIO, String(val / 100));
|
||||||
|
window.dispatchEvent(new Event('clawtap:keyboard-ratio-changed'));
|
||||||
|
}}
|
||||||
|
className="w-full accent-accent"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-xs text-text-dim mt-1">
|
||||||
|
<span>Small</span>
|
||||||
|
<span>Large</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Terminal keyboard font size */}
|
||||||
|
<div className="w-full px-4 py-3 border-b border-border">
|
||||||
|
<div className="flex items-center mb-2">
|
||||||
|
<span className="mr-3"><Terminal className="w-5 h-5 text-text-dim" /></span>
|
||||||
|
<span className="flex-1 text-text text-sm">Terminal Keyboard Font Size</span>
|
||||||
|
<span className="text-text-dim text-xs font-mono">{kbFontScale / 10}×</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={5}
|
||||||
|
max={20}
|
||||||
|
step={1}
|
||||||
|
value={kbFontScale}
|
||||||
|
onChange={e => {
|
||||||
|
const val = Number(e.target.value);
|
||||||
|
setKbFontScale(val);
|
||||||
|
localStorage.setItem(STORAGE.TERMINAL_KB_FONT_SCALE, String(val / 10));
|
||||||
|
window.dispatchEvent(new Event('clawtap:keyboard-font-scale-changed'));
|
||||||
|
}}
|
||||||
|
className="w-full accent-accent"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-xs text-text-dim mt-1">
|
||||||
|
<span>Small</span>
|
||||||
|
<span>Large</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* About */}
|
{/* About */}
|
||||||
<div className="w-full flex items-center px-4 min-h-[48px] border-b border-border">
|
<div className="w-full flex items-center px-4 min-h-[48px] border-b border-border">
|
||||||
<span className="mr-3"><Info className="w-5 h-5 text-text-dim" /></span>
|
<span className="mr-3"><Info className="w-5 h-5 text-text-dim" /></span>
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { useRef } from 'react';
|
||||||
|
import { ChevronLeft, MessageSquare, Terminal } from 'lucide-react';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { TerminalView, type TerminalViewHandle } from './TerminalView';
|
||||||
|
|
||||||
|
interface ShellTerminalViewProps {
|
||||||
|
sessionId: string;
|
||||||
|
cwd: string;
|
||||||
|
onBack: () => void;
|
||||||
|
onOpenChat?: () => void;
|
||||||
|
onOpenClaudeTerminal?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShellTerminalView({ sessionId, cwd, onBack, onOpenChat, onOpenClaudeTerminal }: ShellTerminalViewProps) {
|
||||||
|
const termRef = useRef<TerminalViewHandle>(null);
|
||||||
|
const dirName = cwd.split('/').filter(Boolean).pop() ?? cwd;
|
||||||
|
|
||||||
|
const close = () => { termRef.current?.closeKeyboard(); };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col bg-bg" style={{ height: 'var(--app-height, 100dvh)' }}>
|
||||||
|
<div className="flex items-center gap-2 px-4 py-3 border-b border-border shrink-0 safe-top">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => { close(); onBack(); }}>
|
||||||
|
<ChevronLeft className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
<span className="flex-1 text-sm font-medium text-text font-mono truncate">{dirName}</span>
|
||||||
|
<span className="text-xs text-text-dim font-mono">shell</span>
|
||||||
|
{onOpenClaudeTerminal && (
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => { close(); onOpenClaudeTerminal(); }} title="Claude terminal">
|
||||||
|
<Terminal className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{onOpenChat && (
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => { close(); onOpenChat(); }} title="Back to chat">
|
||||||
|
<MessageSquare className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<TerminalView
|
||||||
|
ref={termRef}
|
||||||
|
sessionId={sessionId}
|
||||||
|
style={{ flex: 1, minHeight: 0 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { useEffect, useRef, useImperativeHandle, forwardRef } from 'react';
|
||||||
|
import { STORAGE } from '../lib/storage-keys';
|
||||||
|
import { WebTerminal } from '../lib/terminal/WebTerminal';
|
||||||
|
import type { TerminalConfig } from '../lib/terminal/terminal-config';
|
||||||
|
|
||||||
|
interface TerminalViewProps {
|
||||||
|
sessionId: string;
|
||||||
|
adapter?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TerminalViewHandle {
|
||||||
|
closeKeyboard(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTerminalWsUrl(sessionId: string, adapter?: string): string {
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
const token = localStorage.getItem(STORAGE.TOKEN) ?? '';
|
||||||
|
const adapterParam = adapter ? `&adapter=${encodeURIComponent(adapter)}` : '';
|
||||||
|
return `${proto}://${location.host}/terminal/${encodeURIComponent(sessionId)}?token=${encodeURIComponent(token)}${adapterParam}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TERMINAL_CONFIG: TerminalConfig = {
|
||||||
|
fontSize: 14,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TerminalView = forwardRef<TerminalViewHandle, TerminalViewProps>(
|
||||||
|
function TerminalView({ sessionId, adapter, style }, ref) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const termRef = useRef<WebTerminal | null>(null);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
closeKeyboard() {
|
||||||
|
termRef.current?.closeKeyboard();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const wsUrl = buildTerminalWsUrl(sessionId, adapter);
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
WebTerminal.create(el, wsUrl, TERMINAL_CONFIG).then((t) => {
|
||||||
|
if (cancelled) {
|
||||||
|
t.dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
termRef.current = t;
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error('[TerminalView] Failed to create terminal:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
termRef.current?.dispose();
|
||||||
|
termRef.current = null;
|
||||||
|
};
|
||||||
|
}, [sessionId, adapter]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: '#000',
|
||||||
|
overflow: 'hidden',
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -170,4 +170,10 @@ export const api = {
|
|||||||
|
|
||||||
deleteInstruction: (id: string) =>
|
deleteInstruction: (id: string) =>
|
||||||
request<void>(`/api/instructions/${id}`, { method: 'DELETE' }),
|
request<void>(`/api/instructions/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
newShellTerminal: (cwd: string) =>
|
||||||
|
request<{ sessionId: string }>('/api/terminal/shell', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ cwd }),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export const STORAGE = {
|
|||||||
INSTALL_DISMISSED: 'clawtap:install-dismissed',
|
INSTALL_DISMISSED: 'clawtap:install-dismissed',
|
||||||
SESSIONS_TAB: 'clawtap:sessionsTab',
|
SESSIONS_TAB: 'clawtap:sessionsTab',
|
||||||
PUSH_PROMPTED: 'clawtap:push-prompted',
|
PUSH_PROMPTED: 'clawtap:push-prompted',
|
||||||
|
TERMINAL_KB_RATIO: 'clawtap:terminal-keyboard-ratio',
|
||||||
|
TERMINAL_KB_FONT_SCALE: 'clawtap:terminal-keyboard-font-scale',
|
||||||
adapterPrefs: (id: string) => `clawtap:adapterPrefs:${id}` as const,
|
adapterPrefs: (id: string) => `clawtap:adapterPrefs:${id}` as const,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,144 @@
|
|||||||
|
export type FrontendLogLevel = "debug" | "info" | "warn" | "error";
|
||||||
|
|
||||||
|
export interface WebtermScopedLogger {
|
||||||
|
debug: (text: string, extra?: Record<string, unknown>) => void;
|
||||||
|
info: (text: string, extra?: Record<string, unknown>) => void;
|
||||||
|
warn: (text: string, extra?: Record<string, unknown>) => void;
|
||||||
|
error: (text: string, extra?: Record<string, unknown>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
webtermClientLog?: (text: string, extra?: Record<string, unknown>) => Promise<void>;
|
||||||
|
webtermClientLogSessionID?: string;
|
||||||
|
webtermClientBuildVersion?: string;
|
||||||
|
webtermLogs?: {
|
||||||
|
bug: WebtermScopedLogger;
|
||||||
|
ui: WebtermScopedLogger;
|
||||||
|
voice: WebtermScopedLogger;
|
||||||
|
ws: WebtermScopedLogger;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FRONTEND_BUILD_VERSION = "clawtap";
|
||||||
|
|
||||||
|
function createFrontendLogSessionID(): string {
|
||||||
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
|
let out = "";
|
||||||
|
for (let i = 0; i < 24; i += 1) {
|
||||||
|
out += chars[Math.floor(Math.random() * chars.length)];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const frontendLogSessionID = createFrontendLogSessionID();
|
||||||
|
|
||||||
|
function normalizeFrontendLogValue(value: unknown): unknown {
|
||||||
|
if (value instanceof Error) {
|
||||||
|
return {
|
||||||
|
name: value.name,
|
||||||
|
message: value.message,
|
||||||
|
stack: value.stack,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((item) => normalizeFrontendLogValue(item));
|
||||||
|
}
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value as Record<string, unknown>).map(([key, item]) => [key, normalizeFrontendLogValue(item)])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeFrontendLogContext(extra: Record<string, unknown> = {}): string {
|
||||||
|
const entries = Object.entries(extra);
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.stringify(Object.fromEntries(entries.map(([key, value]) => [key, normalizeFrontendLogValue(value)])));
|
||||||
|
} catch {
|
||||||
|
return String(extra);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postFrontendLog(
|
||||||
|
level: FrontendLogLevel,
|
||||||
|
text: string,
|
||||||
|
extra: Record<string, unknown> = {}
|
||||||
|
): Promise<void> {
|
||||||
|
const message = typeof text === "string" ? text.trim() : "";
|
||||||
|
if (!message) return;
|
||||||
|
try {
|
||||||
|
await fetch("/api/client-log", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "same-origin",
|
||||||
|
body: JSON.stringify({
|
||||||
|
session_id: frontendLogSessionID,
|
||||||
|
text: message,
|
||||||
|
frontend_version: FRONTEND_BUILD_VERSION,
|
||||||
|
level,
|
||||||
|
context: serializeFrontendLogContext(extra),
|
||||||
|
}),
|
||||||
|
keepalive: true,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[webterm] Failed to post client log:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logFrontend(level: FrontendLogLevel, text: string, extra: Record<string, unknown> = {}): void {
|
||||||
|
void postFrontendLog(level, text, extra);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logFrontendDebug(text: string, extra: Record<string, unknown> = {}): void {
|
||||||
|
logFrontend("debug", text, extra);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logFrontendInfo(text: string, extra: Record<string, unknown> = {}): void {
|
||||||
|
logFrontend("info", text, extra);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logFrontendWarn(text: string, extra: Record<string, unknown> = {}): void {
|
||||||
|
logFrontend("warn", text, extra);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logFrontendError(text: string, extra: Record<string, unknown> = {}): void {
|
||||||
|
logFrontend("error", text, extra);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createScopedFrontendLogger(scope: string): WebtermScopedLogger {
|
||||||
|
const withScope = (extra: Record<string, unknown> = {}): Record<string, unknown> => ({
|
||||||
|
scope,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
debug: (text, extra = {}) => logFrontendDebug(text, withScope(extra)),
|
||||||
|
info: (text, extra = {}) => logFrontendInfo(text, withScope(extra)),
|
||||||
|
warn: (text, extra = {}) => logFrontendWarn(text, withScope(extra)),
|
||||||
|
error: (text, extra = {}) => logFrontendError(text, withScope(extra)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bugLog = createScopedFrontendLogger("bug");
|
||||||
|
export const uiLog = createScopedFrontendLogger("ui");
|
||||||
|
export const voiceLog = createScopedFrontendLogger("voice");
|
||||||
|
export const wsLog = createScopedFrontendLogger("ws");
|
||||||
|
|
||||||
|
window.webtermClientLog = (text: string, extra: Record<string, unknown> = {}) =>
|
||||||
|
postFrontendLog("info", text, extra);
|
||||||
|
window.webtermClientLogSessionID = frontendLogSessionID;
|
||||||
|
window.webtermClientBuildVersion = FRONTEND_BUILD_VERSION;
|
||||||
|
window.webtermLogs = {
|
||||||
|
bug: bugLog,
|
||||||
|
ui: uiLog,
|
||||||
|
voice: voiceLog,
|
||||||
|
ws: wsLog,
|
||||||
|
};
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
const sharedScriptLoads = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
|
/** Shared TextDecoder (stateless for UTF-8, safe to share) */
|
||||||
|
export const sharedTextDecoder = new TextDecoder();
|
||||||
|
|
||||||
|
/** Returns the path to ghostty-vt.wasm served from clawtap's public/ directory */
|
||||||
|
export function getWasmPath(): string {
|
||||||
|
return "/ghostty-vt.wasm";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the base directory for sherpa-moonshine assets (parent of the sherpa dir) */
|
||||||
|
export function getStaticJSBasePath(): string {
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadScriptOnce(src: string): Promise<void> {
|
||||||
|
const existing = sharedScriptLoads.get(src);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = new Promise<void>((resolve, reject) => {
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = src;
|
||||||
|
script.async = true;
|
||||||
|
script.onload = () => resolve();
|
||||||
|
script.onerror = () => {
|
||||||
|
sharedScriptLoads.delete(src);
|
||||||
|
reject(new Error(`Failed to load script: ${src}`));
|
||||||
|
};
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
|
||||||
|
sharedScriptLoads.set(src, promise);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSherpaStatus(status: string): string {
|
||||||
|
if (!status) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (status === "Running...") {
|
||||||
|
return "Model ready";
|
||||||
|
}
|
||||||
|
const downloadMatch = status.match(/Downloading data... \((\d+)\/(\d+)\)/);
|
||||||
|
if (!downloadMatch) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloaded = BigInt(downloadMatch[1]);
|
||||||
|
const total = BigInt(downloadMatch[2]);
|
||||||
|
const percent =
|
||||||
|
total === 0n ? 0 : Number((downloaded * 10_000n) / total) / 100;
|
||||||
|
return `Downloading ${percent.toFixed(2)}%`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { ITheme } from "ghostty-web";
|
||||||
|
|
||||||
|
import { uiLog } from "./frontend-log";
|
||||||
|
import { DEFAULT_FONT_FAMILY, THEMES } from "./terminal-themes";
|
||||||
|
|
||||||
|
export interface TerminalConfig {
|
||||||
|
fontFamily?: string;
|
||||||
|
fontSize?: number;
|
||||||
|
scrollback?: number;
|
||||||
|
theme?: ITheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse configuration from element data attributes */
|
||||||
|
export function parseConfig(element: HTMLElement): TerminalConfig {
|
||||||
|
const config: TerminalConfig = {};
|
||||||
|
|
||||||
|
if (element.dataset.fontFamily) {
|
||||||
|
let fontFamily = element.dataset.fontFamily;
|
||||||
|
// Resolve CSS variables - Canvas 2D context doesn't understand var(--name) syntax
|
||||||
|
if (fontFamily.startsWith("var(")) {
|
||||||
|
const varMatch = fontFamily.match(/var\(([^)]+)\)/);
|
||||||
|
if (varMatch) {
|
||||||
|
const varName = varMatch[1].trim();
|
||||||
|
const resolved = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
|
||||||
|
if (resolved) {
|
||||||
|
fontFamily = resolved;
|
||||||
|
} else {
|
||||||
|
uiLog.warn("CSS variable not found; using default font", { varName });
|
||||||
|
fontFamily = DEFAULT_FONT_FAMILY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
config.fontFamily = fontFamily;
|
||||||
|
}
|
||||||
|
if (element.dataset.fontSize) {
|
||||||
|
config.fontSize = parseInt(element.dataset.fontSize, 10);
|
||||||
|
}
|
||||||
|
if (element.dataset.scrollback) {
|
||||||
|
config.scrollback = parseInt(element.dataset.scrollback, 10);
|
||||||
|
}
|
||||||
|
if (element.dataset.theme) {
|
||||||
|
const themeName = element.dataset.theme.toLowerCase();
|
||||||
|
if (themeName in THEMES) {
|
||||||
|
config.theme = THEMES[themeName];
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
config.theme = JSON.parse(element.dataset.theme) as ITheme;
|
||||||
|
} catch (error) {
|
||||||
|
uiLog.warn("Unknown theme configuration", { theme: element.dataset.theme, error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
export function isMobileDevice(): boolean {
|
||||||
|
const touchPoints = navigator.maxTouchPoints || 0;
|
||||||
|
const coarsePointer =
|
||||||
|
window.matchMedia?.("(pointer: coarse)").matches ||
|
||||||
|
window.matchMedia?.("(any-pointer: coarse)").matches ||
|
||||||
|
false;
|
||||||
|
const isiPadDesktopMode =
|
||||||
|
/Macintosh/i.test(navigator.userAgent) && touchPoints > 1;
|
||||||
|
const hasTouchEvents = "ontouchstart" in window;
|
||||||
|
|
||||||
|
return (
|
||||||
|
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
||||||
|
navigator.userAgent
|
||||||
|
) ||
|
||||||
|
isiPadDesktopMode ||
|
||||||
|
touchPoints > 0 ||
|
||||||
|
hasTouchEvents ||
|
||||||
|
coarsePointer
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHIFT_KEY_MAP: Record<string, string> = {
|
||||||
|
"`": "~",
|
||||||
|
"1": "!",
|
||||||
|
"2": "@",
|
||||||
|
"3": "#",
|
||||||
|
"4": "$",
|
||||||
|
"5": "%",
|
||||||
|
"6": "^",
|
||||||
|
"7": "&",
|
||||||
|
"8": "*",
|
||||||
|
"9": "(",
|
||||||
|
"0": ")",
|
||||||
|
"-": "_",
|
||||||
|
"=": "+",
|
||||||
|
"[": "{",
|
||||||
|
"]": "}",
|
||||||
|
"\\": "|",
|
||||||
|
";": ":",
|
||||||
|
"'": "\"",
|
||||||
|
",": "<",
|
||||||
|
".": ">",
|
||||||
|
"/": "?",
|
||||||
|
};
|
||||||
|
|
||||||
|
const CTRL_KEY_MAP: Record<string, string> = {
|
||||||
|
"2": "@",
|
||||||
|
"3": "[",
|
||||||
|
"4": "\\",
|
||||||
|
"5": "]",
|
||||||
|
"6": "^",
|
||||||
|
"7": "_",
|
||||||
|
"8": "?",
|
||||||
|
};
|
||||||
|
|
||||||
|
export type VirtualKeyboardActionId =
|
||||||
|
| "mode-alpha"
|
||||||
|
| "mode-symbol"
|
||||||
|
| "toggle-voice";
|
||||||
|
|
||||||
|
export type VirtualKeyboardKey = {
|
||||||
|
kind: "char" | "modifier" | "action" | "arrow" | "space";
|
||||||
|
label: string;
|
||||||
|
modifier?: "ctrl" | "alt" | "shift" | "fn";
|
||||||
|
shiftLabel?: string;
|
||||||
|
shiftValue?: string;
|
||||||
|
value?: string;
|
||||||
|
seq?: string;
|
||||||
|
width?: number;
|
||||||
|
actionId?: VirtualKeyboardActionId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const VIRTUAL_KEYBOARD_ALPHA_LAYOUT: VirtualKeyboardKey[][] = [
|
||||||
|
[
|
||||||
|
{ kind: "char", label: "q", shiftLabel: "Q" },
|
||||||
|
{ kind: "char", label: "w", shiftLabel: "W" },
|
||||||
|
{ kind: "char", label: "e", shiftLabel: "E" },
|
||||||
|
{ kind: "char", label: "r", shiftLabel: "R" },
|
||||||
|
{ kind: "char", label: "t", shiftLabel: "T" },
|
||||||
|
{ kind: "char", label: "y", shiftLabel: "Y" },
|
||||||
|
{ kind: "char", label: "u", shiftLabel: "U" },
|
||||||
|
{ kind: "char", label: "i", shiftLabel: "I" },
|
||||||
|
{ kind: "char", label: "o", shiftLabel: "O" },
|
||||||
|
{ kind: "char", label: "p", shiftLabel: "P" },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ kind: "char", label: "a", shiftLabel: "A" },
|
||||||
|
{ kind: "char", label: "s", shiftLabel: "S" },
|
||||||
|
{ kind: "char", label: "d", shiftLabel: "D" },
|
||||||
|
{ kind: "char", label: "f", shiftLabel: "F" },
|
||||||
|
{ kind: "char", label: "g", shiftLabel: "G" },
|
||||||
|
{ kind: "char", label: "h", shiftLabel: "H" },
|
||||||
|
{ kind: "char", label: "j", shiftLabel: "J" },
|
||||||
|
{ kind: "char", label: "k", shiftLabel: "K" },
|
||||||
|
{ kind: "char", label: "l", shiftLabel: "L" },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ kind: "modifier", label: "⇧", modifier: "shift" },
|
||||||
|
{ kind: "char", label: "z", shiftLabel: "Z" },
|
||||||
|
{ kind: "char", label: "x", shiftLabel: "X" },
|
||||||
|
{ kind: "char", label: "c", shiftLabel: "C" },
|
||||||
|
{ kind: "char", label: "v", shiftLabel: "V" },
|
||||||
|
{ kind: "char", label: "b", shiftLabel: "B" },
|
||||||
|
{ kind: "char", label: "n", shiftLabel: "N" },
|
||||||
|
{ kind: "char", label: "m", shiftLabel: "M" },
|
||||||
|
{ kind: "action", label: "⌫", value: "Backspace", seq: "\x7f" },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ kind: "action", label: "123", actionId: "mode-symbol", width: 1.05 },
|
||||||
|
{ kind: "modifier", label: "Ctrl", modifier: "ctrl", width: 1.05 },
|
||||||
|
{ kind: "modifier", label: "Alt", modifier: "alt", width: 1.05 },
|
||||||
|
{ kind: "space", label: "␣", value: " ", width: 3.1 },
|
||||||
|
{ kind: "action", label: "Mic", actionId: "toggle-voice", width: 1.15 },
|
||||||
|
{ kind: "action", label: "⏎", seq: "\r", width: 1.15 },
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
export const VIRTUAL_KEYBOARD_SYMBOL_LAYOUT: VirtualKeyboardKey[][] = [
|
||||||
|
[
|
||||||
|
{ kind: "char", label: "1", value: "1" },
|
||||||
|
{ kind: "char", label: "2", value: "2" },
|
||||||
|
{ kind: "char", label: "3", value: "3" },
|
||||||
|
{ kind: "char", label: "4", value: "4" },
|
||||||
|
{ kind: "char", label: "5", value: "5" },
|
||||||
|
{ kind: "char", label: "6", value: "6" },
|
||||||
|
{ kind: "char", label: "7", value: "7" },
|
||||||
|
{ kind: "char", label: "8", value: "8" },
|
||||||
|
{ kind: "char", label: "9", value: "9" },
|
||||||
|
{ kind: "char", label: "0", value: "0" },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ kind: "char", label: "-", value: "-" },
|
||||||
|
{ kind: "char", label: "/", value: "/" },
|
||||||
|
{ kind: "char", label: ":", value: ":" },
|
||||||
|
{ kind: "char", label: ";", value: ";" },
|
||||||
|
{ kind: "char", label: "(", value: "(" },
|
||||||
|
{ kind: "char", label: ")", value: ")" },
|
||||||
|
{ kind: "char", label: "$", value: "$" },
|
||||||
|
{ kind: "char", label: "&", value: "&" },
|
||||||
|
{ kind: "char", label: "@", value: "@" },
|
||||||
|
{ kind: "char", label: "\"", value: "\"" },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ kind: "action", label: "Esc", seq: "\x1b", width: 1.35 },
|
||||||
|
{ kind: "char", label: ".", value: "." },
|
||||||
|
{ kind: "char", label: ",", value: "," },
|
||||||
|
{ kind: "char", label: "?", value: "?" },
|
||||||
|
{ kind: "char", label: "!", value: "!" },
|
||||||
|
{ kind: "char", label: "'", value: "'" },
|
||||||
|
{ kind: "char", label: "[", value: "[" },
|
||||||
|
{ kind: "char", label: "]", value: "]" },
|
||||||
|
{ kind: "action", label: "⌫", value: "Backspace", seq: "\x7f", width: 1.35 },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ kind: "action", label: "ABC", actionId: "mode-alpha", width: 1.1 },
|
||||||
|
{ kind: "modifier", label: "Ctrl", modifier: "ctrl", width: 1.1 },
|
||||||
|
{ kind: "modifier", label: "Alt", modifier: "alt", width: 1.1 },
|
||||||
|
{ kind: "space", label: "␣", value: " ", width: 4 },
|
||||||
|
{ kind: "action", label: "⏎", seq: "\r", width: 1.35 },
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
export type VirtualKeyboardKeyBounds = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
rowIndex: number;
|
||||||
|
colIndex: number;
|
||||||
|
key: VirtualKeyboardKey;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ActiveVirtualKeyboardPress = {
|
||||||
|
pointerId: number;
|
||||||
|
keyIndex: number;
|
||||||
|
key: VirtualKeyboardKeyBounds | null;
|
||||||
|
repeatTimeout?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FN_NORMAL_KEYS = [
|
||||||
|
"\x1bOP",
|
||||||
|
"\x1bOQ",
|
||||||
|
"\x1bOR",
|
||||||
|
"\x1bOS",
|
||||||
|
"\x1b[15~",
|
||||||
|
"\x1b[17~",
|
||||||
|
"\x1b[18~",
|
||||||
|
"\x1b[19~",
|
||||||
|
"\x1b[20~",
|
||||||
|
"\x1b[21~",
|
||||||
|
];
|
||||||
|
|
||||||
|
const FN_SHIFT_KEYS = [
|
||||||
|
"\x1b[23~",
|
||||||
|
"\x1b[24~",
|
||||||
|
"\x1b[25~",
|
||||||
|
"\x1b[26~",
|
||||||
|
"\x1b[28~",
|
||||||
|
"\x1b[29~",
|
||||||
|
"\x1b[31~",
|
||||||
|
"\x1b[32~",
|
||||||
|
"\x1b[33~",
|
||||||
|
"\x1b[34~",
|
||||||
|
];
|
||||||
|
|
||||||
|
function applyShiftModifier(key: string): string {
|
||||||
|
if (key.length !== 1) {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
if (key >= "a" && key <= "z") {
|
||||||
|
return key.toUpperCase();
|
||||||
|
}
|
||||||
|
return SHIFT_KEY_MAP[key] ?? key;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyCtrlModifier(key: string): string {
|
||||||
|
if (key.length !== 1) {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
const mapped = CTRL_KEY_MAP[key] ?? key;
|
||||||
|
if (mapped === "?") {
|
||||||
|
return "\x7f";
|
||||||
|
}
|
||||||
|
const code = mapped.toUpperCase().charCodeAt(0);
|
||||||
|
if (code >= 64 && code <= 95) {
|
||||||
|
return String.fromCharCode(code - 64);
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyFnModifier(key: string, useShift: boolean): string | null {
|
||||||
|
if (key.length !== 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const index = "1234567890".indexOf(key);
|
||||||
|
if (index < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return useShift ? FN_SHIFT_KEYS[index] : FN_NORMAL_KEYS[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyAltModifier(text: string): string {
|
||||||
|
if (!text || text.startsWith("\x1b")) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
return `\x1b${text}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyModifiers(
|
||||||
|
text: string,
|
||||||
|
useShift: boolean,
|
||||||
|
useCtrl: boolean,
|
||||||
|
useAlt: boolean,
|
||||||
|
useFn: boolean
|
||||||
|
): string {
|
||||||
|
if (text.length !== 1) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
if (useFn) {
|
||||||
|
const fnApplied = applyFnModifier(text, useShift);
|
||||||
|
if (fnApplied) {
|
||||||
|
return useAlt ? applyAltModifier(fnApplied) : fnApplied;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (useCtrl) {
|
||||||
|
const ctrlApplied = applyCtrlModifier(text);
|
||||||
|
if (ctrlApplied !== text) {
|
||||||
|
return useAlt ? applyAltModifier(ctrlApplied) : ctrlApplied;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (useShift) {
|
||||||
|
const shifted = applyShiftModifier(text);
|
||||||
|
return useAlt ? applyAltModifier(shifted) : shifted;
|
||||||
|
}
|
||||||
|
return useAlt ? applyAltModifier(text) : text;
|
||||||
|
}
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
import type { ITheme } from "ghostty-web";
|
||||||
|
|
||||||
|
/** Default font stack - prefers system monospace, falls back through programming fonts */
|
||||||
|
export const DEFAULT_FONT_FAMILY =
|
||||||
|
'ui-monospace, "SFMono-Regular", "FiraCode Nerd Font", "FiraMono Nerd Font", ' +
|
||||||
|
'"Fira Code", "Roboto Mono", Menlo, Monaco, Consolas, "Liberation Mono", ' +
|
||||||
|
'"DejaVu Sans Mono", "Courier New", monospace';
|
||||||
|
|
||||||
|
/** Predefined terminal themes */
|
||||||
|
export const THEMES: Record<string, ITheme> = {
|
||||||
|
// Tango - default theme (GNOME/xterm.js colors)
|
||||||
|
tango: {
|
||||||
|
background: "#000000",
|
||||||
|
foreground: "#d3d7cf",
|
||||||
|
cursor: "#d3d7cf",
|
||||||
|
cursorAccent: "#000000",
|
||||||
|
selectionBackground: "#d3d7cf",
|
||||||
|
selectionForeground: "#000000",
|
||||||
|
black: "#2e3436",
|
||||||
|
red: "#cc0000",
|
||||||
|
green: "#4e9a06",
|
||||||
|
yellow: "#c4a000",
|
||||||
|
blue: "#3465a4",
|
||||||
|
magenta: "#75507b",
|
||||||
|
cyan: "#06989a",
|
||||||
|
white: "#d3d7cf",
|
||||||
|
brightBlack: "#555753",
|
||||||
|
brightRed: "#ef2929",
|
||||||
|
brightGreen: "#8ae234",
|
||||||
|
brightYellow: "#fce94f",
|
||||||
|
brightBlue: "#729fcf",
|
||||||
|
brightMagenta: "#ad7fa8",
|
||||||
|
brightCyan: "#34e2e2",
|
||||||
|
brightWhite: "#eeeeec",
|
||||||
|
},
|
||||||
|
// Classic xterm (VGA colors, pure black background)
|
||||||
|
xterm: {
|
||||||
|
background: "#000000",
|
||||||
|
foreground: "#e5e5e5",
|
||||||
|
cursor: "#e5e5e5",
|
||||||
|
cursorAccent: "#000000",
|
||||||
|
selectionBackground: "#e5e5e5",
|
||||||
|
selectionForeground: "#000000",
|
||||||
|
black: "#000000",
|
||||||
|
red: "#cd0000",
|
||||||
|
green: "#00cd00",
|
||||||
|
yellow: "#cdcd00",
|
||||||
|
blue: "#0000cd",
|
||||||
|
magenta: "#cd00cd",
|
||||||
|
cyan: "#00cdcd",
|
||||||
|
white: "#e5e5e5",
|
||||||
|
brightBlack: "#4d4d4d",
|
||||||
|
brightRed: "#ff0000",
|
||||||
|
brightGreen: "#00ff00",
|
||||||
|
brightYellow: "#ffff00",
|
||||||
|
brightBlue: "#0000ff",
|
||||||
|
brightMagenta: "#ff00ff",
|
||||||
|
brightCyan: "#00ffff",
|
||||||
|
brightWhite: "#ffffff",
|
||||||
|
},
|
||||||
|
// Monokai Classic
|
||||||
|
monokai: {
|
||||||
|
background: "#272822",
|
||||||
|
foreground: "#f8f8f2",
|
||||||
|
cursor: "#f8f8f2",
|
||||||
|
cursorAccent: "#272822",
|
||||||
|
selectionBackground: "#75715e",
|
||||||
|
selectionForeground: "#f8f8f2",
|
||||||
|
black: "#272822",
|
||||||
|
red: "#f92672",
|
||||||
|
green: "#a6e22e",
|
||||||
|
yellow: "#f4bf75",
|
||||||
|
blue: "#66d9ef",
|
||||||
|
magenta: "#ae81ff",
|
||||||
|
cyan: "#a1efe4",
|
||||||
|
white: "#f8f8f2",
|
||||||
|
brightBlack: "#75715e",
|
||||||
|
brightRed: "#f92672",
|
||||||
|
brightGreen: "#a6e22e",
|
||||||
|
brightYellow: "#f4bf75",
|
||||||
|
brightBlue: "#66d9ef",
|
||||||
|
brightMagenta: "#ae81ff",
|
||||||
|
brightCyan: "#a1efe4",
|
||||||
|
brightWhite: "#f9f8f5",
|
||||||
|
},
|
||||||
|
"monokai-pro": {
|
||||||
|
background: "#2d2a2e",
|
||||||
|
foreground: "#fcfcfa",
|
||||||
|
cursor: "#fcfcfa",
|
||||||
|
cursorAccent: "#2d2a2e",
|
||||||
|
selectionBackground: "#5b595c",
|
||||||
|
selectionForeground: "#fcfcfa",
|
||||||
|
black: "#403e41",
|
||||||
|
red: "#ff6188",
|
||||||
|
green: "#a9dc76",
|
||||||
|
yellow: "#ffd866",
|
||||||
|
blue: "#78dce8",
|
||||||
|
magenta: "#ab9df2",
|
||||||
|
cyan: "#78dce8",
|
||||||
|
white: "#fcfcfa",
|
||||||
|
brightBlack: "#727072",
|
||||||
|
brightRed: "#ff6188",
|
||||||
|
brightGreen: "#a9dc76",
|
||||||
|
brightYellow: "#ffd866",
|
||||||
|
brightBlue: "#78dce8",
|
||||||
|
brightMagenta: "#ab9df2",
|
||||||
|
brightCyan: "#78dce8",
|
||||||
|
brightWhite: "#ffffff",
|
||||||
|
},
|
||||||
|
ristretto: {
|
||||||
|
background: "#2c2c2c",
|
||||||
|
foreground: "#eeeeee",
|
||||||
|
cursor: "#eeeeee",
|
||||||
|
cursorAccent: "#2c2c2c",
|
||||||
|
selectionBackground: "#7f7f7f",
|
||||||
|
selectionForeground: "#eeeeee",
|
||||||
|
black: "#2c2c2c",
|
||||||
|
red: "#cdaf95",
|
||||||
|
green: "#a8ff60",
|
||||||
|
yellow: "#bfbb1f",
|
||||||
|
blue: "#75a5b0",
|
||||||
|
magenta: "#ff73fd",
|
||||||
|
cyan: "#5ffdff",
|
||||||
|
white: "#b9b9b9",
|
||||||
|
brightBlack: "#545454",
|
||||||
|
brightRed: "#fcb08f",
|
||||||
|
brightGreen: "#a8ff60",
|
||||||
|
brightYellow: "#fffeb7",
|
||||||
|
brightBlue: "#a5c7ff",
|
||||||
|
brightMagenta: "#ff9cfe",
|
||||||
|
brightCyan: "#d5ffff",
|
||||||
|
brightWhite: "#ffffff",
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
background: "#1e1e1e",
|
||||||
|
foreground: "#d4d4d4",
|
||||||
|
cursor: "#d4d4d4",
|
||||||
|
cursorAccent: "#1e1e1e",
|
||||||
|
selectionBackground: "#264f78",
|
||||||
|
selectionForeground: "#d4d4d4",
|
||||||
|
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: "#e5e5e5",
|
||||||
|
},
|
||||||
|
light: {
|
||||||
|
background: "#ffffff",
|
||||||
|
foreground: "#000000",
|
||||||
|
cursor: "#000000",
|
||||||
|
cursorAccent: "#ffffff",
|
||||||
|
selectionBackground: "#add6ff",
|
||||||
|
selectionForeground: "#000000",
|
||||||
|
black: "#000000",
|
||||||
|
red: "#cd3131",
|
||||||
|
green: "#00bc00",
|
||||||
|
yellow: "#949800",
|
||||||
|
blue: "#0451a5",
|
||||||
|
magenta: "#bc05bc",
|
||||||
|
cyan: "#0598bc",
|
||||||
|
white: "#555555",
|
||||||
|
brightBlack: "#666666",
|
||||||
|
brightRed: "#cd3131",
|
||||||
|
brightGreen: "#14ce14",
|
||||||
|
brightYellow: "#b5ba00",
|
||||||
|
brightBlue: "#0451a5",
|
||||||
|
brightMagenta: "#bc05bc",
|
||||||
|
brightCyan: "#0598bc",
|
||||||
|
brightWhite: "#a5a5a5",
|
||||||
|
},
|
||||||
|
dracula: {
|
||||||
|
background: "#282a36",
|
||||||
|
foreground: "#f8f8f2",
|
||||||
|
cursor: "#f8f8f2",
|
||||||
|
cursorAccent: "#282a36",
|
||||||
|
selectionBackground: "#44475a",
|
||||||
|
selectionForeground: "#f8f8f2",
|
||||||
|
black: "#21222c",
|
||||||
|
red: "#ff5555",
|
||||||
|
green: "#50fa7b",
|
||||||
|
yellow: "#f1fa8c",
|
||||||
|
blue: "#bd93f9",
|
||||||
|
magenta: "#ff79c6",
|
||||||
|
cyan: "#8be9fd",
|
||||||
|
white: "#f8f8f2",
|
||||||
|
brightBlack: "#6272a4",
|
||||||
|
brightRed: "#ff6e6e",
|
||||||
|
brightGreen: "#69ff94",
|
||||||
|
brightYellow: "#ffffa5",
|
||||||
|
brightBlue: "#d6acff",
|
||||||
|
brightMagenta: "#ff92df",
|
||||||
|
brightCyan: "#a4ffff",
|
||||||
|
brightWhite: "#ffffff",
|
||||||
|
},
|
||||||
|
catppuccin: {
|
||||||
|
background: "#1e1e2e",
|
||||||
|
foreground: "#cdd6f4",
|
||||||
|
cursor: "#f5e0dc",
|
||||||
|
cursorAccent: "#1e1e2e",
|
||||||
|
selectionBackground: "#585b70",
|
||||||
|
selectionForeground: "#cdd6f4",
|
||||||
|
black: "#45475a",
|
||||||
|
red: "#f38ba8",
|
||||||
|
green: "#a6e3a1",
|
||||||
|
yellow: "#f9e2af",
|
||||||
|
blue: "#89b4fa",
|
||||||
|
magenta: "#f5c2e7",
|
||||||
|
cyan: "#94e2d5",
|
||||||
|
white: "#bac2de",
|
||||||
|
brightBlack: "#585b70",
|
||||||
|
brightRed: "#f38ba8",
|
||||||
|
brightGreen: "#a6e3a1",
|
||||||
|
brightYellow: "#f9e2af",
|
||||||
|
brightBlue: "#89b4fa",
|
||||||
|
brightMagenta: "#f5c2e7",
|
||||||
|
brightCyan: "#94e2d5",
|
||||||
|
brightWhite: "#a6adc8",
|
||||||
|
},
|
||||||
|
nord: {
|
||||||
|
background: "#2e3440",
|
||||||
|
foreground: "#d8dee9",
|
||||||
|
cursor: "#d8dee9",
|
||||||
|
cursorAccent: "#2e3440",
|
||||||
|
selectionBackground: "#4c566a",
|
||||||
|
selectionForeground: "#eceff4",
|
||||||
|
black: "#3b4252",
|
||||||
|
red: "#bf616a",
|
||||||
|
green: "#a3be8c",
|
||||||
|
yellow: "#ebcb8b",
|
||||||
|
blue: "#81a1c1",
|
||||||
|
magenta: "#b48ead",
|
||||||
|
cyan: "#88c0d0",
|
||||||
|
white: "#e5e9f0",
|
||||||
|
brightBlack: "#4c566a",
|
||||||
|
brightRed: "#bf616a",
|
||||||
|
brightGreen: "#a3be8c",
|
||||||
|
brightYellow: "#ebcb8b",
|
||||||
|
brightBlue: "#81a1c1",
|
||||||
|
brightMagenta: "#b48ead",
|
||||||
|
brightCyan: "#8fbcbb",
|
||||||
|
brightWhite: "#eceff4",
|
||||||
|
},
|
||||||
|
gruvbox: {
|
||||||
|
background: "#282828",
|
||||||
|
foreground: "#ebdbb2",
|
||||||
|
cursor: "#ebdbb2",
|
||||||
|
cursorAccent: "#282828",
|
||||||
|
selectionBackground: "#504945",
|
||||||
|
selectionForeground: "#fbf1c7",
|
||||||
|
black: "#282828",
|
||||||
|
red: "#cc241d",
|
||||||
|
green: "#98971a",
|
||||||
|
yellow: "#d79921",
|
||||||
|
blue: "#458588",
|
||||||
|
magenta: "#b16286",
|
||||||
|
cyan: "#689d6a",
|
||||||
|
white: "#a89984",
|
||||||
|
brightBlack: "#928374",
|
||||||
|
brightRed: "#fb4934",
|
||||||
|
brightGreen: "#b8bb26",
|
||||||
|
brightYellow: "#fabd2f",
|
||||||
|
brightBlue: "#83a598",
|
||||||
|
brightMagenta: "#d3869b",
|
||||||
|
brightCyan: "#8ec07c",
|
||||||
|
brightWhite: "#ebdbb2",
|
||||||
|
},
|
||||||
|
solarized: {
|
||||||
|
background: "#002b36",
|
||||||
|
foreground: "#839496",
|
||||||
|
cursor: "#93a1a1",
|
||||||
|
cursorAccent: "#002b36",
|
||||||
|
selectionBackground: "#073642",
|
||||||
|
selectionForeground: "#93a1a1",
|
||||||
|
black: "#073642",
|
||||||
|
red: "#dc322f",
|
||||||
|
green: "#859900",
|
||||||
|
yellow: "#b58900",
|
||||||
|
blue: "#268bd2",
|
||||||
|
magenta: "#d33682",
|
||||||
|
cyan: "#2aa198",
|
||||||
|
white: "#eee8d5",
|
||||||
|
brightBlack: "#002b36",
|
||||||
|
brightRed: "#cb4b16",
|
||||||
|
brightGreen: "#586e75",
|
||||||
|
brightYellow: "#657b83",
|
||||||
|
brightBlue: "#839496",
|
||||||
|
brightMagenta: "#6c71c4",
|
||||||
|
brightCyan: "#93a1a1",
|
||||||
|
brightWhite: "#fdf6e3",
|
||||||
|
},
|
||||||
|
tokyo: {
|
||||||
|
background: "#1a1b26",
|
||||||
|
foreground: "#c0caf5",
|
||||||
|
cursor: "#c0caf5",
|
||||||
|
cursorAccent: "#1a1b26",
|
||||||
|
selectionBackground: "#33467c",
|
||||||
|
selectionForeground: "#c0caf5",
|
||||||
|
black: "#15161e",
|
||||||
|
red: "#f7768e",
|
||||||
|
green: "#9ece6a",
|
||||||
|
yellow: "#e0af68",
|
||||||
|
blue: "#7aa2f7",
|
||||||
|
magenta: "#bb9af7",
|
||||||
|
cyan: "#7dcfff",
|
||||||
|
white: "#a9b1d6",
|
||||||
|
brightBlack: "#414868",
|
||||||
|
brightRed: "#f7768e",
|
||||||
|
brightGreen: "#9ece6a",
|
||||||
|
brightYellow: "#e0af68",
|
||||||
|
brightBlue: "#7aa2f7",
|
||||||
|
brightMagenta: "#bb9af7",
|
||||||
|
brightCyan: "#7dcfff",
|
||||||
|
brightWhite: "#c0caf5",
|
||||||
|
},
|
||||||
|
miasma: {
|
||||||
|
background: "#222222",
|
||||||
|
foreground: "#c2c2b0",
|
||||||
|
cursor: "#c2c2b0",
|
||||||
|
cursorAccent: "#222222",
|
||||||
|
selectionBackground: "#5f5f5f",
|
||||||
|
selectionForeground: "#ffffff",
|
||||||
|
black: "#222222",
|
||||||
|
red: "#685742",
|
||||||
|
green: "#5f875f",
|
||||||
|
yellow: "#b36d43",
|
||||||
|
blue: "#78824b",
|
||||||
|
magenta: "#bb7744",
|
||||||
|
cyan: "#c9a554",
|
||||||
|
white: "#c2c2b0",
|
||||||
|
brightBlack: "#666666",
|
||||||
|
brightRed: "#685742",
|
||||||
|
brightGreen: "#5f875f",
|
||||||
|
brightYellow: "#b36d43",
|
||||||
|
brightBlue: "#78824b",
|
||||||
|
brightMagenta: "#bb7744",
|
||||||
|
brightCyan: "#c9a554",
|
||||||
|
brightWhite: "#d7d7c7",
|
||||||
|
},
|
||||||
|
github: {
|
||||||
|
background: "#0d1117",
|
||||||
|
foreground: "#c9d1d9",
|
||||||
|
cursor: "#c9d1d9",
|
||||||
|
cursorAccent: "#0d1117",
|
||||||
|
selectionBackground: "#264f78",
|
||||||
|
selectionForeground: "#c9d1d9",
|
||||||
|
black: "#484f58",
|
||||||
|
red: "#ff7b72",
|
||||||
|
green: "#7ee787",
|
||||||
|
yellow: "#d29922",
|
||||||
|
blue: "#58a6ff",
|
||||||
|
magenta: "#bc8cff",
|
||||||
|
cyan: "#39c5cf",
|
||||||
|
white: "#b1bac4",
|
||||||
|
brightBlack: "#6e7681",
|
||||||
|
brightRed: "#ffa198",
|
||||||
|
brightGreen: "#56d364",
|
||||||
|
brightYellow: "#e3b341",
|
||||||
|
brightBlue: "#79c0ff",
|
||||||
|
brightMagenta: "#d2a8ff",
|
||||||
|
brightCyan: "#56d4dd",
|
||||||
|
brightWhite: "#f0f6fc",
|
||||||
|
},
|
||||||
|
gotham: {
|
||||||
|
background: "#0a0f14",
|
||||||
|
foreground: "#98d1ce",
|
||||||
|
cursor: "#98d1ce",
|
||||||
|
cursorAccent: "#0a0f14",
|
||||||
|
selectionBackground: "#1f2233",
|
||||||
|
selectionForeground: "#98d1ce",
|
||||||
|
black: "#0a0f14",
|
||||||
|
red: "#c33027",
|
||||||
|
green: "#26a98b",
|
||||||
|
yellow: "#edb54b",
|
||||||
|
blue: "#195465",
|
||||||
|
magenta: "#4e5165",
|
||||||
|
cyan: "#33859e",
|
||||||
|
white: "#98d1ce",
|
||||||
|
brightBlack: "#10151b",
|
||||||
|
brightRed: "#d26939",
|
||||||
|
brightGreen: "#081f2d",
|
||||||
|
brightYellow: "#245361",
|
||||||
|
brightBlue: "#093748",
|
||||||
|
brightMagenta: "#888ba5",
|
||||||
|
brightCyan: "#599caa",
|
||||||
|
brightWhite: "#d3ebe9",
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
/// <reference lib="webworker" />
|
/// <reference lib="webworker" />
|
||||||
import { precacheAndRoute } from 'workbox-precaching';
|
import { precacheAndRoute } from 'workbox-precaching';
|
||||||
import { registerRoute } from 'workbox-routing';
|
import { registerRoute } from 'workbox-routing';
|
||||||
import { NetworkFirst } from 'workbox-strategies';
|
import { NetworkFirst, CacheFirst } from 'workbox-strategies';
|
||||||
|
|
||||||
declare const self: ServiceWorkerGlobalScope;
|
declare const self: ServiceWorkerGlobalScope;
|
||||||
|
|
||||||
@@ -31,6 +31,12 @@ registerRoute(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Sherpa voice model assets — large (~147MB total), cached on first voice use
|
||||||
|
registerRoute(
|
||||||
|
({ url }) => url.pathname.startsWith('/sherpa-moonshine-v2-base-en/'),
|
||||||
|
new CacheFirst({ cacheName: 'terminal-voice-assets' })
|
||||||
|
);
|
||||||
|
|
||||||
// Push notification handler — server already filters by clientCount,
|
// Push notification handler — server already filters by clientCount,
|
||||||
// so we always show the notification if one is received.
|
// so we always show the notification if one is received.
|
||||||
self.addEventListener('push', (event) => {
|
self.addEventListener('push', (event) => {
|
||||||
|
|||||||
+14
-1
@@ -39,7 +39,9 @@ export default defineConfig({
|
|||||||
categories: ['developer-tools', 'productivity'],
|
categories: ['developer-tools', 'productivity'],
|
||||||
},
|
},
|
||||||
injectManifest: {
|
injectManifest: {
|
||||||
globPatterns: ['**/*.{js,css,html,ico,svg,woff2}', 'favicon-*.png', 'apple-touch-icon.png', 'badge-*.png', 'mascot/*.png'],
|
globPatterns: ['**/*.{js,css,html,ico,svg,woff2,wasm,ttf}', 'favicon-*.png', 'apple-touch-icon.png', 'badge-*.png', 'mascot/*.png'],
|
||||||
|
globIgnores: ['sherpa-moonshine-v2-base-en/**'],
|
||||||
|
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, // 5 MiB (font is ~2.6 MiB)
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
@@ -60,9 +62,20 @@ export default defineConfig({
|
|||||||
ws: true,
|
ws: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
},
|
},
|
||||||
|
'/terminal': {
|
||||||
|
target: 'wss://localhost:3456',
|
||||||
|
ws: true,
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
|
rollupOptions: {
|
||||||
|
external: ['node-pty'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
exclude: ['node-pty'],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user