Compare commits

...

2 Commits

Author SHA1 Message Date
izackp edbecd2c83 Add Ghostty terminal, session recovery, voice asset caching, and gitignore large assets
Integrates ghostty-web and node-pty packages, adds tmux session recovery on startup,
refactors WebSocket transport to noServer mode, caches Sherpa voice model assets via
service worker. Large assets (sherpa, ghostty-vt.wasm, fonts) are now gitignored as
they are copied from the webterm project.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 08:39:44 -04:00
izackp c61712d8c4 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>
2026-06-19 18:04:44 -04:00
22 changed files with 5046 additions and 32 deletions
+5
View File
@@ -11,3 +11,8 @@ docs/
.server.pid
.bkit/
# Large assets copied from webterm project — not tracked in git
public/sherpa-moonshine-v2-base-en/
public/ghostty-vt.wasm
public/fonts/
+4
View File
@@ -2,6 +2,10 @@
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
```bash
+4 -1
View File
@@ -47,9 +47,11 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"express": "^5.1.0",
"ghostty-web": "^0.4.0",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.577.0",
"multer": "^2.1.1",
"node-pty": "^1.1.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-markdown": "^10.1.0",
@@ -84,6 +86,7 @@
"bcrypt@6.0.0": true,
"better-sqlite3@12.10.0": true,
"esbuild@0.28.0": true,
"esbuild@0.25.12": true
"esbuild@0.25.12": true,
"node-pty@1.1.0": true
}
}
+43
View File
@@ -104,6 +104,7 @@ export class TmuxAdapter extends EventEmitter {
this._clientChecker = null;
this._cleanupInterval = null;
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 */
@@ -806,6 +807,48 @@ export class TmuxAdapter extends EventEmitter {
// === 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 {
return {
windowId,
+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();
+10 -4
View File
@@ -22,11 +22,10 @@ export class WebSocketTransport extends EventEmitter {
// connection is never terminated before it has a chance to respond.
private alive = new WeakMap<WebSocket, boolean>();
/** Create WebSocketServer on /ws path with JWT verification and ping/pong keepalive. */
setup(server: HttpServer | HttpsServer): void {
/** Create WebSocketServer in noServer mode. Call handleUpgrade() from your server upgrade handler. */
setup(_server: HttpServer | HttpsServer): void {
this.wss = new WebSocketServer({
server,
path: '/ws',
noServer: true,
verifyClient: ({ req }, cb) => {
const url = new URL(req.url!, `http://${req.headers.host}`);
const token = url.searchParams.get('token');
@@ -73,6 +72,13 @@ export class WebSocketTransport extends EventEmitter {
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. */
destroy(): void {
if (this.pingInterval) {
+56 -2
View File
@@ -9,6 +9,8 @@ import { SettingsView } from './components/SettingsView';
import { NewChatView } from './components/NewChatView';
import { OfflineView } from './components/OfflineView';
import { LoadingAnimation } from './components/ui/LoadingAnimation';
import { ShellTerminalView } from './components/ShellTerminalView';
import { api } from './lib/api';
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
@@ -18,8 +20,9 @@ interface BeforeInstallPromptEvent extends Event {
type View =
| { name: 'sessions' }
| { name: 'newchat'; cwd: string }
| { name: 'chat'; sessionId?: string; cwd?: string; initialPrompt?: string; adapter?: string }
| { name: 'settings' };
| { name: 'chat'; sessionId?: string; cwd?: string; initialPrompt?: string; adapter?: string; initialMode?: 'chat' | 'terminal' }
| { name: 'settings' }
| { name: 'shell-terminal'; sessionId: string; cwd: string; fromChat?: { sessionId?: string; cwd?: string; adapter?: string } };
function loadView(): View {
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
useEffect(() => {
const handler = (e: Event) => {
@@ -363,7 +378,46 @@ export function App() {
cwd={view.cwd}
initialPrompt={view.initialPrompt}
adapter={view.adapter}
initialMode={view.initialMode}
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}
</>
+79 -22
View File
@@ -20,7 +20,8 @@ import { patchAdapterPrefs } from '../lib/adapter-prefs';
import { LoadingAnimation } from './ui/LoadingAnimation';
import { Button } from './ui/button';
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';
@@ -143,18 +144,25 @@ export function ChatView({
cwd,
initialPrompt,
adapter,
initialMode,
onBack,
onOpenTerminal,
}: {
sessionId?: string;
cwd?: string;
initialPrompt?: string;
adapter?: string;
initialMode?: 'chat' | 'terminal';
onBack: () => void;
onOpenTerminal?: (cwd: string) => void;
}) {
const chatScrollRef = useRef<HTMLDivElement>(null);
const reviewPanelRef = useRef<ReviewPanelHandle>(null);
const reviewRefetchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const headerHidden = useAutoHideHeader(chatScrollRef);
const [mode, setMode] = useState<'chat' | 'terminal'>(initialMode ?? 'chat');
const [terminalEverOpened, setTerminalEverOpened] = useState(initialMode === 'terminal');
const terminalViewRef = useRef<TerminalViewHandle>(null);
const {
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"
style={{ height: 'var(--app-height, 100dvh)' }}
>
{/* Header — overlays content, slides up when scrolling */}
<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'}`}>
{/* 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 && mode === 'chat' ? '-translate-y-full' : 'translate-y-0'}`}>
<Button variant="ghost" size="icon" onClick={onBack}>
<ChevronLeft className="w-5 h-5" />
</Button>
@@ -511,28 +519,75 @@ export function ChatView({
{wsStatus === 'reconnecting' && (
<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>
{/* Chat body — messages, tools, input */}
<ChatBody
messages={messages}
streaming={streaming}
pendingResponse={pendingResponse}
liveStatus={liveStatus}
toolStatuses={toolStatuses}
onSend={sendMessage}
onStop={abort}
interrupted={interrupted}
sendTargets={sendTargets}
onSendTo={handleSendTo}
renderAfterMessage={renderReviewMarkers}
renderBeforeInput={renderBeforeInput}
renderPlanBlock={renderPlanBlock}
initialInputText={editText}
renderAboveInput={renderAboveInput}
scrollContainerRef={chatScrollRef}
className="flex-1"
/>
<div className="flex-1 flex flex-col min-h-0" style={{ display: mode === 'chat' ? undefined : 'none' }}>
<ChatBody
messages={messages}
streaming={streaming}
pendingResponse={pendingResponse}
liveStatus={liveStatus}
toolStatuses={toolStatuses}
onSend={sendMessage}
onStop={abort}
interrupted={interrupted}
sendTargets={sendTargets}
onSendTo={handleSendTo}
renderAfterMessage={renderReviewMarkers}
renderBeforeInput={renderBeforeInput}
renderPlanBlock={renderPlanBlock}
initialInputText={editText}
renderAboveInput={renderAboveInput}
scrollContainerRef={chatScrollRef}
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 */}
{activeReviews.length > 0 && (
@@ -623,6 +678,8 @@ export function ChatView({
onRespond={respondPrompt}
/>
) : null}
</>}
</div>
);
}
+66 -1
View File
@@ -1,10 +1,11 @@
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 { AdapterIcon } from './AdapterIcon';
import { SavedInstructionsView } from './SavedInstructionsView';
import { AdapterSettingsSection } from './AdapterSettingsSection';
import { usePushNotifications } from '@/hooks/usePushNotifications';
import { STORAGE } from '@/lib/storage-keys';
interface Adapter {
id: string;
@@ -19,6 +20,16 @@ export function SettingsView({ onBack }: { onBack: () => void }) {
const [certAvailable, setCertAvailable] = useState(false);
const { supported, subscribed, subscribe, unsubscribe } = usePushNotifications();
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(() => {
api.adapters().then(setAdapters).catch(() => {});
@@ -136,6 +147,60 @@ export function SettingsView({ onBack }: { onBack: () => void }) {
</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 */}
<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>
+46
View File
@@ -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>
);
}
+75
View File
@@ -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,
}}
/>
);
}
);
+6
View File
@@ -170,4 +170,10 @@ export const api = {
deleteInstruction: (id: string) =>
request<void>(`/api/instructions/${id}`, { method: 'DELETE' }),
newShellTerminal: (cwd: string) =>
request<{ sessionId: string }>('/api/terminal/shell', {
method: 'POST',
body: JSON.stringify({ cwd }),
}),
};
+2
View File
@@ -7,6 +7,8 @@ export const STORAGE = {
INSTALL_DISMISSED: 'clawtap:install-dismissed',
SESSIONS_TAB: 'clawtap:sessionsTab',
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,
} as const;
File diff suppressed because it is too large Load Diff
+144
View File
@@ -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,
};
+55
View File
@@ -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)}%`;
}
+55
View File
@@ -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;
}
+278
View File
@@ -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;
}
+398
View File
@@ -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",
},
};
+7 -1
View File
@@ -1,7 +1,7 @@
/// <reference lib="webworker" />
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { NetworkFirst } from 'workbox-strategies';
import { NetworkFirst, CacheFirst } from 'workbox-strategies';
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,
// so we always show the notification if one is received.
self.addEventListener('push', (event) => {
+14 -1
View File
@@ -39,7 +39,9 @@ export default defineConfig({
categories: ['developer-tools', 'productivity'],
},
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,
secure: false,
},
'/terminal': {
target: 'wss://localhost:3456',
ws: true,
secure: false,
},
},
},
build: {
outDir: 'dist',
rollupOptions: {
external: ['node-pty'],
},
},
optimizeDeps: {
exclude: ['node-pty'],
},
});