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,
"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:fast": "bun build webterm/static/js/terminal.ts --outfile=webterm/static/js/terminal.js --minify --target=browser",
"watch": "bun build webterm/static/js/terminal.ts --outfile=webterm/static/js/terminal.js --watch --target=browser",
"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 --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 --define __WEBTERM_BUILD_VERSION__=\\\"$(cat VERSION 2>/dev/null || echo dev)\\\"",
"typecheck": "bun x tsc --noEmit -p tsconfig.json",
"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"
AuthCookieSecretEnv = "WEBTERM_AUTH_COOKIE_SECRET"
AuthSessionTTLSecondsEnv = "WEBTERM_AUTH_SESSION_TTL_SECONDS"
FrontendLogDirEnv = "WEBTERM_FRONTEND_LOG_DIR"
DockerUsernameEnv = "WEBTERM_DOCKER_USERNAME"
DockerAutoCommandEnv = "WEBTERM_DOCKER_AUTO_COMMAND"
DockerHostEnv = "DOCKER_HOST"
+177 -2
View File
@@ -173,6 +173,7 @@ type LocalServer struct {
fontSize int
screenshotMode string
staticAssetCacheBust string
frontendLogDir string
sessionManager *SessionManager
landingApps []App
@@ -257,6 +258,7 @@ func NewLocalServer(config Config, options ServerOptions) *LocalServer {
fontFamily: options.FontFamily,
fontSize: fontSize,
screenshotMode: screenshotMode,
frontendLogDir: defaultFrontendLogDir(),
sessionManager: NewSessionManager(apps),
landingApps: append([]App{}, options.LandingApps...),
@@ -323,6 +325,95 @@ func findStaticPath() string {
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) {
now := time.Now()
s.mu.Lock()
@@ -527,6 +618,62 @@ func (s *LocalServer) handleAuthLogout(w http.ResponseWriter, r *http.Request) {
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) {
errorBanner := ""
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 activeResultIndex = -1;
let filteredResults = [];
const clientLogSessionID = 'dashboard-' + Math.random().toString(36).slice(2, 14);
const clientBuildVersion = %q;
const floatingResultsEl = document.getElementById('floating-results');
const keyIndicatorEl = document.getElementById('key-indicator');
const thumbnailCache = {};
@@ -1332,6 +1481,31 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
const grid = document.getElementById('grid');
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) {
return bellStoragePrefix + slug;
}
@@ -1797,7 +1971,7 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
}
subtitle.textContent = '';
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) {
const card = makeTile(tile);
@@ -1846,7 +2020,7 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
}
</script>
</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")
_, _ = io.WriteString(w, html)
return
@@ -2000,6 +2174,7 @@ func (s *LocalServer) Handler() http.Handler {
mux.HandleFunc("/cpu-sparkline.svg", s.handleCPUSparkline)
mux.HandleFunc("/events", s.handleEvents)
mux.HandleFunc("/health", s.handleHealth)
mux.HandleFunc("/api/client-log", s.handleFrontendLog)
mux.HandleFunc("/tiles", s.handleTiles)
mux.HandleFunc("/", s.handleRoot)
if strings.TrimSpace(s.staticPath) != "" {
+66
View File
@@ -7,6 +7,8 @@ import (
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"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}}
}
server := NewLocalServer(config, options)
server.frontendLogDir = t.TempDir()
sessions := &syncSessionMap{m: map[string]*fakeSession{}}
server.sessionManager.SetSessionFactory(func(app App, sessionID string) Session {
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) {
server, httpServer, sessions := newServerForTests(t, false)
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";
declare const __WEBTERM_BUILD_VERSION__: string;
/** Maximum queued messages before oldest are dropped */
const MAX_MESSAGE_QUEUE_SIZE = 1000;
/** 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 BELL_EMOJI = "🔔";
const FRONTEND_BUILD_VERSION = __WEBTERM_BUILD_VERSION__;
const DEFAULT_SHERPA_ASSET_DIR = "sherpa-moonshine-v2-base-en";
const VOICE_SAMPLE_RATE = 16_000;
const VOICE_PROCESSOR_BUFFER_SIZE = 4096;
@@ -39,6 +42,7 @@ const VOICE_CANCEL_COMMAND = "cancel text";
type VoiceMode = "live" | "cleanup";
type VoiceFinalizeAction = "insert" | "submit";
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.`;
@@ -111,11 +115,27 @@ type WakeLockSentinelLike = {
};
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 {
CircularBuffer?: new (capacity: number, module: SherpaModule) => SherpaCircularBuffer;
Module?: SherpaModule;
OfflineRecognizer?: new (config: unknown, module: SherpaModule) => SherpaOfflineRecognizer;
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) */
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 */
async function getSharedGhostty(): Promise<Ghostty> {
if (!sharedGhostty) {
const wasmPath = getWasmPath();
console.log("[webterm] Loading shared Ghostty WASM:", wasmPath);
uiLog.info("Loading shared Ghostty WASM", { wasmPath });
sharedGhostty = await Ghostty.load(wasmPath);
}
return sharedGhostty;
@@ -565,7 +705,7 @@ function parseConfig(element: HTMLElement): TerminalConfig {
if (resolved) {
fontFamily = resolved;
} 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;
}
}
@@ -587,7 +727,7 @@ function parseConfig(element: HTMLElement): TerminalConfig {
try {
config.theme = JSON.parse(element.dataset.theme) as ITheme;
} 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 wakeLockSentinel: WakeLockSentinelLike | null = null;
private wakeLockEnabled = false;
private wakeLockButton: HTMLButtonElement | null = null;
private isVoiceStarting = false;
private voiceState: "idle" | "loading" | "listening" | "processing" | "error" | "unsupported" = "idle";
private voiceStartupErrorCleanup: (() => void) | null = null;
@@ -1416,14 +1557,22 @@ class WebTerminal {
try {
const sentinel = await wakeLock.request("screen");
this.wakeLockSentinel = sentinel;
this.syncWakeLockButton();
uiLog.info("Wake lock acquired");
sentinel.addEventListener?.("release", (() => {
this.wakeLockSentinel = null;
this.syncWakeLockButton();
if (this.wakeLockEnabled && !document.hidden) {
void this.acquireWakeLock();
uiLog.info("Wake lock released; waiting for next user gesture to re-enable");
}
}) as EventListener);
} 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 {
await sentinel.release();
uiLog.info("Wake lock released");
} catch {
// 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 {
@@ -1540,9 +1743,6 @@ class WebTerminal {
/** Initialize event handlers and connect */
private initialize(): void {
this.wakeLockEnabled = true;
void this.acquireWakeLock();
// Wait for fonts to load before fitting to ensure correct measurements
//
// FONT INITIALIZATION (ghostty-web):
@@ -1670,20 +1870,20 @@ class WebTerminal {
this.isTabHidden = false;
this.refreshConnection();
restoreFocus();
void this.acquireWakeLock();
this.syncWakeLockButton();
}
});
// Restore focus when browser window regains focus
this.addTrackedListener(window, "focus", () => {
restoreFocus();
void this.acquireWakeLock();
this.syncWakeLockButton();
});
// Safari can restore tabs via bfcache without a focus event.
this.addTrackedListener(window, "pageshow", () => {
restoreFocus();
void this.acquireWakeLock();
this.syncWakeLockButton();
});
}
@@ -1838,6 +2038,12 @@ class WebTerminal {
document,
"keydown",
((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) {
return;
}
@@ -1914,6 +2120,7 @@ class WebTerminal {
// iOS requires focus() to be called synchronously within the gesture
// Don't call terminal.focus() as it steals focus and dismisses keyboard
const focusTextarea = () => {
this.tryAcquireWakeLockFromGesture();
this.focusMobileInput();
};
@@ -2210,7 +2417,7 @@ class WebTerminal {
this.setVoiceState("listening", this.voiceMode === "cleanup" ? "Listening: Cleanup" : "Listening...");
this.focusTerminalInput();
} catch (error) {
console.error("[webterm] Failed to start sherpa voice input:", error);
voiceLog.error("Failed to start sherpa voice input", { error });
this.disconnectVoiceAudio();
this.resetVoiceDraftState();
this.setVoiceState("error", this.describeVoiceError(error));
@@ -2322,7 +2529,7 @@ class WebTerminal {
try {
this.flushVoiceSegments();
} catch (error) {
console.error("[webterm] Failed to flush sherpa voice segments:", error);
voiceLog.error("Failed to flush sherpa voice segments", { error });
}
this.disconnectVoiceAudio();
if (this.voiceMode === "cleanup") {
@@ -2331,7 +2538,7 @@ class WebTerminal {
try {
await this.finalizeVoiceCleanup(action);
} 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));
}
} else {
@@ -2452,7 +2659,7 @@ class WebTerminal {
if (command === "insert" || command === "submit") {
this.disconnectVoiceAudio();
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.focusTerminalInput();
@@ -2565,7 +2772,7 @@ class WebTerminal {
if (!message || message === "Voice error") {
return;
}
console.error("[webterm] Captured voice startup failure:", error);
voiceLog.error("Captured voice startup failure", { error, message });
this.setVoiceState("error", message);
};
@@ -2800,18 +3007,16 @@ class WebTerminal {
return;
}
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 {
if (typeof window === "undefined" || typeof navigator === "undefined") {
if (typeof window === "undefined") {
return 0;
}
const standalone =
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;
return MOBILE_PWA_BOTTOM_INSET_PX;
}
private updateMobileKeyboardDockLayout(): void {
@@ -2819,18 +3024,16 @@ class WebTerminal {
if (this.mobileVirtualKeyboardHost) {
this.mobileVirtualKeyboardHost.style.bottom = "0";
this.mobileVirtualKeyboardHost.style.paddingBottom = "0";
this.mobileVirtualKeyboardHost.style.transform =
safeAreaBottom > 0 ? `translateY(-${safeAreaBottom}px)` : "";
this.mobileVirtualKeyboardHost.style.transform = "";
}
if (this.mobileKeybar) {
const keyboardHeight = this.mobileVirtualKeyboardHost?.offsetHeight ?? 0;
this.mobileKeybar.style.bottom = `${keyboardHeight}px`;
this.mobileKeybar.style.paddingBottom = "0";
this.mobileKeybar.style.transform =
safeAreaBottom > 0 ? `translateY(-${safeAreaBottom}px)` : "";
this.mobileKeybar.style.transform = "";
}
const keyboardHeight = this.mobileKeyboardVisible
? (this.mobileKeybar?.offsetHeight ?? 0) + (this.mobileVirtualKeyboardHost?.offsetHeight ?? 0) + safeAreaBottom
? (this.mobileKeybar?.offsetHeight ?? 0) + (this.mobileVirtualKeyboardHost?.offsetHeight ?? 0)
: 0;
this.element.style.paddingBottom = `${keyboardHeight}px`;
}
@@ -2875,11 +3078,12 @@ class WebTerminal {
}
const layout = this.currentVirtualKeyboardLayout();
const scale = this.getVirtualKeyboardScale(rect.width);
const bottomInset = this.getMobileKeyboardBottomInset();
const compactRatio = Math.max(0, Math.min(1, (rect.width - 260) / 140));
const padding = (2 + (10 - 2) * compactRatio) / scale;
const gap = (2 + (8 - 2) * compactRatio) / scale;
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 bounds: VirtualKeyboardKeyBounds[] = [];
let currentY = padding;
@@ -3290,6 +3494,8 @@ class WebTerminal {
<button class="keybar-font-grow" title="Larger font">A+</button>
<span class="keybar-label">Voice</span>
<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);
@@ -3451,6 +3657,20 @@ class WebTerminal {
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
keysPanel.querySelectorAll("button[data-key]").forEach((btn) => {
btn.addEventListener("touchstart", (e) => {
@@ -3704,7 +3924,7 @@ class WebTerminal {
this.lastPongAt = Date.now();
break;
default:
console.debug("Unknown message type:", type);
wsLog.debug("Unknown WebSocket message type", { type });
}
} catch {
if (this.isTabHidden) {
@@ -3751,7 +3971,11 @@ class WebTerminal {
const now = Date.now();
const lastInbound = Math.max(this.lastMessageAt, this.lastPongAt);
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();
return;
}
@@ -3786,7 +4010,7 @@ class WebTerminal {
if (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);
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) {
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.processMessageQueue();
@@ -3884,7 +4111,10 @@ class WebTerminal {
this.socket.send(JSON.stringify(message));
}
} catch (e) {
console.error("Failed to send message:", e, message);
wsLog.error("Failed to send WebSocket message", {
error: e,
messageType: message?.[0],
});
if (message) {
this.messageQueue.unshift(message);
}
@@ -3896,7 +4126,10 @@ class WebTerminal {
/** Schedule reconnection attempt */
private scheduleReconnect(): void {
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.");
return;
}
@@ -3905,7 +4138,10 @@ class WebTerminal {
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
setTimeout(() => {
console.log(`[webterm] Reconnecting (attempt ${this.reconnectAttempts})...`);
wsLog.info("Reconnecting WebSocket", {
attempt: this.reconnectAttempts,
delayMs: delay,
});
this.connect();
}, delay);
}
@@ -4033,7 +4269,7 @@ setInterval(() => {
if (!el.isConnected) {
terminal.dispose();
instances.delete(el);
console.log("[webterm] Cleaned up stale terminal instance");
uiLog.info("Cleaned up stale terminal instance");
}
}
}, RESOURCE_CLEANUP_INTERVAL_MS);
@@ -4045,7 +4281,7 @@ async function initTerminals(): Promise<void> {
for (const el of containers) {
const wsUrl = el.dataset.sessionWebsocketUrl;
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.");
continue;
}
@@ -4055,7 +4291,7 @@ async function initTerminals(): Promise<void> {
const terminal = await WebTerminal.create(el, wsUrl, config);
instances.set(el, terminal);
} 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";
renderTerminalStartupError(el, `Terminal startup failed: ${message}`);
}