feat: add frontend client logging

This commit is contained in:
2026-05-12 16:17:25 -04:00
parent 1859700f71
commit d9da2a5b85
6 changed files with 533 additions and 53 deletions
+3 -3
View File
@@ -5,9 +5,9 @@
}, },
"private": true, "private": true,
"scripts": { "scripts": {
"build": "bun run typecheck && bun build webterm/static/js/terminal.ts --outfile=webterm/static/js/terminal.js --minify --target=browser && cp node_modules/ghostty-web/ghostty-vt.wasm webterm/static/js/", "build": "bun run typecheck && bun build webterm/static/js/terminal.ts --outfile=webterm/static/js/terminal.js --minify --target=browser --define __WEBTERM_BUILD_VERSION__=\\\"$(cat VERSION 2>/dev/null || echo dev)\\\" && cp node_modules/ghostty-web/ghostty-vt.wasm webterm/static/js/",
"build:fast": "bun build webterm/static/js/terminal.ts --outfile=webterm/static/js/terminal.js --minify --target=browser", "build:fast": "bun build webterm/static/js/terminal.ts --outfile=webterm/static/js/terminal.js --minify --target=browser --define __WEBTERM_BUILD_VERSION__=\\\"$(cat VERSION 2>/dev/null || echo dev)\\\"",
"watch": "bun build webterm/static/js/terminal.ts --outfile=webterm/static/js/terminal.js --watch --target=browser", "watch": "bun build webterm/static/js/terminal.ts --outfile=webterm/static/js/terminal.js --watch --target=browser --define __WEBTERM_BUILD_VERSION__=\\\"$(cat VERSION 2>/dev/null || echo dev)\\\"",
"typecheck": "bun x tsc --noEmit -p tsconfig.json", "typecheck": "bun x tsc --noEmit -p tsconfig.json",
"copy-wasm": "cp node_modules/ghostty-web/ghostty-vt.wasm webterm/static/js/" "copy-wasm": "cp node_modules/ghostty-web/ghostty-vt.wasm webterm/static/js/"
}, },
+1
View File
@@ -23,6 +23,7 @@ const (
AuthPasswordEnv = "WEBTERM_AUTH_PASSWORD" AuthPasswordEnv = "WEBTERM_AUTH_PASSWORD"
AuthCookieSecretEnv = "WEBTERM_AUTH_COOKIE_SECRET" AuthCookieSecretEnv = "WEBTERM_AUTH_COOKIE_SECRET"
AuthSessionTTLSecondsEnv = "WEBTERM_AUTH_SESSION_TTL_SECONDS" AuthSessionTTLSecondsEnv = "WEBTERM_AUTH_SESSION_TTL_SECONDS"
FrontendLogDirEnv = "WEBTERM_FRONTEND_LOG_DIR"
DockerUsernameEnv = "WEBTERM_DOCKER_USERNAME" DockerUsernameEnv = "WEBTERM_DOCKER_USERNAME"
DockerAutoCommandEnv = "WEBTERM_DOCKER_AUTO_COMMAND" DockerAutoCommandEnv = "WEBTERM_DOCKER_AUTO_COMMAND"
DockerHostEnv = "DOCKER_HOST" DockerHostEnv = "DOCKER_HOST"
+177 -2
View File
@@ -173,6 +173,7 @@ type LocalServer struct {
fontSize int fontSize int
screenshotMode string screenshotMode string
staticAssetCacheBust string staticAssetCacheBust string
frontendLogDir string
sessionManager *SessionManager sessionManager *SessionManager
landingApps []App landingApps []App
@@ -257,6 +258,7 @@ func NewLocalServer(config Config, options ServerOptions) *LocalServer {
fontFamily: options.FontFamily, fontFamily: options.FontFamily,
fontSize: fontSize, fontSize: fontSize,
screenshotMode: screenshotMode, screenshotMode: screenshotMode,
frontendLogDir: defaultFrontendLogDir(),
sessionManager: NewSessionManager(apps), sessionManager: NewSessionManager(apps),
landingApps: append([]App{}, options.LandingApps...), landingApps: append([]App{}, options.LandingApps...),
@@ -323,6 +325,95 @@ func findStaticPath() string {
return "" return ""
} }
func defaultFrontendLogDir() string {
if p := strings.TrimSpace(os.Getenv(FrontendLogDirEnv)); p != "" {
return p
}
if stateHome := strings.TrimSpace(os.Getenv("XDG_STATE_HOME")); stateHome != "" {
return filepath.Join(stateHome, "webterm", "frontend-logs")
}
if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" {
return filepath.Join(home, ".local", "state", "webterm", "frontend-logs")
}
return filepath.Join(os.TempDir(), "webterm", "frontend-logs")
}
func sanitizeFrontendLogSessionID(value string) (string, bool) {
value = strings.TrimSpace(value)
if value == "" {
return "", false
}
var b strings.Builder
for _, r := range value {
switch {
case r >= 'a' && r <= 'z':
b.WriteRune(r)
case r >= 'A' && r <= 'Z':
b.WriteRune(r)
case r >= '0' && r <= '9':
b.WriteRune(r)
case r == '-', r == '_':
b.WriteRune(r)
default:
return "", false
}
if b.Len() >= 128 {
break
}
}
sanitized := b.String()
if sanitized == "" || sanitized != value {
return "", false
}
return sanitized, true
}
type frontendLogRequest struct {
SessionID string `json:"session_id"`
Text string `json:"text"`
FrontendVersion string `json:"frontend_version"`
Level string `json:"level"`
Context string `json:"context"`
}
func (s *LocalServer) appendFrontendLog(sessionID, text, frontendVersion, level, context string, now time.Time) (string, error) {
if err := os.MkdirAll(s.frontendLogDir, 0o755); err != nil {
return "", err
}
filename := fmt.Sprintf("%s.log", now.Format("2006-01-02"))
path := filepath.Join(s.frontendLogDir, filename)
file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return "", err
}
defer file.Close()
if frontendVersion == "" {
frontendVersion = "unknown"
}
record := map[string]string{
"timestamp": now.UTC().Format(time.RFC3339Nano),
"session_id": sessionID,
"text": text,
"frontend_version": frontendVersion,
"server_version": Version,
}
if strings.TrimSpace(level) != "" {
record["level"] = strings.TrimSpace(level)
}
if strings.TrimSpace(context) != "" {
record["context"] = strings.TrimSpace(context)
}
line, err := json.Marshal(record)
if err != nil {
return "", err
}
if _, err := file.Write(append(line, '\n')); err != nil {
return "", err
}
return path, nil
}
func (s *LocalServer) markRouteActivity(routeKey string) { func (s *LocalServer) markRouteActivity(routeKey string) {
now := time.Now() now := time.Now()
s.mu.Lock() s.mu.Lock()
@@ -527,6 +618,62 @@ func (s *LocalServer) handleAuthLogout(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
} }
func (s *LocalServer) handleFrontendLog(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var payload frontendLogRequest
contentType := strings.ToLower(strings.TrimSpace(strings.Split(r.Header.Get("Content-Type"), ";")[0]))
switch contentType {
case "", "application/x-www-form-urlencoded", "multipart/form-data":
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return
}
payload.SessionID = r.Form.Get("session_id")
payload.Text = r.Form.Get("text")
payload.FrontendVersion = r.Form.Get("frontend_version")
payload.Level = r.Form.Get("level")
payload.Context = r.Form.Get("context")
default:
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&payload); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
}
sessionID, ok := sanitizeFrontendLogSessionID(payload.SessionID)
if !ok {
http.Error(w, "missing session_id", http.StatusBadRequest)
return
}
text := strings.TrimSpace(payload.Text)
if text == "" {
http.Error(w, "missing text", http.StatusBadRequest)
return
}
path, err := s.appendFrontendLog(
sessionID,
text,
strings.TrimSpace(payload.FrontendVersion),
strings.TrimSpace(payload.Level),
strings.TrimSpace(payload.Context),
time.Now(),
)
if err != nil {
log.Printf("frontend log append failed session_id=%s err=%v", sessionID, err)
http.Error(w, "log write failed", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"path": path,
})
}
func (s *LocalServer) renderLoginPage(w http.ResponseWriter, r *http.Request) { func (s *LocalServer) renderLoginPage(w http.ResponseWriter, r *http.Request) {
errorBanner := "" errorBanner := ""
if r.URL.Query().Get("auth_error") == "1" { if r.URL.Query().Get("auth_error") == "1" {
@@ -1319,6 +1466,8 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
let searchQuery = ''; let searchQuery = '';
let activeResultIndex = -1; let activeResultIndex = -1;
let filteredResults = []; let filteredResults = [];
const clientLogSessionID = 'dashboard-' + Math.random().toString(36).slice(2, 14);
const clientBuildVersion = %q;
const floatingResultsEl = document.getElementById('floating-results'); const floatingResultsEl = document.getElementById('floating-results');
const keyIndicatorEl = document.getElementById('key-indicator'); const keyIndicatorEl = document.getElementById('key-indicator');
const thumbnailCache = {}; const thumbnailCache = {};
@@ -1332,6 +1481,31 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
const grid = document.getElementById('grid'); const grid = document.getElementById('grid');
const subtitle = document.getElementById('subtitle'); const subtitle = document.getElementById('subtitle');
function clientLog(level, text, context) {
const message = (text || '').toString().trim();
if (!message) return;
const payload = {
session_id: clientLogSessionID,
text: message,
frontend_version: clientBuildVersion,
level: level || 'info',
};
if (context) {
try {
payload.context = JSON.stringify(context);
} catch (_) {
payload.context = String(context);
}
}
fetch('/api/client-log', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
keepalive: true,
credentials: 'same-origin',
}).catch(() => {});
}
function bellStorageKey(slug) { function bellStorageKey(slug) {
return bellStoragePrefix + slug; return bellStoragePrefix + slug;
} }
@@ -1797,7 +1971,7 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
} }
subtitle.textContent = ''; subtitle.textContent = '';
if (dockerWatchMode) { if (dockerWatchMode) {
console.log(tiles.length + ' container(s) found'); clientLog('info', tiles.length + ' container(s) found', { tile_count: tiles.length });
} }
for (const tile of tiles) { for (const tile of tiles) {
const card = makeTile(tile); const card = makeTile(tile);
@@ -1846,7 +2020,7 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
} }
</script> </script>
</body> </body>
</html>`, string(tilesJSON), composeModeJS, dockerWatchJS, screenshotEndpoint, screenshotDownloadEndpoint, screenshotDownloadQuery, screenshotDownloadExt) </html>`, string(tilesJSON), composeModeJS, dockerWatchJS, screenshotEndpoint, screenshotDownloadEndpoint, screenshotDownloadQuery, screenshotDownloadExt, Version)
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = io.WriteString(w, html) _, _ = io.WriteString(w, html)
return return
@@ -2000,6 +2174,7 @@ func (s *LocalServer) Handler() http.Handler {
mux.HandleFunc("/cpu-sparkline.svg", s.handleCPUSparkline) mux.HandleFunc("/cpu-sparkline.svg", s.handleCPUSparkline)
mux.HandleFunc("/events", s.handleEvents) mux.HandleFunc("/events", s.handleEvents)
mux.HandleFunc("/health", s.handleHealth) mux.HandleFunc("/health", s.handleHealth)
mux.HandleFunc("/api/client-log", s.handleFrontendLog)
mux.HandleFunc("/tiles", s.handleTiles) mux.HandleFunc("/tiles", s.handleTiles)
mux.HandleFunc("/", s.handleRoot) mux.HandleFunc("/", s.handleRoot)
if strings.TrimSpace(s.staticPath) != "" { if strings.TrimSpace(s.staticPath) != "" {
+66
View File
@@ -7,6 +7,8 @@ import (
"net" "net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"path/filepath"
"strings" "strings"
"sync" "sync"
"testing" "testing"
@@ -46,6 +48,7 @@ func newServerForTests(t *testing.T, withLanding bool) (*LocalServer, *httptest.
options.LandingApps = []App{{Name: "Shell", Slug: "shell", Command: "/bin/sh", Terminal: true}} options.LandingApps = []App{{Name: "Shell", Slug: "shell", Command: "/bin/sh", Terminal: true}}
} }
server := NewLocalServer(config, options) server := NewLocalServer(config, options)
server.frontendLogDir = t.TempDir()
sessions := &syncSessionMap{m: map[string]*fakeSession{}} sessions := &syncSessionMap{m: map[string]*fakeSession{}}
server.sessionManager.SetSessionFactory(func(app App, sessionID string) Session { server.sessionManager.SetSessionFactory(func(app App, sessionID string) Session {
s := newFakeSession() s := newFakeSession()
@@ -128,6 +131,69 @@ func TestHealthAndTilesEndpoints(t *testing.T) {
} }
} }
func TestFrontendLogEndpointAppendsJSONLine(t *testing.T) {
_, httpServer, _ := newServerForTests(t, false)
body := strings.NewReader(`{"session_id":"frontend-abc123","text":"button clicked","frontend_version":"1.3.39"}`)
req, err := http.NewRequest(http.MethodPost, httpServer.URL+"/api/client-log", body)
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("post frontend log: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
data, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 200, got %d body=%q", resp.StatusCode, string(data))
}
var payload map[string]string
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
logPath := payload["path"]
if filepath.Base(logPath) != time.Now().Format("2006-01-02")+".log" {
t.Fatalf("unexpected log path: %q", logPath)
}
data, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("read log file: %v", err)
}
var record map[string]string
if err := json.Unmarshal([]byte(strings.TrimSpace(string(data))), &record); err != nil {
t.Fatalf("decode log line: %v", err)
}
if record["session_id"] != "frontend-abc123" || record["text"] != "button clicked" {
t.Fatalf("unexpected log record: %+v", record)
}
if record["frontend_version"] != "1.3.39" || record["server_version"] == "" {
t.Fatalf("missing version fields: %+v", record)
}
}
func TestFrontendLogEndpointRejectsInvalidSessionID(t *testing.T) {
_, httpServer, _ := newServerForTests(t, false)
body := strings.NewReader(`{"session_id":"../../bad","text":"oops"}`)
req, err := http.NewRequest(http.MethodPost, httpServer.URL+"/api/client-log", body)
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("post frontend log: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
data, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 400, got %d body=%q", resp.StatusCode, string(data))
}
}
func TestWebSocketPingResizeAndStdin(t *testing.T) { func TestWebSocketPingResizeAndStdin(t *testing.T) {
server, httpServer, sessions := newServerForTests(t, false) server, httpServer, sessions := newServerForTests(t, false)
wsURL := "ws" + strings.TrimPrefix(httpServer.URL, "http") + "/ws/shell" wsURL := "ws" + strings.TrimPrefix(httpServer.URL, "http") + "/ws/shell"
File diff suppressed because one or more lines are too long
+275 -39
View File
@@ -8,6 +8,8 @@
import { Terminal, FitAddon, Ghostty, type ITerminalOptions, type ITheme } from "ghostty-web"; import { Terminal, FitAddon, Ghostty, type ITerminalOptions, type ITheme } from "ghostty-web";
declare const __WEBTERM_BUILD_VERSION__: string;
/** Maximum queued messages before oldest are dropped */ /** Maximum queued messages before oldest are dropped */
const MAX_MESSAGE_QUEUE_SIZE = 1000; const MAX_MESSAGE_QUEUE_SIZE = 1000;
/** How often to run periodic resource cleanup (ms) */ /** How often to run periodic resource cleanup (ms) */
@@ -21,6 +23,7 @@ const STDIN_BATCH_DELAY_MS = 10;
const STDIN_BATCH_MAX_CHARS = 8192; const STDIN_BATCH_MAX_CHARS = 8192;
const BELL_EMOJI = "🔔"; const BELL_EMOJI = "🔔";
const FRONTEND_BUILD_VERSION = __WEBTERM_BUILD_VERSION__;
const DEFAULT_SHERPA_ASSET_DIR = "sherpa-moonshine-v2-base-en"; const DEFAULT_SHERPA_ASSET_DIR = "sherpa-moonshine-v2-base-en";
const VOICE_SAMPLE_RATE = 16_000; const VOICE_SAMPLE_RATE = 16_000;
const VOICE_PROCESSOR_BUFFER_SIZE = 4096; const VOICE_PROCESSOR_BUFFER_SIZE = 4096;
@@ -39,6 +42,7 @@ const VOICE_CANCEL_COMMAND = "cancel text";
type VoiceMode = "live" | "cleanup"; type VoiceMode = "live" | "cleanup";
type VoiceFinalizeAction = "insert" | "submit"; type VoiceFinalizeAction = "insert" | "submit";
type VoiceCommandAction = VoiceFinalizeAction | "cancel"; type VoiceCommandAction = VoiceFinalizeAction | "cancel";
type FrontendLogLevel = "debug" | "info" | "warn" | "error";
const VOICE_CLEANUP_SYSTEM_PROMPT = `You clean up raw speech-to-text transcripts into concise terminal-ready text. Remove filler words, false starts, repetitions, and obvious recognition mistakes while preserving user intent. Do not ask questions. Return only cleaned transcript text with no explanation, labels, or quotes.`; const VOICE_CLEANUP_SYSTEM_PROMPT = `You clean up raw speech-to-text transcripts into concise terminal-ready text. Remove filler words, false starts, repetitions, and obvious recognition mistakes while preserving user intent. Do not ask questions. Return only cleaned transcript text with no explanation, labels, or quotes.`;
@@ -111,11 +115,27 @@ type WakeLockSentinelLike = {
}; };
declare global { declare global {
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;
}
interface Window { interface Window {
CircularBuffer?: new (capacity: number, module: SherpaModule) => SherpaCircularBuffer; CircularBuffer?: new (capacity: number, module: SherpaModule) => SherpaCircularBuffer;
Module?: SherpaModule; Module?: SherpaModule;
OfflineRecognizer?: new (config: unknown, module: SherpaModule) => SherpaOfflineRecognizer; OfflineRecognizer?: new (config: unknown, module: SherpaModule) => SherpaOfflineRecognizer;
createVad?: (module: SherpaModule, config?: unknown) => SherpaVad; createVad?: (module: SherpaModule, config?: unknown) => SherpaVad;
webtermClientLog?: (text: string, extra?: Record<string, unknown>) => Promise<void>;
webtermClientLogSessionID?: string;
webtermClientBuildVersion?: string;
webtermLogs?: {
bug: WebtermScopedLogger;
ui: WebtermScopedLogger;
voice: WebtermScopedLogger;
ws: WebtermScopedLogger;
};
} }
} }
@@ -125,11 +145,131 @@ let sharedSherpaRuntimePromise: Promise<SherpaRuntime> | null = null;
/** Shared Ghostty WASM instance (loaded once, reused across all terminals) */ /** Shared Ghostty WASM instance (loaded once, reused across all terminals) */
let sharedGhostty: Ghostty | null = null; let sharedGhostty: Ghostty | null = null;
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;
}
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);
}
}
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);
}
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)),
};
}
const bugLog = createScopedFrontendLogger("bug");
const uiLog = createScopedFrontendLogger("ui");
const voiceLog = createScopedFrontendLogger("voice");
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,
};
/** Load or reuse the shared Ghostty WASM instance */ /** Load or reuse the shared Ghostty WASM instance */
async function getSharedGhostty(): Promise<Ghostty> { async function getSharedGhostty(): Promise<Ghostty> {
if (!sharedGhostty) { if (!sharedGhostty) {
const wasmPath = getWasmPath(); const wasmPath = getWasmPath();
console.log("[webterm] Loading shared Ghostty WASM:", wasmPath); uiLog.info("Loading shared Ghostty WASM", { wasmPath });
sharedGhostty = await Ghostty.load(wasmPath); sharedGhostty = await Ghostty.load(wasmPath);
} }
return sharedGhostty; return sharedGhostty;
@@ -565,7 +705,7 @@ function parseConfig(element: HTMLElement): TerminalConfig {
if (resolved) { if (resolved) {
fontFamily = resolved; fontFamily = resolved;
} else { } else {
console.warn(`[webterm] CSS variable ${varName} not found, using default font`); uiLog.warn("CSS variable not found; using default font", { varName });
fontFamily = DEFAULT_FONT_FAMILY; fontFamily = DEFAULT_FONT_FAMILY;
} }
} }
@@ -587,7 +727,7 @@ function parseConfig(element: HTMLElement): TerminalConfig {
try { try {
config.theme = JSON.parse(element.dataset.theme) as ITheme; config.theme = JSON.parse(element.dataset.theme) as ITheme;
} catch (e) { } catch (e) {
console.warn(`[webterm] Unknown theme "${element.dataset.theme}"`, e); uiLog.warn("Unknown theme configuration", { theme: element.dataset.theme, error: e });
} }
} }
} }
@@ -1071,6 +1211,7 @@ class WebTerminal {
private voiceFinalizeToken = 0; private voiceFinalizeToken = 0;
private wakeLockSentinel: WakeLockSentinelLike | null = null; private wakeLockSentinel: WakeLockSentinelLike | null = null;
private wakeLockEnabled = false; private wakeLockEnabled = false;
private wakeLockButton: HTMLButtonElement | null = null;
private isVoiceStarting = false; private isVoiceStarting = false;
private voiceState: "idle" | "loading" | "listening" | "processing" | "error" | "unsupported" = "idle"; private voiceState: "idle" | "loading" | "listening" | "processing" | "error" | "unsupported" = "idle";
private voiceStartupErrorCleanup: (() => void) | null = null; private voiceStartupErrorCleanup: (() => void) | null = null;
@@ -1416,14 +1557,22 @@ class WebTerminal {
try { try {
const sentinel = await wakeLock.request("screen"); const sentinel = await wakeLock.request("screen");
this.wakeLockSentinel = sentinel; this.wakeLockSentinel = sentinel;
this.syncWakeLockButton();
uiLog.info("Wake lock acquired");
sentinel.addEventListener?.("release", (() => { sentinel.addEventListener?.("release", (() => {
this.wakeLockSentinel = null; this.wakeLockSentinel = null;
this.syncWakeLockButton();
if (this.wakeLockEnabled && !document.hidden) { if (this.wakeLockEnabled && !document.hidden) {
void this.acquireWakeLock(); uiLog.info("Wake lock released; waiting for next user gesture to re-enable");
} }
}) as EventListener); }) as EventListener);
} catch (error) { } catch (error) {
console.warn("[webterm] Failed to acquire wake lock:", error); this.syncWakeLockButton();
uiLog.warn("Failed to acquire wake lock", { error });
this.showUserError("Wake lock blocked. Tap Wake again after interacting.");
window.setTimeout(() => {
this.clearUserError();
}, 2200);
} }
} }
@@ -1435,9 +1584,63 @@ class WebTerminal {
} }
try { try {
await sentinel.release(); await sentinel.release();
uiLog.info("Wake lock released");
} catch { } catch {
// Ignore release failures on already-released sentinels. // Ignore release failures on already-released sentinels.
} }
this.syncWakeLockButton();
}
private wakeLockSupported(): boolean {
return Boolean((navigator as Navigator & {
wakeLock?: { request(type: "screen"): Promise<WakeLockSentinelLike> };
}).wakeLock?.request);
}
private syncWakeLockButton(): void {
if (!this.wakeLockButton) {
return;
}
const active = this.wakeLockEnabled;
const held = Boolean(this.wakeLockSentinel && !this.wakeLockSentinel.released);
this.wakeLockButton.textContent = active ? (held ? "Wake On" : "Wake Tap") : "Wake Off";
this.wakeLockButton.classList.toggle("active", active);
this.wakeLockButton.disabled = !this.wakeLockSupported();
this.wakeLockButton.title = active
? (held ? "Wake lock enabled" : "Wake lock enabled; tap after resume to reacquire")
: "Enable wake lock";
}
private async setWakeLockEnabled(enabled: boolean, userInitiated = false): Promise<void> {
if (enabled && !this.wakeLockSupported()) {
uiLog.warn("Wake lock unsupported on this browser");
this.showUserError("Wake lock unsupported on this browser.");
window.setTimeout(() => {
this.clearUserError();
}, 2200);
return;
}
this.wakeLockEnabled = enabled;
this.syncWakeLockButton();
if (!enabled) {
await this.releaseWakeLock();
return;
}
if (userInitiated) {
this.clearUserError();
await this.acquireWakeLock();
}
}
private async toggleWakeLock(userInitiated = false): Promise<void> {
await this.setWakeLockEnabled(!this.wakeLockEnabled, userInitiated);
}
private tryAcquireWakeLockFromGesture(): void {
if (!this.wakeLockEnabled || this.wakeLockSentinel || document.hidden) {
return;
}
void this.acquireWakeLock();
} }
private ensureErrorOverlay(): HTMLDivElement { private ensureErrorOverlay(): HTMLDivElement {
@@ -1540,9 +1743,6 @@ class WebTerminal {
/** Initialize event handlers and connect */ /** Initialize event handlers and connect */
private initialize(): void { private initialize(): void {
this.wakeLockEnabled = true;
void this.acquireWakeLock();
// Wait for fonts to load before fitting to ensure correct measurements // Wait for fonts to load before fitting to ensure correct measurements
// //
// FONT INITIALIZATION (ghostty-web): // FONT INITIALIZATION (ghostty-web):
@@ -1670,20 +1870,20 @@ class WebTerminal {
this.isTabHidden = false; this.isTabHidden = false;
this.refreshConnection(); this.refreshConnection();
restoreFocus(); restoreFocus();
void this.acquireWakeLock(); this.syncWakeLockButton();
} }
}); });
// Restore focus when browser window regains focus // Restore focus when browser window regains focus
this.addTrackedListener(window, "focus", () => { this.addTrackedListener(window, "focus", () => {
restoreFocus(); restoreFocus();
void this.acquireWakeLock(); this.syncWakeLockButton();
}); });
// Safari can restore tabs via bfcache without a focus event. // Safari can restore tabs via bfcache without a focus event.
this.addTrackedListener(window, "pageshow", () => { this.addTrackedListener(window, "pageshow", () => {
restoreFocus(); restoreFocus();
void this.acquireWakeLock(); this.syncWakeLockButton();
}); });
} }
@@ -1838,6 +2038,12 @@ class WebTerminal {
document, document,
"keydown", "keydown",
((event: KeyboardEvent) => { ((event: KeyboardEvent) => {
if (event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey && event.key.toLowerCase() === "w") {
event.preventDefault();
event.stopPropagation();
void this.toggleWakeLock(true);
return;
}
if (!this.ctrlActive && !this.shiftActive && !this.altActive && !this.fnActive) { if (!this.ctrlActive && !this.shiftActive && !this.altActive && !this.fnActive) {
return; return;
} }
@@ -1914,6 +2120,7 @@ class WebTerminal {
// iOS requires focus() to be called synchronously within the gesture // iOS requires focus() to be called synchronously within the gesture
// Don't call terminal.focus() as it steals focus and dismisses keyboard // Don't call terminal.focus() as it steals focus and dismisses keyboard
const focusTextarea = () => { const focusTextarea = () => {
this.tryAcquireWakeLockFromGesture();
this.focusMobileInput(); this.focusMobileInput();
}; };
@@ -2210,7 +2417,7 @@ class WebTerminal {
this.setVoiceState("listening", this.voiceMode === "cleanup" ? "Listening: Cleanup" : "Listening..."); this.setVoiceState("listening", this.voiceMode === "cleanup" ? "Listening: Cleanup" : "Listening...");
this.focusTerminalInput(); this.focusTerminalInput();
} catch (error) { } catch (error) {
console.error("[webterm] Failed to start sherpa voice input:", error); voiceLog.error("Failed to start sherpa voice input", { error });
this.disconnectVoiceAudio(); this.disconnectVoiceAudio();
this.resetVoiceDraftState(); this.resetVoiceDraftState();
this.setVoiceState("error", this.describeVoiceError(error)); this.setVoiceState("error", this.describeVoiceError(error));
@@ -2322,7 +2529,7 @@ class WebTerminal {
try { try {
this.flushVoiceSegments(); this.flushVoiceSegments();
} catch (error) { } catch (error) {
console.error("[webterm] Failed to flush sherpa voice segments:", error); voiceLog.error("Failed to flush sherpa voice segments", { error });
} }
this.disconnectVoiceAudio(); this.disconnectVoiceAudio();
if (this.voiceMode === "cleanup") { if (this.voiceMode === "cleanup") {
@@ -2331,7 +2538,7 @@ class WebTerminal {
try { try {
await this.finalizeVoiceCleanup(action); await this.finalizeVoiceCleanup(action);
} catch (error) { } catch (error) {
console.error("[webterm] Failed to finalize cleanup transcript:", error); voiceLog.error("Failed to finalize cleanup transcript", { error, action });
this.setVoiceState("error", this.describeVoiceError(error)); this.setVoiceState("error", this.describeVoiceError(error));
} }
} else { } else {
@@ -2452,7 +2659,7 @@ class WebTerminal {
if (command === "insert" || command === "submit") { if (command === "insert" || command === "submit") {
this.disconnectVoiceAudio(); this.disconnectVoiceAudio();
void this.finalizeVoiceCleanup(command).catch((error) => { void this.finalizeVoiceCleanup(command).catch((error) => {
console.error("[webterm] Failed to finalize cleanup transcript:", error); voiceLog.error("Failed to finalize cleanup transcript", { error, action: command });
this.setVoiceState("error", this.describeVoiceError(error)); this.setVoiceState("error", this.describeVoiceError(error));
}); });
this.focusTerminalInput(); this.focusTerminalInput();
@@ -2565,7 +2772,7 @@ class WebTerminal {
if (!message || message === "Voice error") { if (!message || message === "Voice error") {
return; return;
} }
console.error("[webterm] Captured voice startup failure:", error); voiceLog.error("Captured voice startup failure", { error, message });
this.setVoiceState("error", message); this.setVoiceState("error", message);
}; };
@@ -2800,18 +3007,16 @@ class WebTerminal {
return; return;
} }
const scale = this.getVirtualKeyboardScale(width); const scale = this.getVirtualKeyboardScale(width);
this.mobileVirtualKeyboardHost.style.height = `${Math.round(this.virtualKeyboardBaseHeight / scale)}px`; const baseHeight = Math.round(this.virtualKeyboardBaseHeight / scale);
const bottomInset = this.getMobileKeyboardBottomInset();
this.mobileVirtualKeyboardHost.style.height = `${baseHeight + bottomInset}px`;
} }
private getMobileKeyboardBottomInset(): number { private getMobileKeyboardBottomInset(): number {
if (typeof window === "undefined" || typeof navigator === "undefined") { if (typeof window === "undefined") {
return 0; return 0;
} }
const standalone = return MOBILE_PWA_BOTTOM_INSET_PX;
window.matchMedia?.("(display-mode: standalone)")?.matches ||
Boolean((navigator as Navigator & { standalone?: boolean }).standalone);
const isiPhoneLike = /iPhone|iPod/.test(navigator.userAgent);
return standalone && isiPhoneLike ? MOBILE_PWA_BOTTOM_INSET_PX : 0;
} }
private updateMobileKeyboardDockLayout(): void { private updateMobileKeyboardDockLayout(): void {
@@ -2819,18 +3024,16 @@ class WebTerminal {
if (this.mobileVirtualKeyboardHost) { if (this.mobileVirtualKeyboardHost) {
this.mobileVirtualKeyboardHost.style.bottom = "0"; this.mobileVirtualKeyboardHost.style.bottom = "0";
this.mobileVirtualKeyboardHost.style.paddingBottom = "0"; this.mobileVirtualKeyboardHost.style.paddingBottom = "0";
this.mobileVirtualKeyboardHost.style.transform = this.mobileVirtualKeyboardHost.style.transform = "";
safeAreaBottom > 0 ? `translateY(-${safeAreaBottom}px)` : "";
} }
if (this.mobileKeybar) { if (this.mobileKeybar) {
const keyboardHeight = this.mobileVirtualKeyboardHost?.offsetHeight ?? 0; const keyboardHeight = this.mobileVirtualKeyboardHost?.offsetHeight ?? 0;
this.mobileKeybar.style.bottom = `${keyboardHeight}px`; this.mobileKeybar.style.bottom = `${keyboardHeight}px`;
this.mobileKeybar.style.paddingBottom = "0"; this.mobileKeybar.style.paddingBottom = "0";
this.mobileKeybar.style.transform = this.mobileKeybar.style.transform = "";
safeAreaBottom > 0 ? `translateY(-${safeAreaBottom}px)` : "";
} }
const keyboardHeight = this.mobileKeyboardVisible const keyboardHeight = this.mobileKeyboardVisible
? (this.mobileKeybar?.offsetHeight ?? 0) + (this.mobileVirtualKeyboardHost?.offsetHeight ?? 0) + safeAreaBottom ? (this.mobileKeybar?.offsetHeight ?? 0) + (this.mobileVirtualKeyboardHost?.offsetHeight ?? 0)
: 0; : 0;
this.element.style.paddingBottom = `${keyboardHeight}px`; this.element.style.paddingBottom = `${keyboardHeight}px`;
} }
@@ -2875,11 +3078,12 @@ class WebTerminal {
} }
const layout = this.currentVirtualKeyboardLayout(); const layout = this.currentVirtualKeyboardLayout();
const scale = this.getVirtualKeyboardScale(rect.width); const scale = this.getVirtualKeyboardScale(rect.width);
const bottomInset = this.getMobileKeyboardBottomInset();
const compactRatio = Math.max(0, Math.min(1, (rect.width - 260) / 140)); const compactRatio = Math.max(0, Math.min(1, (rect.width - 260) / 140));
const padding = (2 + (10 - 2) * compactRatio) / scale; const padding = (2 + (10 - 2) * compactRatio) / scale;
const gap = (2 + (8 - 2) * compactRatio) / scale; const gap = (2 + (8 - 2) * compactRatio) / scale;
const contentW = rect.width - padding * 2; const contentW = rect.width - padding * 2;
const contentH = rect.height - padding * 2; const contentH = rect.height - padding * 2 - bottomInset;
const rowHeight = (contentH - (layout.length - 1) * gap) / layout.length; const rowHeight = (contentH - (layout.length - 1) * gap) / layout.length;
const bounds: VirtualKeyboardKeyBounds[] = []; const bounds: VirtualKeyboardKeyBounds[] = [];
let currentY = padding; let currentY = padding;
@@ -3290,6 +3494,8 @@ class WebTerminal {
<button class="keybar-font-grow" title="Larger font">A+</button> <button class="keybar-font-grow" title="Larger font">A+</button>
<span class="keybar-label">Voice</span> <span class="keybar-label">Voice</span>
<button class="keybar-voice-mode" title="Toggle voice mode">Live</button> <button class="keybar-voice-mode" title="Toggle voice mode">Live</button>
<span class="keybar-label">Wake</span>
<button class="keybar-wake-lock" title="Toggle wake lock">Wake Off</button>
`; `;
keybar.appendChild(keysPanel); keybar.appendChild(keysPanel);
@@ -3451,6 +3657,20 @@ class WebTerminal {
updateVoiceModeButton(); updateVoiceModeButton();
}); });
const wakeLockButton = settingsPanel.querySelector(".keybar-wake-lock") as HTMLButtonElement | null;
if (wakeLockButton) {
this.wakeLockButton = wakeLockButton;
this.syncWakeLockButton();
wakeLockButton.addEventListener("touchstart", (e) => {
e.preventDefault();
void this.toggleWakeLock(true);
});
wakeLockButton.addEventListener("click", (e) => {
e.preventDefault();
void this.toggleWakeLock(true);
});
}
// Handle key button presses // Handle key button presses
keysPanel.querySelectorAll("button[data-key]").forEach((btn) => { keysPanel.querySelectorAll("button[data-key]").forEach((btn) => {
btn.addEventListener("touchstart", (e) => { btn.addEventListener("touchstart", (e) => {
@@ -3704,7 +3924,7 @@ class WebTerminal {
this.lastPongAt = Date.now(); this.lastPongAt = Date.now();
break; break;
default: default:
console.debug("Unknown message type:", type); wsLog.debug("Unknown WebSocket message type", { type });
} }
} catch { } catch {
if (this.isTabHidden) { if (this.isTabHidden) {
@@ -3751,7 +3971,11 @@ class WebTerminal {
const now = Date.now(); const now = Date.now();
const lastInbound = Math.max(this.lastMessageAt, this.lastPongAt); const lastInbound = Math.max(this.lastMessageAt, this.lastPongAt);
if (now - lastInbound > this.stallTimeoutMs) { if (now - lastInbound > this.stallTimeoutMs) {
console.warn("WebSocket inbound stream stalled; reconnecting"); wsLog.warn("WebSocket inbound stream stalled; reconnecting", {
stallTimeoutMs: this.stallTimeoutMs,
lastInboundAt: lastInbound,
now,
});
this.socket.close(); this.socket.close();
return; return;
} }
@@ -3786,7 +4010,7 @@ class WebTerminal {
if (this.messageQueue.length > MAX_MESSAGE_QUEUE_SIZE) { if (this.messageQueue.length > MAX_MESSAGE_QUEUE_SIZE) {
const dropped = this.messageQueue.length - MAX_MESSAGE_QUEUE_SIZE; const dropped = this.messageQueue.length - MAX_MESSAGE_QUEUE_SIZE;
this.messageQueue = this.messageQueue.slice(-MAX_MESSAGE_QUEUE_SIZE); this.messageQueue = this.messageQueue.slice(-MAX_MESSAGE_QUEUE_SIZE);
console.warn(`[webterm] Trimmed ${dropped} stale messages from queue`); wsLog.warn("Trimmed stale messages from queue", { dropped, queueSize: this.messageQueue.length });
} }
} }
@@ -3865,7 +4089,10 @@ class WebTerminal {
if (this.messageQueue.length >= MAX_MESSAGE_QUEUE_SIZE) { if (this.messageQueue.length >= MAX_MESSAGE_QUEUE_SIZE) {
this.messageQueue = this.messageQueue.slice(-Math.floor(MAX_MESSAGE_QUEUE_SIZE / 2)); this.messageQueue = this.messageQueue.slice(-Math.floor(MAX_MESSAGE_QUEUE_SIZE / 2));
console.warn("[webterm] Message queue overflow; trimmed old messages"); wsLog.warn("Message queue overflow; trimmed old messages", {
queueSize: this.messageQueue.length,
messageType: message[0],
});
} }
this.messageQueue.push(message); this.messageQueue.push(message);
this.processMessageQueue(); this.processMessageQueue();
@@ -3884,7 +4111,10 @@ class WebTerminal {
this.socket.send(JSON.stringify(message)); this.socket.send(JSON.stringify(message));
} }
} catch (e) { } catch (e) {
console.error("Failed to send message:", e, message); wsLog.error("Failed to send WebSocket message", {
error: e,
messageType: message?.[0],
});
if (message) { if (message) {
this.messageQueue.unshift(message); this.messageQueue.unshift(message);
} }
@@ -3896,7 +4126,10 @@ class WebTerminal {
/** Schedule reconnection attempt */ /** Schedule reconnection attempt */
private scheduleReconnect(): void { private scheduleReconnect(): void {
if (this.reconnectAttempts >= this.maxReconnectAttempts) { if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error("Max reconnection attempts reached"); wsLog.error("Max reconnection attempts reached", {
reconnectAttempts: this.reconnectAttempts,
maxReconnectAttempts: this.maxReconnectAttempts,
});
this.showUserError("Disconnected. Max reconnection attempts reached."); this.showUserError("Disconnected. Max reconnection attempts reached.");
return; return;
} }
@@ -3905,7 +4138,10 @@ class WebTerminal {
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1); const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
setTimeout(() => { setTimeout(() => {
console.log(`[webterm] Reconnecting (attempt ${this.reconnectAttempts})...`); wsLog.info("Reconnecting WebSocket", {
attempt: this.reconnectAttempts,
delayMs: delay,
});
this.connect(); this.connect();
}, delay); }, delay);
} }
@@ -4033,7 +4269,7 @@ setInterval(() => {
if (!el.isConnected) { if (!el.isConnected) {
terminal.dispose(); terminal.dispose();
instances.delete(el); instances.delete(el);
console.log("[webterm] Cleaned up stale terminal instance"); uiLog.info("Cleaned up stale terminal instance");
} }
} }
}, RESOURCE_CLEANUP_INTERVAL_MS); }, RESOURCE_CLEANUP_INTERVAL_MS);
@@ -4045,7 +4281,7 @@ async function initTerminals(): Promise<void> {
for (const el of containers) { for (const el of containers) {
const wsUrl = el.dataset.sessionWebsocketUrl; const wsUrl = el.dataset.sessionWebsocketUrl;
if (!wsUrl) { if (!wsUrl) {
console.error("[webterm] Missing data-session-websocket-url on terminal container"); bugLog.error("Missing data-session-websocket-url on terminal container");
renderTerminalStartupError(el, "Terminal startup failed: missing websocket URL."); renderTerminalStartupError(el, "Terminal startup failed: missing websocket URL.");
continue; continue;
} }
@@ -4055,7 +4291,7 @@ async function initTerminals(): Promise<void> {
const terminal = await WebTerminal.create(el, wsUrl, config); const terminal = await WebTerminal.create(el, wsUrl, config);
instances.set(el, terminal); instances.set(el, terminal);
} catch (e) { } catch (e) {
console.error("[webterm] Failed to create terminal:", e); bugLog.error("Failed to create terminal", { error: e, wsUrl });
const message = e instanceof Error && e.message ? e.message : "Unknown startup error"; const message = e instanceof Error && e.message ? e.message : "Unknown startup error";
renderTerminalStartupError(el, `Terminal startup failed: ${message}`); renderTerminalStartupError(el, `Terminal startup failed: ${message}`);
} }