6c273feda7
Add service worker that enables full offline launch and persistent caching of large Moonshine voice model assets (136MB .data file). - sw.js: stale-while-revalidate for app shell (HTML, JS, CSS, fonts); cache-first for sherpa model files and ghostty WASM; old shell caches deleted on SW activate to reclaim storage after updates - server.go: /sw.js route injects current staticAssetCacheBust version into SW content so browser detects new SW on each deploy; new SW pre-caches versioned terminal.js during install so update is ready before next launch; SW registration added to dashboard HTML - terminal.ts: register /sw.js on load; detect navigator.onLine at startup; listen for online/offline events; pause reconnect loop when offline instead of exhausting attempts; resume with reset attempts when network returns; show "Offline. Will reconnect..." instead of misleading WebSocket error Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3414 lines
107 KiB
TypeScript
3414 lines
107 KiB
TypeScript
/**
|
||
* ghostty-web terminal client for webterm.
|
||
*
|
||
* Implements the WebSocket protocol compatible with local_server.py:
|
||
* - Client → Server: ["stdin", data], ["resize", {width, height}], ["ping", data]
|
||
* - Server → Client: ["stdout", data], ["pong", data], or binary frames
|
||
*/
|
||
|
||
import { Terminal, FitAddon, Ghostty, type ITerminalOptions, type ITheme } from "ghostty-web";
|
||
|
||
import { bugLog, uiLog, voiceLog, wsLog } from "./frontend-log";
|
||
import { type TerminalConfig, parseConfig } from "./terminal-config";
|
||
import { formatSherpaStatus, getStaticJSBasePath, getWasmPath, loadScriptOnce, sharedTextDecoder } from "./terminal-assets";
|
||
import {
|
||
ActiveVirtualKeyboardPress,
|
||
applyAltModifier,
|
||
applyCtrlModifier,
|
||
applyFnModifier,
|
||
applyModifiers,
|
||
isMobileDevice,
|
||
VIRTUAL_KEYBOARD_ALPHA_LAYOUT,
|
||
VIRTUAL_KEYBOARD_SYMBOL_LAYOUT,
|
||
type VirtualKeyboardKey,
|
||
type VirtualKeyboardKeyBounds,
|
||
} from "./terminal-input";
|
||
import { DEFAULT_FONT_FAMILY, THEMES } from "./terminal-themes";
|
||
|
||
/** Maximum queued messages before oldest are dropped */
|
||
const MAX_MESSAGE_QUEUE_SIZE = 1000;
|
||
/** How often to run periodic resource cleanup (ms) */
|
||
const RESOURCE_CLEANUP_INTERVAL_MS = 30_000;
|
||
/** Maximum bytes to buffer while the tab is hidden (256 KB) */
|
||
const MAX_HIDDEN_BUFFER_BYTES = 256 * 1024;
|
||
|
||
/** Batch stdin writes to reduce per-keystroke overhead and avoid WS/PTY backlogs */
|
||
const STDIN_BATCH_DELAY_MS = 10;
|
||
/** Flush stdin batch when it gets large (e.g. paste) */
|
||
const STDIN_BATCH_MAX_CHARS = 8192;
|
||
|
||
const BELL_EMOJI = "🔔";
|
||
const DEFAULT_SHERPA_ASSET_DIR = "sherpa-moonshine-v2-base-en";
|
||
const VOICE_SAMPLE_RATE = 16_000;
|
||
const VOICE_PROCESSOR_BUFFER_SIZE = 4096;
|
||
const VOICE_VAD_WINDOW_SIZE = 512;
|
||
const VOICE_BUFFER_SIZE_SECONDS = 30;
|
||
const VOICE_STATUS_MAX_LENGTH = 48;
|
||
const DEFAULT_VOICE_LLM_BASE_URL = "/llm";
|
||
const VOICE_LLM_TIMEOUT_MS = 180_000;
|
||
const VOICE_MODE_STORAGE_KEY = "webterm:voice-mode";
|
||
const MOBILE_PWA_BOTTOM_INSET_PX = 20;
|
||
|
||
const VOICE_INSERT_COMMAND = "insert text";
|
||
const VOICE_SUBMIT_COMMAND = "submit text";
|
||
const VOICE_CANCEL_COMMAND = "cancel text";
|
||
|
||
type VoiceMode = "live" | "cleanup";
|
||
type VoiceFinalizeAction = "insert" | "submit";
|
||
type VoiceCommandAction = VoiceFinalizeAction | "cancel";
|
||
|
||
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.`;
|
||
|
||
type SherpaModule = {
|
||
HEAPF32: Float32Array;
|
||
HEAP32: Int32Array;
|
||
_free(ptr: number): void;
|
||
_malloc(size: number): number;
|
||
_SherpaOnnxFileExists(ptr: number): number;
|
||
lengthBytesUTF8(text: string): number;
|
||
stringToUTF8(text: string, buffer: number, length: number): void;
|
||
locateFile?: (path: string, scriptDirectory?: string) => string;
|
||
onRuntimeInitialized?: () => void;
|
||
setStatus?: (status: string) => void;
|
||
};
|
||
|
||
type SherpaCircularBuffer = {
|
||
free(): void;
|
||
get(startIndex: number, n: number): Float32Array;
|
||
head(): number;
|
||
pop(n: number): void;
|
||
push(samples: Float32Array): void;
|
||
reset(): void;
|
||
size(): number;
|
||
};
|
||
|
||
type SherpaVad = {
|
||
free(): void;
|
||
acceptWaveform(samples: Float32Array): void;
|
||
flush(): void;
|
||
front(): { samples: Float32Array; start: number };
|
||
isDetected(): boolean;
|
||
isEmpty(): boolean;
|
||
pop(): void;
|
||
reset(): void;
|
||
};
|
||
|
||
type SherpaOfflineRecognizer = {
|
||
free(): void;
|
||
createStream(): SherpaOfflineStream;
|
||
decode(stream: SherpaOfflineStream): void;
|
||
getResult(stream: SherpaOfflineStream): { text?: string };
|
||
};
|
||
|
||
type SherpaOfflineStream = {
|
||
free(): void;
|
||
acceptWaveform(sampleRate: number, samples: Float32Array): void;
|
||
};
|
||
|
||
type SherpaRuntime = {
|
||
module: SherpaModule;
|
||
CircularBuffer: new (capacity: number, module: SherpaModule) => SherpaCircularBuffer;
|
||
OfflineRecognizer: new (config: unknown, module: SherpaModule) => SherpaOfflineRecognizer;
|
||
createVad: (module: SherpaModule, config?: unknown) => SherpaVad;
|
||
};
|
||
|
||
type OpenAIChatMessage = {
|
||
role: "system" | "user" | "assistant";
|
||
content: string;
|
||
};
|
||
|
||
type OpenAIModelsResponse = {
|
||
data?: Array<{ id?: string }>;
|
||
};
|
||
|
||
type WakeLockSentinelLike = {
|
||
released?: boolean;
|
||
release: () => Promise<void>;
|
||
addEventListener?: (type: "release", listener: EventListenerOrEventListenerObject) => void;
|
||
};
|
||
|
||
declare global {
|
||
interface Window {
|
||
CircularBuffer?: new (capacity: number, module: SherpaModule) => SherpaCircularBuffer;
|
||
Module?: SherpaModule;
|
||
OfflineRecognizer?: new (config: unknown, module: SherpaModule) => SherpaOfflineRecognizer;
|
||
createVad?: (module: SherpaModule, config?: unknown) => SherpaVad;
|
||
}
|
||
}
|
||
|
||
let sharedSherpaRuntimePromise: Promise<SherpaRuntime> | null = null;
|
||
|
||
/** Shared Ghostty WASM instance (loaded once, reused across all terminals) */
|
||
let sharedGhostty: Ghostty | null = null;
|
||
|
||
/** Load or reuse the shared Ghostty WASM instance */
|
||
async function getSharedGhostty(): Promise<Ghostty> {
|
||
if (!sharedGhostty) {
|
||
const wasmPath = getWasmPath();
|
||
uiLog.info("Loading shared Ghostty WASM", { wasmPath });
|
||
sharedGhostty = await Ghostty.load(wasmPath);
|
||
}
|
||
return sharedGhostty;
|
||
}
|
||
|
||
async function getSharedSherpaRuntime(
|
||
assetBase: string,
|
||
onStatus: (status: string) => void
|
||
): Promise<SherpaRuntime> {
|
||
if (!sharedSherpaRuntimePromise) {
|
||
sharedSherpaRuntimePromise = new Promise<SherpaRuntime>((resolve, reject) => {
|
||
const module: SherpaModule = {
|
||
HEAP32: new Int32Array(),
|
||
HEAPF32: new Float32Array(),
|
||
_free: () => undefined,
|
||
_malloc: () => 0,
|
||
_SherpaOnnxFileExists: () => 0,
|
||
lengthBytesUTF8: () => 0,
|
||
stringToUTF8: () => undefined,
|
||
locateFile: (path: string) => assetBase + path,
|
||
onRuntimeInitialized: () => {
|
||
if (!window.OfflineRecognizer || !window.createVad || !window.CircularBuffer) {
|
||
reject(new Error("sherpa-onnx globals missing after runtime initialization"));
|
||
return;
|
||
}
|
||
resolve({
|
||
module,
|
||
CircularBuffer: window.CircularBuffer,
|
||
OfflineRecognizer: window.OfflineRecognizer,
|
||
createVad: window.createVad,
|
||
});
|
||
},
|
||
setStatus: (status: string) => {
|
||
const formatted = formatSherpaStatus(status);
|
||
if (formatted) {
|
||
onStatus(formatted);
|
||
}
|
||
},
|
||
};
|
||
|
||
window.Module = module;
|
||
(globalThis as { Module?: SherpaModule }).Module = module;
|
||
|
||
void (async () => {
|
||
try {
|
||
await loadScriptOnce(assetBase + "sherpa-onnx-asr.js");
|
||
await loadScriptOnce(assetBase + "sherpa-onnx-vad.js");
|
||
await loadScriptOnce(assetBase + "sherpa-onnx-wasm-main-vad-asr.js");
|
||
} catch (error) {
|
||
sharedSherpaRuntimePromise = null;
|
||
reject(error);
|
||
}
|
||
})();
|
||
});
|
||
}
|
||
|
||
onStatus("Loading runtime...");
|
||
return sharedSherpaRuntimePromise;
|
||
}
|
||
|
||
/**
|
||
* WebTerminal - wraps ghostty-web with WebSocket communication.
|
||
*/
|
||
class WebTerminal {
|
||
private terminal: Terminal;
|
||
private fitAddon: FitAddon;
|
||
private socket: WebSocket | null = null;
|
||
private socketGeneration = 0;
|
||
private element: HTMLElement;
|
||
private wsUrl: string;
|
||
private reconnectAttempts = 0;
|
||
private maxReconnectAttempts = 5;
|
||
private reconnectDelay = 1000;
|
||
private isOffline = !navigator.onLine;
|
||
private offlinePendingReconnect = false;
|
||
private heartbeatIntervalMs = 15000;
|
||
private stallTimeoutMs = 45000;
|
||
private heartbeatTimer: number | undefined;
|
||
private lastMessageAt = 0;
|
||
private lastPongAt = 0;
|
||
private messageQueue: [string, unknown][] = [];
|
||
|
||
// Stdin batching (coalesces key repeats into fewer WS frames)
|
||
private pendingStdin = "";
|
||
private pendingStdinTimer: number | undefined;
|
||
|
||
private lastValidSize: { cols: number; rows: number } | null = null;
|
||
private mobileInput: HTMLTextAreaElement | null = null;
|
||
private mobileKeybar: HTMLElement | null = null;
|
||
private mobileVirtualKeyboardHost: HTMLDivElement | null = null;
|
||
private mobileVirtualKeyboard: HTMLCanvasElement | null = null;
|
||
private mobileKeyboardVisible = false;
|
||
private mobileKeyboardMode: "alpha" | "symbol" = "alpha";
|
||
private mobileVirtualKeyboardBounds: VirtualKeyboardKeyBounds[] = [];
|
||
private mobileVirtualKeyboardActivePresses = new Map<number, ActiveVirtualKeyboardPress>();
|
||
private mobileVirtualKeyboardDrawFrame: number | null = null;
|
||
private ctrlActive = false;
|
||
private altActive = false;
|
||
private shiftActive = false;
|
||
private fnActive = false;
|
||
private pendingCtrl = false;
|
||
private pendingAlt = false;
|
||
private pendingShift = false;
|
||
private pendingFn = false;
|
||
private allowMobileKeyboardOpen = false;
|
||
private fontFamily: string;
|
||
private fontSize: number;
|
||
private cleanupTimer: number | undefined;
|
||
private resizeObserver: ResizeObserver | null = null;
|
||
private mobileKeybarStyle: HTMLStyleElement | null = null;
|
||
private errorOverlay: HTMLDivElement | null = null;
|
||
private boundHandlers: { target: EventTarget; type: string; handler: EventListener; options?: boolean | AddEventListenerOptions }[] = [];
|
||
private isTabHidden = false;
|
||
private hiddenBuffer: Uint8Array[] = [];
|
||
private hiddenBufferBytes = 0;
|
||
private baseTitle: string;
|
||
private bellActive = false;
|
||
private routeKey: string;
|
||
private voiceControls: HTMLElement | null = null;
|
||
private voiceStatus: HTMLElement | null = null;
|
||
private voiceRecognizer: SherpaOfflineRecognizer | null = null;
|
||
private voiceVad: SherpaVad | null = null;
|
||
private voiceBuffer: SherpaCircularBuffer | null = null;
|
||
private voiceAudioContext: AudioContext | null = null;
|
||
private voiceMediaStreamSource: MediaStreamAudioSourceNode | null = null;
|
||
private voiceProcessor: ScriptProcessorNode | null = null;
|
||
private voiceSilentGain: GainNode | null = null;
|
||
private voiceInputStream: MediaStream | null = null;
|
||
private voiceAssetBase = `${getStaticJSBasePath()}${DEFAULT_SHERPA_ASSET_DIR}/`;
|
||
private voiceLlmBaseUrl = "";
|
||
private voiceLlmModelOverride = "";
|
||
private voiceLlmDetectedModel: string | null = null;
|
||
private voiceMode: VoiceMode = "live";
|
||
private voiceSpeechDetected = false;
|
||
private voiceReceivedAudio = false;
|
||
private voicePendingSeparator = false;
|
||
private voiceDraftTranscript = "";
|
||
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;
|
||
private static sharedTextEncoder = new TextEncoder();
|
||
|
||
private constructor(
|
||
container: HTMLElement,
|
||
wsUrl: string,
|
||
terminal: Terminal,
|
||
fitAddon: FitAddon,
|
||
fontFamily: string,
|
||
fontSize: number,
|
||
routeKey: string,
|
||
baseTitle: string
|
||
) {
|
||
this.element = container;
|
||
this.wsUrl = wsUrl;
|
||
this.terminal = terminal;
|
||
this.fitAddon = fitAddon;
|
||
this.fontFamily = fontFamily;
|
||
this.fontSize = fontSize;
|
||
this.routeKey = routeKey;
|
||
this.baseTitle = baseTitle;
|
||
}
|
||
|
||
/** Register an event listener that will be removed on dispose */
|
||
private addTrackedListener(
|
||
target: EventTarget,
|
||
type: string,
|
||
handler: EventListener,
|
||
options?: boolean | AddEventListenerOptions
|
||
): void {
|
||
target.addEventListener(type, handler, options);
|
||
this.boundHandlers.push({ target, type, handler, options });
|
||
}
|
||
|
||
private disableNativeMobileTerminalInput(): void {
|
||
if (!this.usesVirtualKeyboard()) {
|
||
return;
|
||
}
|
||
|
||
const ghosttyTerminal = this.terminal as unknown as {
|
||
textarea?: HTMLTextAreaElement;
|
||
};
|
||
const textarea = ghosttyTerminal.textarea;
|
||
if (!textarea) {
|
||
return;
|
||
}
|
||
|
||
textarea.readOnly = true;
|
||
textarea.setAttribute("readonly", "true");
|
||
textarea.setAttribute("inputmode", "none");
|
||
textarea.setAttribute("tabindex", "-1");
|
||
textarea.setAttribute("aria-hidden", "true");
|
||
textarea.blur();
|
||
}
|
||
|
||
private async copyCurrentSelectionToClipboard(): Promise<boolean> {
|
||
const terminal = this.terminal as unknown as {
|
||
getSelection?: () => string;
|
||
hasSelection?: () => boolean;
|
||
};
|
||
const text =
|
||
(terminal.hasSelection?.() ? terminal.getSelection?.() : "") ||
|
||
document.getSelection()?.toString() ||
|
||
"";
|
||
if (!text) {
|
||
return false;
|
||
}
|
||
await navigator.clipboard?.writeText(text);
|
||
this.showUserError("Copied selection");
|
||
window.setTimeout(() => {
|
||
this.clearUserError();
|
||
}, 1200);
|
||
return true;
|
||
}
|
||
|
||
private bellStorageKey(): string | null {
|
||
if (!this.routeKey) {
|
||
return null;
|
||
}
|
||
return `webterm:bell:${this.routeKey}`;
|
||
}
|
||
|
||
private bellSuppressUntil = 0;
|
||
|
||
private setBellActive(): void {
|
||
// Suppress bells briefly after returning to the tab so that
|
||
// buffered output processed on visibility change doesn't
|
||
// immediately re-trigger the indicator we just cleared.
|
||
if (Date.now() < this.bellSuppressUntil) return;
|
||
|
||
if (!this.bellActive) {
|
||
this.bellActive = true;
|
||
document.title = `${BELL_EMOJI} ${this.baseTitle}`;
|
||
}
|
||
const key = this.bellStorageKey();
|
||
if (key) {
|
||
localStorage.setItem(key, String(Date.now()));
|
||
}
|
||
}
|
||
|
||
private clearBellState(): void {
|
||
// Suppress further bells for a short window so that buffered
|
||
// output arriving right after focus doesn't re-set the indicator.
|
||
this.bellSuppressUntil = Date.now() + 1000;
|
||
|
||
const key = this.bellStorageKey();
|
||
if (key) {
|
||
localStorage.removeItem(key);
|
||
}
|
||
if (!this.bellActive) {
|
||
return;
|
||
}
|
||
this.bellActive = false;
|
||
document.title = this.baseTitle;
|
||
}
|
||
|
||
private loadVoiceModePreference(): VoiceMode {
|
||
try {
|
||
const stored = localStorage.getItem(VOICE_MODE_STORAGE_KEY);
|
||
return stored === "cleanup" ? "cleanup" : "live";
|
||
} catch {
|
||
return "live";
|
||
}
|
||
}
|
||
|
||
private persistVoiceModePreference(): void {
|
||
try {
|
||
localStorage.setItem(VOICE_MODE_STORAGE_KEY, this.voiceMode);
|
||
} catch {
|
||
// Ignore storage failures in private browsing or restricted contexts.
|
||
}
|
||
}
|
||
|
||
private setVoiceMode(mode: VoiceMode): void {
|
||
this.voiceMode = mode;
|
||
this.persistVoiceModePreference();
|
||
this.syncModifierButtons();
|
||
const configError = this.getVoiceLlmConfigError();
|
||
if (configError) {
|
||
this.setVoiceState("error", configError);
|
||
return;
|
||
}
|
||
if (this.voiceState === "idle" || this.voiceState === "error") {
|
||
this.setVoiceState("idle", mode === "cleanup" ? "Ready: Cleanup" : "Ready: Live");
|
||
}
|
||
}
|
||
|
||
private normalizeVoiceCommandText(value: string): string {
|
||
return value
|
||
.toLowerCase()
|
||
.replace(/[\s]+/g, " ")
|
||
.replace(/[.,!?;:]+$/g, "")
|
||
.trim();
|
||
}
|
||
|
||
private getVoiceCommandAction(transcript: string): VoiceCommandAction | null {
|
||
const normalized = this.normalizeVoiceCommandText(transcript);
|
||
if (!normalized) {
|
||
return null;
|
||
}
|
||
if (normalized === VOICE_INSERT_COMMAND || normalized.endsWith(` ${VOICE_INSERT_COMMAND}`)) {
|
||
return "insert";
|
||
}
|
||
if (normalized === VOICE_SUBMIT_COMMAND || normalized.endsWith(` ${VOICE_SUBMIT_COMMAND}`)) {
|
||
return "submit";
|
||
}
|
||
if (normalized === VOICE_CANCEL_COMMAND || normalized.endsWith(` ${VOICE_CANCEL_COMMAND}`)) {
|
||
return "cancel";
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private stripVoiceCommandSuffix(transcript: string): string {
|
||
let value = transcript.trim();
|
||
const patterns = [
|
||
new RegExp(`(?:^|\\s)${VOICE_INSERT_COMMAND}[\\s,.!?;:]*$`, "i"),
|
||
new RegExp(`(?:^|\\s)${VOICE_SUBMIT_COMMAND}[\\s,.!?;:]*$`, "i"),
|
||
new RegExp(`(?:^|\\s)${VOICE_CANCEL_COMMAND}[\\s,.!?;:]*$`, "i"),
|
||
];
|
||
for (const pattern of patterns) {
|
||
value = value.replace(pattern, "").trim();
|
||
}
|
||
return value;
|
||
}
|
||
|
||
private appendVoiceDraftSegment(transcript: string): string {
|
||
const next = this.formatVoiceTranscriptForInsert(transcript);
|
||
this.voiceDraftTranscript += next;
|
||
return this.voiceDraftTranscript;
|
||
}
|
||
|
||
private voiceDraftPreview(limit = 40): string {
|
||
const text = this.voiceDraftTranscript.trim();
|
||
if (!text) {
|
||
return "Draft empty";
|
||
}
|
||
return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
|
||
}
|
||
|
||
private getVoiceLlmConfigError(): string | null {
|
||
if (this.voiceMode !== "cleanup") {
|
||
return null;
|
||
}
|
||
if (!this.voiceLlmBaseUrl) {
|
||
return "Cleanup mode missing LLM URL";
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private async resolveVoiceLlmModel(): Promise<string> {
|
||
if (this.voiceLlmModelOverride) {
|
||
return this.voiceLlmModelOverride;
|
||
}
|
||
if (this.voiceLlmDetectedModel) {
|
||
return this.voiceLlmDetectedModel;
|
||
}
|
||
|
||
const controller = new AbortController();
|
||
const timeoutId = window.setTimeout(() => controller.abort(), VOICE_LLM_TIMEOUT_MS);
|
||
try {
|
||
const response = await fetch(`${this.voiceLlmBaseUrl}/v1/models`, {
|
||
method: "GET",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
signal: controller.signal,
|
||
});
|
||
if (!response.ok) {
|
||
const body = await response.text().catch(() => "");
|
||
throw new Error(`LLM model discovery failed: ${response.status}${body ? ` ${body}` : ""}`);
|
||
}
|
||
const payload = await response.json() as OpenAIModelsResponse;
|
||
const model = payload.data?.find((entry) => typeof entry.id === "string" && entry.id.trim())?.id?.trim();
|
||
if (!model) {
|
||
throw new Error("LLM model discovery returned no models");
|
||
}
|
||
this.voiceLlmDetectedModel = model;
|
||
return model;
|
||
} finally {
|
||
clearTimeout(timeoutId);
|
||
}
|
||
}
|
||
|
||
private async requestVoiceChatCompletion(messages: OpenAIChatMessage[]): Promise<string> {
|
||
const model = await this.resolveVoiceLlmModel();
|
||
const controller = new AbortController();
|
||
const timeoutId = window.setTimeout(() => controller.abort(), VOICE_LLM_TIMEOUT_MS);
|
||
try {
|
||
const response = await fetch(`${this.voiceLlmBaseUrl}/v1/chat/completions`, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
model,
|
||
messages,
|
||
stream: false,
|
||
}),
|
||
signal: controller.signal,
|
||
});
|
||
if (!response.ok) {
|
||
const body = await response.text().catch(() => "");
|
||
throw new Error(`LLM request failed: ${response.status}${body ? ` ${body}` : ""}`);
|
||
}
|
||
const payload = await response.json() as {
|
||
choices?: Array<{ message?: { content?: string | Array<{ text?: string }> } }>;
|
||
};
|
||
const content = payload.choices?.[0]?.message?.content;
|
||
if (typeof content === "string") {
|
||
return content.trim();
|
||
}
|
||
if (Array.isArray(content)) {
|
||
return content
|
||
.map((item) => item.text ?? "")
|
||
.join("")
|
||
.trim();
|
||
}
|
||
throw new Error("LLM response missing content");
|
||
} finally {
|
||
clearTimeout(timeoutId);
|
||
}
|
||
}
|
||
|
||
private async cleanupVoiceTranscript(rawTranscript: string): Promise<string> {
|
||
const finalUser = `<transcript>\n${rawTranscript}\n</transcript>\n\nClean up the transcript now:`;
|
||
return this.requestVoiceChatCompletion([
|
||
{ role: "system", content: VOICE_CLEANUP_SYSTEM_PROMPT },
|
||
{ role: "user", content: finalUser },
|
||
]);
|
||
}
|
||
|
||
private async finalizeVoiceCleanup(action: VoiceFinalizeAction): Promise<void> {
|
||
const finalizeToken = ++this.voiceFinalizeToken;
|
||
const rawTranscript = this.stripVoiceCommandSuffix(this.voiceDraftTranscript);
|
||
if (!rawTranscript) {
|
||
this.resetVoiceDraftState();
|
||
this.setVoiceState("idle", "Ready: Cleanup");
|
||
return;
|
||
}
|
||
|
||
this.setVoiceState("processing", "Cleaning...");
|
||
const cleaned = (await this.cleanupVoiceTranscript(rawTranscript)).trim();
|
||
if (finalizeToken !== this.voiceFinalizeToken) {
|
||
return;
|
||
}
|
||
|
||
this.resetVoiceDraftState();
|
||
if (!cleaned) {
|
||
this.setVoiceState("idle", "Ready: Cleanup");
|
||
return;
|
||
}
|
||
|
||
this.sendStdin(cleaned);
|
||
if (action === "submit") {
|
||
this.sendStdin("\r");
|
||
this.setVoiceState("idle", `Submitted: ${cleaned}`);
|
||
return;
|
||
}
|
||
this.setVoiceState("idle", `Inserted: ${cleaned}`);
|
||
}
|
||
|
||
private resetVoiceDraftState(): void {
|
||
this.voiceDraftTranscript = "";
|
||
this.voicePendingSeparator = false;
|
||
}
|
||
|
||
private async acquireWakeLock(): Promise<void> {
|
||
if (!this.wakeLockEnabled || typeof document === "undefined" || document.hidden) {
|
||
return;
|
||
}
|
||
const wakeLock = (navigator as Navigator & {
|
||
wakeLock?: { request(type: "screen"): Promise<WakeLockSentinelLike> };
|
||
}).wakeLock;
|
||
if (!wakeLock?.request) {
|
||
this.wakeLockEnabled = false;
|
||
return;
|
||
}
|
||
if (this.wakeLockSentinel && !this.wakeLockSentinel.released) {
|
||
return;
|
||
}
|
||
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) {
|
||
uiLog.info("Wake lock released; waiting for next user gesture to re-enable");
|
||
}
|
||
}) as EventListener);
|
||
} catch (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);
|
||
}
|
||
}
|
||
|
||
private async releaseWakeLock(): Promise<void> {
|
||
const sentinel = this.wakeLockSentinel;
|
||
this.wakeLockSentinel = null;
|
||
if (!sentinel) {
|
||
return;
|
||
}
|
||
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 {
|
||
if (this.errorOverlay) {
|
||
return this.errorOverlay;
|
||
}
|
||
|
||
if (window.getComputedStyle(this.element).position === "static") {
|
||
this.element.style.position = "relative";
|
||
}
|
||
|
||
const overlay = document.createElement("div");
|
||
overlay.style.cssText = `
|
||
position: absolute;
|
||
inset: 12px 12px auto 12px;
|
||
z-index: 30;
|
||
display: none;
|
||
padding: 10px 12px;
|
||
border-radius: 8px;
|
||
background: rgba(95, 15, 15, 0.92);
|
||
border: 1px solid rgba(255, 120, 120, 0.45);
|
||
color: #fff3f3;
|
||
font: 13px/1.4 system-ui, sans-serif;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||
pointer-events: none;
|
||
`;
|
||
this.element.appendChild(overlay);
|
||
this.errorOverlay = overlay;
|
||
return overlay;
|
||
}
|
||
|
||
private showUserError(message: string): void {
|
||
const overlay = this.ensureErrorOverlay();
|
||
overlay.textContent = message;
|
||
overlay.style.display = "block";
|
||
}
|
||
|
||
private clearUserError(): void {
|
||
if (!this.errorOverlay) {
|
||
return;
|
||
}
|
||
this.errorOverlay.textContent = "";
|
||
this.errorOverlay.style.display = "none";
|
||
}
|
||
|
||
/** Create and initialize a WebTerminal instance */
|
||
static async create(
|
||
container: HTMLElement,
|
||
wsUrl: string,
|
||
config: TerminalConfig
|
||
): Promise<WebTerminal> {
|
||
const ghostty = await getSharedGhostty();
|
||
|
||
// Build terminal options
|
||
const themeToUse = config.theme ?? THEMES.tango;
|
||
const fontFamily = config.fontFamily?.trim() || DEFAULT_FONT_FAMILY;
|
||
const fontSize = config.fontSize ?? 16;
|
||
|
||
const defaultScrollback = isMobileDevice() ? 200 : 1000;
|
||
const options: ITerminalOptions = {
|
||
fontFamily,
|
||
fontSize,
|
||
scrollback: config.scrollback ?? defaultScrollback,
|
||
cursorBlink: true,
|
||
cursorStyle: "block",
|
||
theme: themeToUse,
|
||
ghostty,
|
||
};
|
||
|
||
const terminal = new Terminal(options);
|
||
const fitAddon = new FitAddon();
|
||
terminal.loadAddon(fitAddon);
|
||
|
||
// Open terminal (initializes rendering - WASM already loaded)
|
||
terminal.open(container);
|
||
|
||
const routeKey = container.dataset.sessionRouteKey
|
||
?? new URLSearchParams(window.location.search).get("route_key")
|
||
?? "";
|
||
const rawTitle = container.dataset.sessionName?.trim() || document.title;
|
||
const baseTitle = rawTitle.startsWith(`${BELL_EMOJI} `)
|
||
? rawTitle.slice(BELL_EMOJI.length + 1)
|
||
: rawTitle;
|
||
const instance = new WebTerminal(
|
||
container,
|
||
wsUrl,
|
||
terminal,
|
||
fitAddon,
|
||
fontFamily,
|
||
fontSize,
|
||
routeKey,
|
||
baseTitle
|
||
);
|
||
instance.disableNativeMobileTerminalInput();
|
||
instance.initialize();
|
||
return instance;
|
||
}
|
||
|
||
/** Initialize event handlers and connect */
|
||
private initialize(): void {
|
||
// Wait for fonts to load before fitting to ensure correct measurements
|
||
//
|
||
// FONT INITIALIZATION (ghostty-web):
|
||
// The font stack is set in two places:
|
||
// 1. At Terminal construction time via ITerminalOptions.fontFamily
|
||
// 2. After web fonts load via terminal.loadFonts()
|
||
// - Re-measures font metrics and triggers a full re-render
|
||
this.waitForFonts().then(() => {
|
||
if (typeof (this.terminal as unknown as { loadFonts?: () => void }).loadFonts === "function") {
|
||
(this.terminal as unknown as { loadFonts: () => void }).loadFonts();
|
||
}
|
||
this.fit();
|
||
});
|
||
|
||
// Setup resize observer (we use our own fit method, not FitAddon's)
|
||
this.setupResizeObserver();
|
||
|
||
// Handle window resize (some browsers don't trigger ResizeObserver on window resize)
|
||
this.addTrackedListener(window, "resize", () => {
|
||
this.fit();
|
||
});
|
||
|
||
// Handle terminal input
|
||
this.terminal.onData((data) => {
|
||
this.clearBellState();
|
||
this.sendStdin(data);
|
||
});
|
||
|
||
this.terminal.onBell(() => {
|
||
this.setBellActive();
|
||
});
|
||
|
||
// Clear bell on any mouse interaction with the terminal
|
||
this.addTrackedListener(this.element, "mousedown", () => {
|
||
if (this.bellActive) this.clearBellState();
|
||
});
|
||
|
||
// Handle resize
|
||
this.terminal.onResize((size) => {
|
||
if (this.isValidSize(size.cols, size.rows)) {
|
||
this.lastValidSize = { cols: size.cols, rows: size.rows };
|
||
this.send(["resize", { width: size.cols, height: size.rows }]);
|
||
}
|
||
});
|
||
|
||
// Setup native mobile keyboard support only when virtual keyboard is disabled.
|
||
if (!this.usesVirtualKeyboard()) {
|
||
this.setupMobileKeyboard();
|
||
}
|
||
this.addTrackedListener(
|
||
document,
|
||
"focusin",
|
||
((event: FocusEvent) => {
|
||
if (!this.usesVirtualKeyboard()) {
|
||
return;
|
||
}
|
||
const target = event.target;
|
||
if (
|
||
target instanceof HTMLTextAreaElement ||
|
||
target instanceof HTMLInputElement ||
|
||
(target instanceof HTMLElement && target.isContentEditable)
|
||
) {
|
||
target.blur();
|
||
}
|
||
}) as EventListener,
|
||
true
|
||
);
|
||
this.setupTouchSelection();
|
||
this.setupVoiceInput();
|
||
|
||
// Setup mobile extended keybar (only on mobile devices)
|
||
if (isMobileDevice()) {
|
||
this.setupMobileKeybar();
|
||
this.addTrackedListener(document, "click", ((event: MouseEvent) => {
|
||
if (!this.usesVirtualKeyboard() || !this.mobileKeyboardVisible) {
|
||
return;
|
||
}
|
||
const target = event.target;
|
||
if (!(target instanceof Node)) {
|
||
return;
|
||
}
|
||
if (
|
||
this.element.contains(target) ||
|
||
(this.mobileVirtualKeyboardHost && this.mobileVirtualKeyboardHost.contains(target)) ||
|
||
(this.mobileKeybar && this.mobileKeybar.contains(target))
|
||
) {
|
||
return;
|
||
}
|
||
this.setMobileKeyboardVisible(false);
|
||
}) as EventListener, true);
|
||
}
|
||
|
||
this.isTabHidden = document.hidden;
|
||
|
||
// Start periodic resource cleanup
|
||
this.startResourceCleanup();
|
||
|
||
// Connect WebSocket
|
||
this.connect();
|
||
if (isMobileDevice()) {
|
||
void this.setupVirtualKeyboard();
|
||
}
|
||
if (document.hasFocus()) {
|
||
this.clearBellState();
|
||
}
|
||
|
||
const restoreFocus = () => {
|
||
this.clearBellState();
|
||
if (isMobileDevice()) {
|
||
if (this.mobileKeyboardVisible) {
|
||
this.focusMobileInput();
|
||
}
|
||
} else {
|
||
this.terminal.focus();
|
||
}
|
||
};
|
||
|
||
// Focus terminal when returning to the tab
|
||
this.addTrackedListener(document, "visibilitychange", () => {
|
||
if (document.hidden) {
|
||
this.isTabHidden = true;
|
||
this.stopHeartbeatWatchdog();
|
||
void this.releaseWakeLock();
|
||
} else {
|
||
this.isTabHidden = false;
|
||
this.refreshConnection();
|
||
restoreFocus();
|
||
this.syncWakeLockButton();
|
||
}
|
||
});
|
||
|
||
// Restore focus when browser window regains focus
|
||
this.addTrackedListener(window, "focus", () => {
|
||
restoreFocus();
|
||
this.syncWakeLockButton();
|
||
});
|
||
|
||
// Safari can restore tabs via bfcache without a focus event.
|
||
this.addTrackedListener(window, "pageshow", () => {
|
||
restoreFocus();
|
||
this.syncWakeLockButton();
|
||
});
|
||
|
||
this.addTrackedListener(window, "offline", () => {
|
||
this.isOffline = true;
|
||
this.showUserError("Offline. Will reconnect when network returns.");
|
||
});
|
||
|
||
this.addTrackedListener(window, "online", () => {
|
||
this.isOffline = false;
|
||
if (this.offlinePendingReconnect) {
|
||
this.offlinePendingReconnect = false;
|
||
this.reconnectAttempts = 0;
|
||
this.connect();
|
||
}
|
||
});
|
||
}
|
||
|
||
/** Setup mobile keyboard input via hidden textarea */
|
||
private setupMobileKeyboard(): void {
|
||
// Create hidden textarea for mobile keyboard input
|
||
const textarea = document.createElement("textarea");
|
||
textarea.setAttribute("autocapitalize", "off");
|
||
textarea.setAttribute("autocomplete", "off");
|
||
textarea.setAttribute("autocorrect", "off");
|
||
textarea.setAttribute("spellcheck", "false");
|
||
textarea.setAttribute("inputmode", "text");
|
||
textarea.setAttribute("enterkeyhint", "send");
|
||
// iOS requires the element to be "visible" and interactive for keyboard
|
||
// Use opacity near-zero but not zero, and keep it in the visible area
|
||
textarea.style.cssText = `
|
||
position: absolute;
|
||
left: 0;
|
||
top: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
opacity: 0.01;
|
||
z-index: 1;
|
||
color: transparent;
|
||
background: transparent;
|
||
border: none;
|
||
outline: none;
|
||
resize: none;
|
||
font-size: 16px;
|
||
caret-color: transparent;
|
||
pointer-events: none;
|
||
`;
|
||
// Font size 16px prevents iOS auto-zoom on focus
|
||
|
||
this.element.style.position = "relative";
|
||
this.element.appendChild(textarea);
|
||
this.mobileInput = textarea;
|
||
|
||
const handleMobileInput = (text: string, e?: Event) => {
|
||
if (e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
}
|
||
if (!text) {
|
||
return;
|
||
}
|
||
this.sendMobileText(text);
|
||
textarea.value = "";
|
||
};
|
||
|
||
// Handle special keys via beforeinput to intercept before browser modifies textarea
|
||
textarea.addEventListener("beforeinput", (e) => {
|
||
if (e.inputType === "insertText" && e.data) {
|
||
handleMobileInput(e.data, e);
|
||
return;
|
||
}
|
||
|
||
let seq: string | null = null;
|
||
switch (e.inputType) {
|
||
case "insertLineBreak": // Enter key
|
||
seq = "\r";
|
||
break;
|
||
case "deleteContentBackward": // Backspace
|
||
seq = "\x7f";
|
||
break;
|
||
case "deleteContentForward": // Delete
|
||
seq = "\x1b[3~";
|
||
break;
|
||
}
|
||
if (seq) {
|
||
handleMobileInput(seq, e);
|
||
}
|
||
});
|
||
|
||
// Handle input from mobile keyboard (regular text only, special keys handled above)
|
||
textarea.addEventListener("input", () => {
|
||
handleMobileInput(textarea.value);
|
||
});
|
||
|
||
// Handle special navigation keys via keydown (not covered by beforeinput)
|
||
// Check both physical keyboard modifiers (e.ctrlKey/e.shiftKey) and mobile keybar flags
|
||
textarea.addEventListener("keydown", (e) => {
|
||
const isCtrl = e.ctrlKey || this.ctrlActive;
|
||
const isShift = e.shiftKey || this.shiftActive;
|
||
const isAlt = e.altKey || this.altActive;
|
||
const isFn = this.fnActive;
|
||
|
||
// Handle Ctrl+key combinations (these don't fire input events)
|
||
if (isCtrl && e.key.length === 1 && !e.altKey && !e.metaKey) {
|
||
const ctrlApplied = applyCtrlModifier(e.key);
|
||
if (ctrlApplied !== e.key) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const toSend = isAlt ? applyAltModifier(ctrlApplied) : ctrlApplied;
|
||
this.sendStdin(toSend); // Ctrl+A=0x01, Ctrl+C=0x03, etc.
|
||
this.deactivateModifiers(); // Clear modifiers after physical Ctrl+key
|
||
return;
|
||
}
|
||
}
|
||
if (isFn && e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
|
||
const fnApplied = applyFnModifier(e.key, isShift);
|
||
if (fnApplied) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
this.sendStdin(fnApplied);
|
||
this.deactivateModifiers();
|
||
return;
|
||
}
|
||
}
|
||
|
||
let seq: string | null = null;
|
||
switch (e.key) {
|
||
case "Escape":
|
||
seq = "\x1b";
|
||
break;
|
||
case "ArrowUp":
|
||
case "ArrowDown":
|
||
case "ArrowRight":
|
||
case "ArrowLeft": {
|
||
const dir = e.key === "ArrowUp" ? "A" : e.key === "ArrowDown" ? "B" : e.key === "ArrowRight" ? "C" : "D";
|
||
if (isCtrl && isShift) {
|
||
seq = `\x1b[1;6${dir}`;
|
||
} else if (isCtrl) {
|
||
seq = `\x1b[1;5${dir}`;
|
||
} else if (isShift) {
|
||
seq = `\x1b[1;2${dir}`;
|
||
} else {
|
||
seq = `\x1b[${dir}`;
|
||
}
|
||
break;
|
||
}
|
||
case "Tab":
|
||
if (isShift) {
|
||
seq = "\x1b[Z"; // Back-tab
|
||
} else {
|
||
seq = "\t";
|
||
}
|
||
e.preventDefault();
|
||
break;
|
||
}
|
||
if (seq) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
this.sendStdin(isAlt ? applyAltModifier(seq) : seq);
|
||
// Always clear modifiers after any key
|
||
this.deactivateModifiers();
|
||
}
|
||
});
|
||
|
||
// Apply keybar modifiers to physical keyboard input even when the textarea isn't focused.
|
||
this.addTrackedListener(
|
||
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;
|
||
}
|
||
if (event.target === this.mobileInput) {
|
||
return;
|
||
}
|
||
|
||
const useCtrl = this.ctrlActive;
|
||
const useShift = this.shiftActive;
|
||
const useAlt = this.altActive;
|
||
const useFn = this.fnActive;
|
||
let handled = false;
|
||
|
||
if (event.key.length === 1 && !event.altKey && !event.metaKey) {
|
||
const toSend = applyModifiers(event.key, useShift, useCtrl, useAlt, useFn);
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
this.sendStdin(toSend);
|
||
handled = true;
|
||
} else {
|
||
let seq: string | null = null;
|
||
switch (event.key) {
|
||
case "Escape":
|
||
seq = "\x1b";
|
||
break;
|
||
case "ArrowUp":
|
||
case "ArrowDown":
|
||
case "ArrowRight":
|
||
case "ArrowLeft": {
|
||
const dir =
|
||
event.key === "ArrowUp"
|
||
? "A"
|
||
: event.key === "ArrowDown"
|
||
? "B"
|
||
: event.key === "ArrowRight"
|
||
? "C"
|
||
: "D";
|
||
if (useCtrl && useShift) {
|
||
seq = `\x1b[1;6${dir}`;
|
||
} else if (useCtrl) {
|
||
seq = `\x1b[1;5${dir}`;
|
||
} else if (useShift) {
|
||
seq = `\x1b[1;2${dir}`;
|
||
} else {
|
||
seq = `\x1b[${dir}`;
|
||
}
|
||
break;
|
||
}
|
||
case "Tab":
|
||
if (useShift) {
|
||
seq = "\x1b[Z";
|
||
} else {
|
||
seq = "\t";
|
||
}
|
||
break;
|
||
}
|
||
|
||
if (seq) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
this.sendStdin(useAlt ? applyAltModifier(seq) : seq);
|
||
handled = true;
|
||
}
|
||
}
|
||
|
||
if (handled) {
|
||
this.deactivateModifiers();
|
||
}
|
||
}) as EventListener,
|
||
{ capture: true }
|
||
);
|
||
|
||
// Focus textarea on touch/click to show mobile keyboard
|
||
// 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();
|
||
};
|
||
|
||
this.addTrackedListener(this.element, "touchend", focusTextarea, { passive: true });
|
||
this.addTrackedListener(this.element, "click", focusTextarea);
|
||
}
|
||
|
||
private setupTouchSelection(): void {
|
||
const canvas = this.element.querySelector("canvas");
|
||
if (!canvas) return;
|
||
|
||
const LONG_PRESS_MS = 300;
|
||
const COPY_LONG_PRESS_MS = 500;
|
||
const MOVE_THRESHOLD = 10;
|
||
const SCROLL_SPEED = 1.5;
|
||
|
||
let mode: "undecided" | "scroll" | "select" = "undecided";
|
||
let startX = 0;
|
||
let startY = 0;
|
||
let lastY = 0;
|
||
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
|
||
let copyLongPressTimer: ReturnType<typeof setTimeout> | null = null;
|
||
let copiedSelection = false;
|
||
let scrollRemainder = 0;
|
||
// Momentum state
|
||
let velocityY = 0;
|
||
let lastMoveTime = 0;
|
||
let momentumFrame: number | null = null;
|
||
|
||
const dispatchMouse = (type: "mousedown" | "mousemove" | "mouseup", touch: Touch) => {
|
||
const event = new MouseEvent(type, {
|
||
bubbles: true,
|
||
cancelable: true,
|
||
clientX: touch.clientX,
|
||
clientY: touch.clientY,
|
||
button: 0,
|
||
buttons: type === "mouseup" ? 0 : 1,
|
||
});
|
||
canvas.dispatchEvent(event);
|
||
};
|
||
|
||
const cancelLongPress = () => {
|
||
if (longPressTimer !== null) {
|
||
clearTimeout(longPressTimer);
|
||
longPressTimer = null;
|
||
}
|
||
if (copyLongPressTimer !== null) {
|
||
clearTimeout(copyLongPressTimer);
|
||
copyLongPressTimer = null;
|
||
}
|
||
};
|
||
|
||
const stopMomentum = () => {
|
||
if (momentumFrame !== null) {
|
||
cancelAnimationFrame(momentumFrame);
|
||
momentumFrame = null;
|
||
}
|
||
velocityY = 0;
|
||
};
|
||
|
||
const lineHeight = () => {
|
||
const rect = canvas.getBoundingClientRect();
|
||
const rows = this.terminal.rows;
|
||
return rows > 0 ? rect.height / rows : 16;
|
||
};
|
||
|
||
const doMomentumScroll = () => {
|
||
const FRICTION = 0.92;
|
||
const MIN_VELOCITY = 0.5;
|
||
|
||
velocityY *= FRICTION;
|
||
if (Math.abs(velocityY) < MIN_VELOCITY) {
|
||
momentumFrame = null;
|
||
return;
|
||
}
|
||
|
||
const lh = lineHeight();
|
||
scrollRemainder += (velocityY * SCROLL_SPEED) / lh;
|
||
const lines = Math.trunc(scrollRemainder);
|
||
if (lines !== 0) {
|
||
scrollRemainder -= lines;
|
||
this.terminal.scrollLines(lines);
|
||
}
|
||
momentumFrame = requestAnimationFrame(doMomentumScroll);
|
||
};
|
||
|
||
this.addTrackedListener(
|
||
canvas,
|
||
"touchstart",
|
||
((e: TouchEvent) => {
|
||
if (e.touches.length !== 1) return;
|
||
e.preventDefault();
|
||
stopMomentum();
|
||
|
||
const touch = e.touches[0];
|
||
startX = touch.clientX;
|
||
startY = touch.clientY;
|
||
lastY = touch.clientY;
|
||
lastMoveTime = performance.now();
|
||
velocityY = 0;
|
||
scrollRemainder = 0;
|
||
mode = "undecided";
|
||
copiedSelection = false;
|
||
|
||
const terminal = this.terminal as unknown as {
|
||
hasSelection?: () => boolean;
|
||
};
|
||
if (terminal.hasSelection?.()) {
|
||
copyLongPressTimer = setTimeout(() => {
|
||
copyLongPressTimer = null;
|
||
if (mode !== "undecided") {
|
||
return;
|
||
}
|
||
copiedSelection = true;
|
||
void this.copyCurrentSelectionToClipboard().catch(() => undefined);
|
||
}, COPY_LONG_PRESS_MS);
|
||
}
|
||
|
||
longPressTimer = setTimeout(() => {
|
||
longPressTimer = null;
|
||
if (mode === "undecided" && !copiedSelection) {
|
||
mode = "select";
|
||
dispatchMouse("mousedown", touch);
|
||
}
|
||
}, LONG_PRESS_MS);
|
||
}) as EventListener,
|
||
{ passive: false }
|
||
);
|
||
|
||
this.addTrackedListener(
|
||
canvas,
|
||
"touchmove",
|
||
((e: TouchEvent) => {
|
||
if (e.touches.length !== 1) return;
|
||
e.preventDefault();
|
||
const touch = e.touches[0];
|
||
const now = performance.now();
|
||
|
||
if (mode === "undecided") {
|
||
const dx = touch.clientX - startX;
|
||
const dy = touch.clientY - startY;
|
||
if (Math.abs(dy) > MOVE_THRESHOLD || Math.abs(dx) > MOVE_THRESHOLD) {
|
||
cancelLongPress();
|
||
mode = "scroll";
|
||
} else {
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (mode === "scroll") {
|
||
const deltaY = lastY - touch.clientY;
|
||
const dt = now - lastMoveTime;
|
||
if (dt > 0) {
|
||
velocityY = deltaY / dt * 16; // normalize to ~per-frame
|
||
}
|
||
lastY = touch.clientY;
|
||
lastMoveTime = now;
|
||
|
||
const lh = lineHeight();
|
||
scrollRemainder += (deltaY * SCROLL_SPEED) / lh;
|
||
const lines = Math.trunc(scrollRemainder);
|
||
if (lines !== 0) {
|
||
scrollRemainder -= lines;
|
||
this.terminal.scrollLines(lines);
|
||
}
|
||
} else if (mode === "select") {
|
||
dispatchMouse("mousemove", touch);
|
||
}
|
||
}) as EventListener,
|
||
{ passive: false }
|
||
);
|
||
|
||
this.addTrackedListener(
|
||
canvas,
|
||
"touchend",
|
||
((e: TouchEvent) => {
|
||
const touch = e.changedTouches[0];
|
||
if (!touch) return;
|
||
e.preventDefault();
|
||
cancelLongPress();
|
||
|
||
if (copiedSelection) {
|
||
copiedSelection = false;
|
||
mode = "undecided";
|
||
return;
|
||
}
|
||
|
||
if (mode === "select") {
|
||
dispatchMouse("mouseup", touch);
|
||
} else if (mode === "scroll") {
|
||
// Kick off momentum scrolling
|
||
if (Math.abs(velocityY) > 0.5) {
|
||
momentumFrame = requestAnimationFrame(doMomentumScroll);
|
||
}
|
||
} else if (!this.mobileKeyboardVisible) {
|
||
this.openMobileKeyboardFromControl();
|
||
}
|
||
|
||
mode = "undecided";
|
||
}) as EventListener,
|
||
{ passive: false }
|
||
);
|
||
}
|
||
|
||
private setupVoiceInput(): void {
|
||
const assetOverride = this.element.dataset.sherpaAssetPath?.trim();
|
||
if (assetOverride) {
|
||
this.voiceAssetBase = assetOverride.endsWith("/") ? assetOverride : `${assetOverride}/`;
|
||
}
|
||
this.voiceLlmBaseUrl =
|
||
this.element.dataset.voiceLlmBaseUrl?.trim().replace(/\/+$/, "") || DEFAULT_VOICE_LLM_BASE_URL;
|
||
this.voiceLlmModelOverride = this.element.dataset.voiceLlmModel?.trim() ?? "";
|
||
this.voiceMode = this.loadVoiceModePreference();
|
||
|
||
if (window.getComputedStyle(this.element).position === "static") {
|
||
this.element.style.position = "relative";
|
||
}
|
||
|
||
const controls = document.createElement("div");
|
||
controls.className = "webterm-voice-controls";
|
||
controls.innerHTML = `<span class="webterm-voice-status">Ready</span>`;
|
||
this.element.appendChild(controls);
|
||
this.voiceControls = controls;
|
||
this.voiceStatus = controls.querySelector(".webterm-voice-status");
|
||
|
||
if (!this.voiceStatus) {
|
||
return;
|
||
}
|
||
|
||
Object.assign(controls.style, {
|
||
position: "absolute",
|
||
top: "12px",
|
||
right: "12px",
|
||
zIndex: "4",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: "0",
|
||
padding: "8px 12px",
|
||
borderRadius: "999px",
|
||
background: "rgba(9, 14, 19, 0.78)",
|
||
backdropFilter: "blur(8px)",
|
||
boxShadow: "0 8px 30px rgba(0, 0, 0, 0.28)",
|
||
pointerEvents: "auto",
|
||
} satisfies Partial<CSSStyleDeclaration>);
|
||
|
||
Object.assign(this.voiceStatus.style, {
|
||
color: "#c9d5e0",
|
||
font: '500 11px "Fira Code", "FiraCode Nerd Font", monospace',
|
||
whiteSpace: "normal",
|
||
lineHeight: "1.35",
|
||
maxWidth: "44ch",
|
||
overflowWrap: "anywhere",
|
||
} satisfies Partial<CSSStyleDeclaration>);
|
||
if (
|
||
typeof navigator === "undefined" ||
|
||
!navigator.mediaDevices?.getUserMedia ||
|
||
typeof AudioContext === "undefined"
|
||
) {
|
||
this.setVoiceState("unsupported", "Voice unavailable");
|
||
return;
|
||
}
|
||
|
||
const configError = this.getVoiceLlmConfigError();
|
||
if (configError) {
|
||
this.setVoiceState("error", configError);
|
||
return;
|
||
}
|
||
this.setVoiceState("idle", this.voiceMode === "cleanup" ? "Ready: Cleanup" : "Ready: Live");
|
||
}
|
||
|
||
private async toggleVoiceInput(): Promise<void> {
|
||
if (this.isVoiceStarting) {
|
||
return;
|
||
}
|
||
if (this.voiceProcessor) {
|
||
await this.stopVoiceInput("insert");
|
||
return;
|
||
}
|
||
|
||
const configError = this.getVoiceLlmConfigError();
|
||
if (configError) {
|
||
this.setVoiceState("error", configError);
|
||
return;
|
||
}
|
||
|
||
this.isVoiceStarting = true;
|
||
this.armVoiceStartupErrorCapture();
|
||
this.setVoiceState("loading", "Starting...");
|
||
|
||
try {
|
||
await this.startVoiceInput();
|
||
await this.ensureVoiceRuntime();
|
||
this.resetVoiceDraftState();
|
||
this.setVoiceState("listening", this.voiceMode === "cleanup" ? "Listening: Cleanup" : "Listening...");
|
||
this.focusTerminalInput();
|
||
} catch (error) {
|
||
voiceLog.error("Failed to start sherpa voice input", { error });
|
||
this.disconnectVoiceAudio();
|
||
this.resetVoiceDraftState();
|
||
this.setVoiceState("error", this.describeVoiceError(error));
|
||
} finally {
|
||
this.isVoiceStarting = false;
|
||
}
|
||
}
|
||
|
||
private async ensureVoiceRuntime(): Promise<void> {
|
||
if (this.voiceRecognizer && this.voiceVad && this.voiceBuffer) {
|
||
return;
|
||
}
|
||
|
||
const runtime = await getSharedSherpaRuntime(this.voiceAssetBase, (status) => {
|
||
this.setVoiceState("loading", status);
|
||
});
|
||
const fileExists = (filename: string): boolean => {
|
||
const filenameLen = runtime.module.lengthBytesUTF8(filename) + 1;
|
||
const buffer = runtime.module._malloc(filenameLen);
|
||
runtime.module.stringToUTF8(filename, buffer, filenameLen);
|
||
const exists = runtime.module._SherpaOnnxFileExists(buffer) === 1;
|
||
runtime.module._free(buffer);
|
||
return exists;
|
||
};
|
||
|
||
const requiredFiles = [
|
||
"./tokens.txt",
|
||
"./moonshine-encoder.ort",
|
||
"./moonshine-merged-decoder.ort",
|
||
"./silero_vad.onnx",
|
||
];
|
||
for (const filename of requiredFiles) {
|
||
if (!fileExists(filename)) {
|
||
throw new Error(`Missing sherpa asset: ${filename}`);
|
||
}
|
||
}
|
||
|
||
this.voiceRecognizer = new runtime.OfflineRecognizer(
|
||
{
|
||
modelConfig: {
|
||
debug: 0,
|
||
moonshine: {
|
||
encoder: "./moonshine-encoder.ort",
|
||
mergedDecoder: "./moonshine-merged-decoder.ort",
|
||
},
|
||
tokens: "./tokens.txt",
|
||
},
|
||
},
|
||
runtime.module
|
||
);
|
||
this.voiceVad = runtime.createVad(runtime.module, {
|
||
bufferSizeInSeconds: VOICE_BUFFER_SIZE_SECONDS,
|
||
debug: 0,
|
||
numThreads: 1,
|
||
provider: "cpu",
|
||
sampleRate: VOICE_SAMPLE_RATE,
|
||
sileroVad: {
|
||
maxSpeechDuration: 20,
|
||
minSilenceDuration: 0.5,
|
||
minSpeechDuration: 0.25,
|
||
model: "./silero_vad.onnx",
|
||
threshold: 0.5,
|
||
windowSize: VOICE_VAD_WINDOW_SIZE,
|
||
},
|
||
tenVad: {
|
||
maxSpeechDuration: 20,
|
||
minSilenceDuration: 0.5,
|
||
minSpeechDuration: 0.25,
|
||
model: "",
|
||
threshold: 0.5,
|
||
windowSize: 256,
|
||
},
|
||
});
|
||
this.voiceBuffer = new runtime.CircularBuffer(
|
||
VOICE_BUFFER_SIZE_SECONDS * VOICE_SAMPLE_RATE,
|
||
runtime.module
|
||
);
|
||
}
|
||
|
||
private async startVoiceInput(): Promise<void> {
|
||
this.setVoiceState("loading", "Allow microphone...");
|
||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||
this.voiceInputStream = stream;
|
||
|
||
const audioContext = new AudioContext({ sampleRate: VOICE_SAMPLE_RATE });
|
||
await audioContext.resume().catch(() => undefined);
|
||
this.voiceAudioContext = audioContext;
|
||
this.voiceMediaStreamSource = audioContext.createMediaStreamSource(stream);
|
||
this.voiceProcessor = audioContext.createScriptProcessor(
|
||
VOICE_PROCESSOR_BUFFER_SIZE,
|
||
1,
|
||
1
|
||
);
|
||
this.voiceSilentGain = audioContext.createGain();
|
||
this.voiceSilentGain.gain.value = 0;
|
||
|
||
this.voiceProcessor.onaudioprocess = (event: AudioProcessingEvent) => {
|
||
this.processVoiceChunk(new Float32Array(event.inputBuffer.getChannelData(0)));
|
||
};
|
||
|
||
this.voiceMediaStreamSource.connect(this.voiceProcessor);
|
||
this.voiceProcessor.connect(this.voiceSilentGain);
|
||
this.voiceSilentGain.connect(audioContext.destination);
|
||
await audioContext.resume().catch(() => undefined);
|
||
}
|
||
|
||
private async stopVoiceInput(finalizeAction?: VoiceFinalizeAction): Promise<void> {
|
||
this.clearVoiceStartupErrorCapture();
|
||
try {
|
||
this.flushVoiceSegments();
|
||
} catch (error) {
|
||
voiceLog.error("Failed to flush sherpa voice segments", { error });
|
||
}
|
||
this.disconnectVoiceAudio();
|
||
if (this.voiceMode === "cleanup") {
|
||
const action = finalizeAction ?? "insert";
|
||
if (this.voiceDraftTranscript.trim()) {
|
||
try {
|
||
await this.finalizeVoiceCleanup(action);
|
||
} catch (error) {
|
||
voiceLog.error("Failed to finalize cleanup transcript", { error, action });
|
||
this.setVoiceState("error", this.describeVoiceError(error));
|
||
}
|
||
} else {
|
||
this.resetVoiceDraftState();
|
||
this.setVoiceState("idle", "Ready: Cleanup");
|
||
}
|
||
} else {
|
||
this.setVoiceState("idle", "Ready: Live");
|
||
}
|
||
this.focusTerminalInput();
|
||
}
|
||
|
||
private disconnectVoiceAudio(): void {
|
||
this.voiceProcessor?.disconnect();
|
||
this.voiceMediaStreamSource?.disconnect();
|
||
this.voiceSilentGain?.disconnect();
|
||
this.voiceProcessor = null;
|
||
this.voiceMediaStreamSource = null;
|
||
this.voiceSilentGain = null;
|
||
this.voiceAudioContext?.close().catch(() => undefined);
|
||
this.voiceAudioContext = null;
|
||
this.voiceInputStream?.getTracks().forEach((track) => track.stop());
|
||
this.voiceInputStream = null;
|
||
this.voiceSpeechDetected = false;
|
||
this.voiceReceivedAudio = false;
|
||
}
|
||
|
||
private processVoiceChunk(samples: Float32Array): void {
|
||
if (!this.voiceVad || !this.voiceBuffer || !samples.length) {
|
||
return;
|
||
}
|
||
|
||
const normalized =
|
||
this.voiceAudioContext && this.voiceAudioContext.sampleRate !== VOICE_SAMPLE_RATE
|
||
? this.downsampleBuffer(samples, VOICE_SAMPLE_RATE, this.voiceAudioContext.sampleRate)
|
||
: samples;
|
||
if (!normalized.length) {
|
||
return;
|
||
}
|
||
|
||
if (!this.voiceReceivedAudio) {
|
||
this.voiceReceivedAudio = true;
|
||
this.setVoiceState("listening", "Listening...");
|
||
}
|
||
|
||
this.voiceBuffer.push(normalized);
|
||
this.drainVoiceBuffer();
|
||
}
|
||
|
||
private drainVoiceBuffer(flush = false): void {
|
||
if (!this.voiceVad || !this.voiceBuffer) {
|
||
return;
|
||
}
|
||
|
||
while (this.voiceBuffer.size() >= VOICE_VAD_WINDOW_SIZE) {
|
||
const chunk = this.voiceBuffer.get(this.voiceBuffer.head(), VOICE_VAD_WINDOW_SIZE);
|
||
this.voiceVad.acceptWaveform(chunk);
|
||
this.voiceBuffer.pop(VOICE_VAD_WINDOW_SIZE);
|
||
|
||
if (this.voiceVad.isDetected() && !this.voiceSpeechDetected) {
|
||
this.voiceSpeechDetected = true;
|
||
this.setVoiceState("listening", "Speech detected");
|
||
} else if (!this.voiceVad.isDetected() && this.voiceSpeechDetected) {
|
||
this.voiceSpeechDetected = false;
|
||
this.setVoiceState("listening", "Listening...");
|
||
}
|
||
|
||
this.consumeVoiceSegments();
|
||
}
|
||
|
||
if (flush) {
|
||
while (this.voiceBuffer.size() > 0) {
|
||
const remaining = this.voiceBuffer.size();
|
||
const chunk = new Float32Array(VOICE_VAD_WINDOW_SIZE);
|
||
chunk.set(this.voiceBuffer.get(this.voiceBuffer.head(), remaining));
|
||
this.voiceVad.acceptWaveform(chunk);
|
||
this.voiceBuffer.pop(remaining);
|
||
this.consumeVoiceSegments();
|
||
}
|
||
this.voiceVad.flush();
|
||
this.consumeVoiceSegments();
|
||
this.voiceVad.reset();
|
||
this.voiceBuffer.reset();
|
||
this.voiceSpeechDetected = false;
|
||
}
|
||
}
|
||
|
||
private consumeVoiceSegments(): void {
|
||
if (!this.voiceVad || !this.voiceRecognizer) {
|
||
return;
|
||
}
|
||
|
||
while (!this.voiceVad.isEmpty()) {
|
||
const segment = this.voiceVad.front();
|
||
this.voiceVad.pop();
|
||
|
||
this.setVoiceState("listening", "Processing...");
|
||
const stream = this.voiceRecognizer.createStream();
|
||
try {
|
||
stream.acceptWaveform(VOICE_SAMPLE_RATE, segment.samples);
|
||
this.voiceRecognizer.decode(stream);
|
||
const transcript = this.voiceRecognizer.getResult(stream).text?.trim() || "";
|
||
if (!transcript) {
|
||
this.setVoiceState("listening", "Listening...");
|
||
continue;
|
||
}
|
||
if (this.voiceMode === "cleanup") {
|
||
const fullDraft = this.appendVoiceDraftSegment(transcript);
|
||
const command = this.getVoiceCommandAction(fullDraft);
|
||
if (command === "cancel") {
|
||
this.voiceFinalizeToken += 1;
|
||
this.resetVoiceDraftState();
|
||
this.disconnectVoiceAudio();
|
||
this.setVoiceState("idle", "Canceled");
|
||
this.focusTerminalInput();
|
||
return;
|
||
}
|
||
if (command === "insert" || command === "submit") {
|
||
this.disconnectVoiceAudio();
|
||
void this.finalizeVoiceCleanup(command).catch((error) => {
|
||
voiceLog.error("Failed to finalize cleanup transcript", { error, action: command });
|
||
this.setVoiceState("error", this.describeVoiceError(error));
|
||
});
|
||
this.focusTerminalInput();
|
||
return;
|
||
}
|
||
this.setVoiceState("listening", `Draft: ${this.voiceDraftPreview()}`);
|
||
continue;
|
||
}
|
||
this.sendStdin(this.formatVoiceTranscriptForInsert(transcript));
|
||
this.setVoiceState("listening", `Sent: ${transcript}`);
|
||
this.focusTerminalInput();
|
||
} finally {
|
||
stream.free();
|
||
}
|
||
}
|
||
}
|
||
|
||
private formatVoiceTranscriptForInsert(transcript: string): string {
|
||
const needsSeparator =
|
||
this.voicePendingSeparator &&
|
||
!/^\s/.test(transcript) &&
|
||
!/^[,.;:!?)]/.test(transcript);
|
||
this.voicePendingSeparator = !/\s$/.test(transcript);
|
||
return needsSeparator ? ` ${transcript}` : transcript;
|
||
}
|
||
|
||
private flushVoiceSegments(): void {
|
||
this.drainVoiceBuffer(true);
|
||
this.voiceVad?.reset();
|
||
this.voiceBuffer?.reset();
|
||
this.voicePendingSeparator = false;
|
||
}
|
||
|
||
private destroyVoiceEngine(): void {
|
||
this.voiceRecognizer?.free();
|
||
this.voiceVad?.free();
|
||
this.voiceBuffer?.free();
|
||
this.voiceRecognizer = null;
|
||
this.voiceVad = null;
|
||
this.voiceBuffer = null;
|
||
this.voicePendingSeparator = false;
|
||
}
|
||
|
||
private downsampleBuffer(
|
||
samples: Float32Array,
|
||
targetSampleRate: number,
|
||
sourceSampleRate: number
|
||
): Float32Array {
|
||
if (targetSampleRate >= sourceSampleRate) {
|
||
return samples;
|
||
}
|
||
|
||
const ratio = sourceSampleRate / targetSampleRate;
|
||
const outputLength = Math.round(samples.length / ratio);
|
||
const output = new Float32Array(outputLength);
|
||
let outputIndex = 0;
|
||
let inputIndex = 0;
|
||
|
||
while (outputIndex < outputLength) {
|
||
const nextInputIndex = Math.round((outputIndex + 1) * ratio);
|
||
let sum = 0;
|
||
let count = 0;
|
||
for (let i = inputIndex; i < nextInputIndex && i < samples.length; i += 1) {
|
||
sum += samples[i];
|
||
count += 1;
|
||
}
|
||
output[outputIndex] = count > 0 ? sum / count : 0;
|
||
outputIndex += 1;
|
||
inputIndex = nextInputIndex;
|
||
}
|
||
|
||
return output;
|
||
}
|
||
|
||
private describeVoiceError(error: unknown): string {
|
||
if (typeof error === "string" && error.trim()) {
|
||
return error.trim();
|
||
}
|
||
if (error instanceof DOMException && error.name === "AbortError") {
|
||
return `LLM request timed out after ${Math.round(VOICE_LLM_TIMEOUT_MS / 1000)}s`;
|
||
}
|
||
if (error instanceof Error && error.message.trim()) {
|
||
const cause =
|
||
"cause" in error && error.cause
|
||
? ` Cause: ${this.describeVoiceError(error.cause)}`
|
||
: "";
|
||
return `${error.name}: ${error.message.trim()}${cause}`;
|
||
}
|
||
if (typeof error === "object" && error !== null) {
|
||
try {
|
||
return JSON.stringify(error);
|
||
} catch {
|
||
return String(error);
|
||
}
|
||
}
|
||
return "Voice error";
|
||
}
|
||
|
||
private armVoiceStartupErrorCapture(): void {
|
||
this.clearVoiceStartupErrorCapture();
|
||
|
||
const startedAt = Date.now();
|
||
const captureWindowMs = 15_000;
|
||
|
||
const handleFailure = (error: unknown): void => {
|
||
if (Date.now() - startedAt > captureWindowMs) {
|
||
return;
|
||
}
|
||
const message = this.describeVoiceError(error);
|
||
if (!message || message === "Voice error") {
|
||
return;
|
||
}
|
||
voiceLog.error("Captured voice startup failure", { error, message });
|
||
this.setVoiceState("error", message);
|
||
};
|
||
|
||
const rejectionHandler = (event: PromiseRejectionEvent) => {
|
||
handleFailure(event.reason);
|
||
};
|
||
const errorHandler = (event: ErrorEvent) => {
|
||
handleFailure(event.error ?? event.message);
|
||
};
|
||
|
||
window.addEventListener("unhandledrejection", rejectionHandler);
|
||
window.addEventListener("error", errorHandler);
|
||
|
||
const timeoutId = window.setTimeout(() => {
|
||
this.clearVoiceStartupErrorCapture();
|
||
}, captureWindowMs);
|
||
|
||
this.voiceStartupErrorCleanup = () => {
|
||
window.removeEventListener("unhandledrejection", rejectionHandler);
|
||
window.removeEventListener("error", errorHandler);
|
||
clearTimeout(timeoutId);
|
||
this.voiceStartupErrorCleanup = null;
|
||
};
|
||
}
|
||
|
||
private clearVoiceStartupErrorCapture(): void {
|
||
this.voiceStartupErrorCleanup?.();
|
||
}
|
||
|
||
private setVoiceState(
|
||
state: "idle" | "loading" | "listening" | "processing" | "error" | "unsupported",
|
||
message: string
|
||
): void {
|
||
this.voiceState = state;
|
||
this.syncVirtualKeyboardState();
|
||
|
||
if (!this.voiceStatus) {
|
||
return;
|
||
}
|
||
|
||
const clippedMessage =
|
||
message.length > VOICE_STATUS_MAX_LENGTH
|
||
? `${message.slice(0, VOICE_STATUS_MAX_LENGTH - 1)}…`
|
||
: message;
|
||
|
||
const displayMessage = state === "error" ? message : clippedMessage;
|
||
|
||
this.voiceStatus.textContent = displayMessage;
|
||
this.voiceStatus.title = message;
|
||
|
||
if (state === "listening") {
|
||
if (this.voiceControls) {
|
||
this.voiceControls.style.background = "rgba(45, 10, 10, 0.78)";
|
||
}
|
||
} else if (state === "processing") {
|
||
if (this.voiceControls) {
|
||
this.voiceControls.style.background = "rgba(16, 34, 44, 0.82)";
|
||
}
|
||
} else if (state === "error") {
|
||
if (this.voiceControls) {
|
||
this.voiceControls.style.background = "rgba(46, 29, 8, 0.82)";
|
||
}
|
||
} else {
|
||
if (this.voiceControls) {
|
||
this.voiceControls.style.background = "rgba(9, 14, 19, 0.78)";
|
||
}
|
||
}
|
||
}
|
||
|
||
private focusTerminalInput(): void {
|
||
if (isMobileDevice()) {
|
||
this.focusMobileInput();
|
||
} else {
|
||
this.terminal.focus();
|
||
}
|
||
}
|
||
|
||
private keybarButtonHeight = 44;
|
||
private virtualKeyboardBaseHeight = 500;
|
||
|
||
private usesVirtualKeyboard(): boolean {
|
||
return isMobileDevice();
|
||
}
|
||
|
||
private setMobileKeyboardVisible(visible: boolean): void {
|
||
if (visible && this.usesVirtualKeyboard() && !this.allowMobileKeyboardOpen) {
|
||
return;
|
||
}
|
||
this.allowMobileKeyboardOpen = false;
|
||
this.mobileKeyboardVisible = visible;
|
||
if (this.mobileVirtualKeyboardHost) {
|
||
this.mobileVirtualKeyboardHost.style.display = visible ? "" : "none";
|
||
}
|
||
if (this.mobileKeybar) {
|
||
this.mobileKeybar.style.display = visible ? "" : "none";
|
||
}
|
||
this.updateMobileKeyboardDockLayout();
|
||
this.fit();
|
||
if (visible) {
|
||
this.requestMobileVirtualKeyboardDraw();
|
||
}
|
||
}
|
||
|
||
private toggleMobileKeyboard(): void {
|
||
this.setMobileKeyboardVisible(!this.mobileKeyboardVisible);
|
||
}
|
||
|
||
private openMobileKeyboardFromControl(): void {
|
||
this.allowMobileKeyboardOpen = true;
|
||
this.setMobileKeyboardVisible(true);
|
||
}
|
||
|
||
private getMobileModifierState(): {
|
||
useShift: boolean;
|
||
useCtrl: boolean;
|
||
useAlt: boolean;
|
||
useFn: boolean;
|
||
} {
|
||
return {
|
||
useShift: this.shiftActive || this.pendingShift,
|
||
useCtrl: this.ctrlActive || this.pendingCtrl,
|
||
useAlt: this.altActive || this.pendingAlt,
|
||
useFn: this.fnActive || this.pendingFn,
|
||
};
|
||
}
|
||
|
||
private sendMobileText(text: string): void {
|
||
const { useShift, useCtrl, useAlt, useFn } = this.getMobileModifierState();
|
||
this.sendStdin(applyModifiers(text, useShift, useCtrl, useAlt, useFn));
|
||
this.deactivateModifiers();
|
||
}
|
||
|
||
private sendMobileSequence(seq: string): void {
|
||
const { useShift, useCtrl, useAlt, useFn } = this.getMobileModifierState();
|
||
let output = seq;
|
||
|
||
if (useFn && output.length === 1) {
|
||
const fnApplied = applyFnModifier(output, useShift);
|
||
if (fnApplied) {
|
||
output = fnApplied;
|
||
}
|
||
} else if (output === "\x09" && useShift) {
|
||
output = "\x1b[Z";
|
||
} else if (output.startsWith("\x1b[") && output.length === 3) {
|
||
const dir = output[2];
|
||
if (useCtrl && useShift) {
|
||
output = `\x1b[1;6${dir}`;
|
||
} else if (useCtrl) {
|
||
output = `\x1b[1;5${dir}`;
|
||
} else if (useShift) {
|
||
output = `\x1b[1;2${dir}`;
|
||
}
|
||
}
|
||
|
||
if (output.length === 1) {
|
||
output = applyModifiers(output, useShift, useCtrl, useAlt, useFn);
|
||
} else if (useAlt) {
|
||
output = applyAltModifier(output);
|
||
}
|
||
|
||
this.sendStdin(output);
|
||
this.deactivateModifiers();
|
||
}
|
||
|
||
private setModifierState(modifier: "ctrl" | "alt" | "shift" | "fn", active: boolean): void {
|
||
if (modifier === "ctrl") {
|
||
this.ctrlActive = active;
|
||
this.pendingCtrl = active;
|
||
} else if (modifier === "alt") {
|
||
this.altActive = active;
|
||
this.pendingAlt = active;
|
||
} else if (modifier === "shift") {
|
||
this.shiftActive = active;
|
||
this.pendingShift = active;
|
||
} else if (modifier === "fn") {
|
||
this.fnActive = active;
|
||
this.pendingFn = active;
|
||
}
|
||
this.syncModifierButtons();
|
||
this.syncVirtualKeyboardState();
|
||
}
|
||
|
||
private toggleModifierState(modifier: "ctrl" | "alt" | "shift" | "fn"): void {
|
||
if (modifier === "ctrl") {
|
||
this.setModifierState("ctrl", !this.ctrlActive);
|
||
} else if (modifier === "alt") {
|
||
this.setModifierState("alt", !this.altActive);
|
||
} else if (modifier === "shift") {
|
||
this.setModifierState("shift", !this.shiftActive);
|
||
} else if (modifier === "fn") {
|
||
this.setModifierState("fn", !this.fnActive);
|
||
}
|
||
}
|
||
|
||
private syncModifierButtons(): void {
|
||
this.mobileKeybar?.querySelectorAll<HTMLElement>("button[data-modifier]").forEach((btn) => {
|
||
const modifier = btn.dataset.modifier;
|
||
const active =
|
||
modifier === "ctrl"
|
||
? this.ctrlActive
|
||
: modifier === "alt"
|
||
? this.altActive
|
||
: modifier === "shift"
|
||
? this.shiftActive
|
||
: modifier === "fn"
|
||
? this.fnActive
|
||
: false;
|
||
btn.classList.toggle("active", active);
|
||
});
|
||
}
|
||
|
||
private syncVirtualKeyboardState(): void {
|
||
this.requestMobileVirtualKeyboardDraw();
|
||
}
|
||
|
||
private currentVirtualKeyboardLayout(): VirtualKeyboardKey[][] {
|
||
return this.mobileKeyboardMode === "symbol"
|
||
? VIRTUAL_KEYBOARD_SYMBOL_LAYOUT
|
||
: VIRTUAL_KEYBOARD_ALPHA_LAYOUT;
|
||
}
|
||
|
||
private getVirtualKeyboardScale(width: number): number {
|
||
return width < 390 ? 390 / width : 1;
|
||
}
|
||
|
||
private updateVirtualKeyboardHostHeight(): void {
|
||
if (!this.mobileVirtualKeyboardHost) {
|
||
return;
|
||
}
|
||
const width = this.mobileVirtualKeyboardHost.getBoundingClientRect().width;
|
||
if (!width) {
|
||
return;
|
||
}
|
||
const scale = this.getVirtualKeyboardScale(width);
|
||
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") {
|
||
return 0;
|
||
}
|
||
return MOBILE_PWA_BOTTOM_INSET_PX;
|
||
}
|
||
|
||
private updateMobileKeyboardDockLayout(): void {
|
||
const safeAreaBottom = this.getMobileKeyboardBottomInset();
|
||
if (this.mobileVirtualKeyboardHost) {
|
||
this.mobileVirtualKeyboardHost.style.bottom = "0";
|
||
this.mobileVirtualKeyboardHost.style.paddingBottom = "0";
|
||
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 = "";
|
||
}
|
||
const keyboardHeight = this.mobileKeyboardVisible
|
||
? (this.mobileKeybar?.offsetHeight ?? 0) + (this.mobileVirtualKeyboardHost?.offsetHeight ?? 0)
|
||
: 0;
|
||
this.element.style.paddingBottom = `${keyboardHeight}px`;
|
||
}
|
||
|
||
private getVirtualKeyboardRect(): DOMRect | null {
|
||
return this.mobileVirtualKeyboard?.getBoundingClientRect() ?? null;
|
||
}
|
||
|
||
private getVirtualKeyboardLabel(key: VirtualKeyboardKey): string {
|
||
if (key.actionId === "toggle-voice") {
|
||
if (this.voiceState === "listening") {
|
||
return "Stop";
|
||
}
|
||
if (this.voiceState === "loading" || this.voiceState === "processing") {
|
||
return "Mic...";
|
||
}
|
||
return "Mic";
|
||
}
|
||
if (key.kind === "char") {
|
||
return this.shiftActive && key.shiftLabel ? key.shiftLabel : key.label;
|
||
}
|
||
return key.label;
|
||
}
|
||
|
||
private getVirtualKeyboardValue(key: VirtualKeyboardKey): string {
|
||
if (key.kind === "char") {
|
||
if (this.shiftActive && key.shiftValue) {
|
||
return key.shiftValue;
|
||
}
|
||
return key.value ?? key.label;
|
||
}
|
||
if (key.kind === "space") {
|
||
return key.value ?? " ";
|
||
}
|
||
return key.value ?? key.label;
|
||
}
|
||
|
||
private calculateVirtualKeyboardLayout(): VirtualKeyboardKeyBounds[] {
|
||
const rect = this.getVirtualKeyboardRect();
|
||
if (!rect) {
|
||
return [];
|
||
}
|
||
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 - bottomInset;
|
||
const rowHeight = (contentH - (layout.length - 1) * gap) / layout.length;
|
||
const bounds: VirtualKeyboardKeyBounds[] = [];
|
||
let currentY = padding;
|
||
|
||
layout.forEach((row, rowIndex) => {
|
||
const totalGapW = (row.length - 1) * gap;
|
||
const totalFlexGrow = row.reduce((sum, key) => sum + (key.width ?? 1), 0);
|
||
const unitW = (contentW - totalGapW) / totalFlexGrow;
|
||
let currentX = padding;
|
||
|
||
row.forEach((key, colIndex) => {
|
||
const width = unitW * (key.width ?? 1);
|
||
bounds.push({
|
||
x: currentX,
|
||
y: currentY,
|
||
w: width,
|
||
h: rowHeight,
|
||
rowIndex,
|
||
colIndex,
|
||
key,
|
||
label: this.getVirtualKeyboardLabel(key),
|
||
value: this.getVirtualKeyboardValue(key),
|
||
});
|
||
currentX += width + gap;
|
||
});
|
||
|
||
currentY += rowHeight + gap;
|
||
});
|
||
|
||
return bounds;
|
||
}
|
||
|
||
private isVirtualKeyboardModifierActive(key: VirtualKeyboardKeyBounds): boolean {
|
||
if (key.key.modifier === "shift") {
|
||
return this.shiftActive;
|
||
}
|
||
if (key.key.modifier === "ctrl") {
|
||
return this.ctrlActive;
|
||
}
|
||
if (key.key.modifier === "alt") {
|
||
return this.altActive;
|
||
}
|
||
if (key.key.modifier === "fn") {
|
||
return this.fnActive;
|
||
}
|
||
if (key.key.actionId === "mode-symbol") {
|
||
return this.mobileKeyboardMode === "symbol";
|
||
}
|
||
if (key.key.actionId === "toggle-voice") {
|
||
return this.voiceState === "listening" || this.voiceState === "loading" || this.voiceState === "processing";
|
||
}
|
||
return false;
|
||
}
|
||
|
||
private drawVirtualKeyboardKey(
|
||
ctx: CanvasRenderingContext2D,
|
||
key: VirtualKeyboardKeyBounds,
|
||
pressed: boolean,
|
||
active: boolean,
|
||
scale: number,
|
||
): void {
|
||
let radius = 10 / scale;
|
||
if (key.w < radius * 2) {
|
||
radius = key.w / 2;
|
||
}
|
||
if (key.h < radius * 2) {
|
||
radius = key.h / 2;
|
||
}
|
||
|
||
const colors = {
|
||
shadow: "#0f172a",
|
||
keyFace: pressed ? "#334155" : "#1e293b",
|
||
keyText: "#f8fafc",
|
||
actionFace: pressed ? "#475569" : "#334155",
|
||
activeFace: pressed ? "#f8fafc" : "#e2e8f0",
|
||
activeText: "#0f172a",
|
||
};
|
||
|
||
const isAction = key.key.kind === "action" || key.key.kind === "arrow" || key.key.kind === "modifier";
|
||
const faceColor = active ? colors.activeFace : isAction ? colors.actionFace : colors.keyFace;
|
||
const textColor = active ? colors.activeText : colors.keyText;
|
||
const depth = 4 / scale;
|
||
const pressOffset = pressed ? depth * 0.6 : 0;
|
||
|
||
ctx.beginPath();
|
||
ctx.moveTo(key.x + radius, key.y + depth);
|
||
ctx.arcTo(key.x + key.w, key.y + depth, key.x + key.w, key.y + key.h + depth, radius);
|
||
ctx.arcTo(key.x + key.w, key.y + key.h + depth, key.x, key.y + key.h + depth, radius);
|
||
ctx.arcTo(key.x, key.y + key.h + depth, key.x, key.y + depth, radius);
|
||
ctx.arcTo(key.x, key.y + depth, key.x + key.w, key.y + depth, radius);
|
||
ctx.closePath();
|
||
ctx.fillStyle = colors.shadow;
|
||
ctx.fill();
|
||
|
||
ctx.beginPath();
|
||
ctx.moveTo(key.x + radius, key.y + pressOffset);
|
||
ctx.arcTo(key.x + key.w, key.y + pressOffset, key.x + key.w, key.y + key.h + pressOffset, radius);
|
||
ctx.arcTo(key.x + key.w, key.y + key.h + pressOffset, key.x, key.y + key.h + pressOffset, radius);
|
||
ctx.arcTo(key.x, key.y + key.h + pressOffset, key.x, key.y + pressOffset, radius);
|
||
ctx.arcTo(key.x, key.y + pressOffset, key.x + key.w, key.y + pressOffset, radius);
|
||
ctx.closePath();
|
||
ctx.fillStyle = faceColor;
|
||
ctx.fill();
|
||
|
||
let fontSize = key.h * 0.34;
|
||
if (key.label.length > 1) {
|
||
fontSize = key.h * 0.24;
|
||
}
|
||
if (key.label.length > 5) {
|
||
fontSize = key.h * 0.2;
|
||
}
|
||
fontSize = Math.max(fontSize, 14 / scale);
|
||
|
||
ctx.fillStyle = textColor;
|
||
ctx.textAlign = "center";
|
||
ctx.textBaseline = "middle";
|
||
ctx.font = `500 ${fontSize}px Inter, system-ui, sans-serif`;
|
||
ctx.fillText(key.label, key.x + key.w / 2, key.y + key.h / 2 + pressOffset);
|
||
}
|
||
|
||
private drawMobileVirtualKeyboard(): void {
|
||
const canvas = this.mobileVirtualKeyboard;
|
||
if (!canvas || !this.mobileKeyboardVisible) {
|
||
return;
|
||
}
|
||
const ctx = canvas.getContext("2d", { alpha: false });
|
||
if (!ctx) {
|
||
return;
|
||
}
|
||
const rect = canvas.getBoundingClientRect();
|
||
if (!rect.width || !rect.height) {
|
||
return;
|
||
}
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const width = Math.round(rect.width * dpr);
|
||
const height = Math.round(rect.height * dpr);
|
||
if (canvas.width !== width || canvas.height !== height) {
|
||
canvas.width = width;
|
||
canvas.height = height;
|
||
}
|
||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
ctx.fillStyle = "#0f172a";
|
||
ctx.fillRect(0, 0, rect.width, rect.height);
|
||
|
||
this.mobileVirtualKeyboardBounds = this.calculateVirtualKeyboardLayout();
|
||
const scale = this.getVirtualKeyboardScale(rect.width);
|
||
|
||
this.mobileVirtualKeyboardBounds.forEach((key) => {
|
||
let pressed = false;
|
||
for (const press of this.mobileVirtualKeyboardActivePresses.values()) {
|
||
const keyIndex = key.rowIndex * 100 + key.colIndex;
|
||
if (press.keyIndex === keyIndex) {
|
||
pressed = true;
|
||
break;
|
||
}
|
||
}
|
||
this.drawVirtualKeyboardKey(
|
||
ctx,
|
||
key,
|
||
pressed,
|
||
this.isVirtualKeyboardModifierActive(key),
|
||
scale,
|
||
);
|
||
});
|
||
}
|
||
|
||
private requestMobileVirtualKeyboardDraw(): void {
|
||
if (this.mobileVirtualKeyboardDrawFrame !== null) {
|
||
return;
|
||
}
|
||
this.mobileVirtualKeyboardDrawFrame = window.requestAnimationFrame(() => {
|
||
this.mobileVirtualKeyboardDrawFrame = null;
|
||
this.drawMobileVirtualKeyboard();
|
||
});
|
||
}
|
||
|
||
private clearVirtualKeyboardRepeat(press: ActiveVirtualKeyboardPress | undefined): void {
|
||
if (press?.repeatTimeout) {
|
||
window.clearTimeout(press.repeatTimeout);
|
||
press.repeatTimeout = undefined;
|
||
}
|
||
}
|
||
|
||
private findVirtualKeyboardKey(clientX: number, clientY: number): VirtualKeyboardKeyBounds | null {
|
||
const rect = this.getVirtualKeyboardRect();
|
||
if (!rect) {
|
||
return null;
|
||
}
|
||
const x = clientX - rect.left;
|
||
const y = clientY - rect.top;
|
||
return this.mobileVirtualKeyboardBounds.find((key) =>
|
||
x >= key.x && x <= key.x + key.w && y >= key.y && y <= key.y + key.h,
|
||
) ?? null;
|
||
}
|
||
|
||
private startVirtualKeyboardRepeat(press: ActiveVirtualKeyboardPress): void {
|
||
const value = press.key?.value ?? "";
|
||
const repeatable =
|
||
press.key?.key.kind === "char" ||
|
||
press.key?.key.kind === "space" ||
|
||
value === "Backspace" ||
|
||
value === "Delete" ||
|
||
value === "ArrowLeft" ||
|
||
value === "ArrowRight";
|
||
|
||
if (!repeatable) {
|
||
return;
|
||
}
|
||
|
||
const tick = () => {
|
||
const activePress = this.mobileVirtualKeyboardActivePresses.get(press.pointerId);
|
||
if (!activePress?.key) {
|
||
return;
|
||
}
|
||
void this.dispatchVirtualKeyboardKey(activePress.key);
|
||
activePress.repeatTimeout = window.setTimeout(tick, 60);
|
||
this.mobileVirtualKeyboardActivePresses.set(press.pointerId, activePress);
|
||
};
|
||
|
||
press.repeatTimeout = window.setTimeout(tick, 500);
|
||
this.mobileVirtualKeyboardActivePresses.set(press.pointerId, press);
|
||
}
|
||
|
||
private async dispatchVirtualKeyboardKey(key: VirtualKeyboardKeyBounds): Promise<void> {
|
||
const actionId = key.key.actionId;
|
||
if (actionId === "mode-alpha") {
|
||
this.mobileKeyboardMode = "alpha";
|
||
this.syncVirtualKeyboardState();
|
||
return;
|
||
}
|
||
if (actionId === "mode-symbol") {
|
||
this.mobileKeyboardMode = this.mobileKeyboardMode === "symbol" ? "alpha" : "symbol";
|
||
this.syncVirtualKeyboardState();
|
||
return;
|
||
}
|
||
if (actionId === "toggle-voice") {
|
||
await this.toggleVoiceInput();
|
||
return;
|
||
}
|
||
if (key.key.modifier) {
|
||
this.toggleModifierState(key.key.modifier);
|
||
return;
|
||
}
|
||
if (key.key.seq) {
|
||
this.sendMobileSequence(key.key.seq);
|
||
return;
|
||
}
|
||
this.sendMobileText(key.value);
|
||
}
|
||
|
||
private handleVirtualKeyboardPointerDown = (event: PointerEvent): void => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
if (!this.mobileVirtualKeyboard) {
|
||
return;
|
||
}
|
||
this.mobileVirtualKeyboard.setPointerCapture(event.pointerId);
|
||
if (!this.mobileVirtualKeyboardBounds.length) {
|
||
this.mobileVirtualKeyboardBounds = this.calculateVirtualKeyboardLayout();
|
||
}
|
||
this.mobileVirtualKeyboardActivePresses.forEach((press) => {
|
||
this.clearVirtualKeyboardRepeat(press);
|
||
});
|
||
const hitKey = this.findVirtualKeyboardKey(event.clientX, event.clientY);
|
||
if (hitKey) {
|
||
const press: ActiveVirtualKeyboardPress = {
|
||
pointerId: event.pointerId,
|
||
keyIndex: hitKey.rowIndex * 100 + hitKey.colIndex,
|
||
key: hitKey,
|
||
};
|
||
void this.dispatchVirtualKeyboardKey(hitKey);
|
||
this.mobileVirtualKeyboardActivePresses.set(event.pointerId, press);
|
||
this.startVirtualKeyboardRepeat(press);
|
||
} else {
|
||
this.mobileVirtualKeyboardActivePresses.set(event.pointerId, {
|
||
pointerId: event.pointerId,
|
||
keyIndex: -1,
|
||
key: null,
|
||
});
|
||
}
|
||
this.requestMobileVirtualKeyboardDraw();
|
||
};
|
||
|
||
private handleVirtualKeyboardPointerMove = (event: PointerEvent): void => {
|
||
event.preventDefault();
|
||
const press = this.mobileVirtualKeyboardActivePresses.get(event.pointerId);
|
||
if (!press) {
|
||
return;
|
||
}
|
||
const hitKey = this.findVirtualKeyboardKey(event.clientX, event.clientY);
|
||
if (hitKey) {
|
||
const newKeyIndex = hitKey.rowIndex * 100 + hitKey.colIndex;
|
||
if (newKeyIndex !== press.keyIndex) {
|
||
this.clearVirtualKeyboardRepeat(press);
|
||
const nextPress: ActiveVirtualKeyboardPress = {
|
||
pointerId: event.pointerId,
|
||
keyIndex: newKeyIndex,
|
||
key: hitKey,
|
||
};
|
||
void this.dispatchVirtualKeyboardKey(hitKey);
|
||
this.mobileVirtualKeyboardActivePresses.set(event.pointerId, nextPress);
|
||
this.startVirtualKeyboardRepeat(nextPress);
|
||
}
|
||
} else if (press.keyIndex !== -1) {
|
||
this.clearVirtualKeyboardRepeat(press);
|
||
this.mobileVirtualKeyboardActivePresses.set(event.pointerId, {
|
||
pointerId: event.pointerId,
|
||
keyIndex: -1,
|
||
key: null,
|
||
});
|
||
}
|
||
this.requestMobileVirtualKeyboardDraw();
|
||
};
|
||
|
||
private handleVirtualKeyboardPointerUp = (event: PointerEvent): void => {
|
||
event.preventDefault();
|
||
const press = this.mobileVirtualKeyboardActivePresses.get(event.pointerId);
|
||
if (press) {
|
||
this.clearVirtualKeyboardRepeat(press);
|
||
this.mobileVirtualKeyboardActivePresses.delete(event.pointerId);
|
||
}
|
||
if (this.mobileVirtualKeyboard?.hasPointerCapture(event.pointerId)) {
|
||
this.mobileVirtualKeyboard.releasePointerCapture(event.pointerId);
|
||
}
|
||
this.requestMobileVirtualKeyboardDraw();
|
||
};
|
||
|
||
private setupVirtualKeyboard(): void {
|
||
if (this.mobileVirtualKeyboard || this.mobileVirtualKeyboardHost) {
|
||
return;
|
||
}
|
||
|
||
const keyboardHost = document.createElement("div");
|
||
keyboardHost.className = "mobile-virtual-keyboard";
|
||
keyboardHost.style.cssText = `
|
||
position: absolute;
|
||
left: 0;
|
||
right: 0;
|
||
background: #0f172a;
|
||
padding: 0;
|
||
touch-action: none;
|
||
user-select: none;
|
||
-webkit-user-select: none;
|
||
z-index: 9998;
|
||
`;
|
||
|
||
const canvas = document.createElement("canvas");
|
||
canvas.style.cssText = `
|
||
display: block;
|
||
width: 100%;
|
||
height: 100%;
|
||
touch-action: none;
|
||
`;
|
||
canvas.addEventListener("pointerdown", this.handleVirtualKeyboardPointerDown, { passive: false });
|
||
canvas.addEventListener("pointermove", this.handleVirtualKeyboardPointerMove, { passive: false });
|
||
canvas.addEventListener("pointerup", this.handleVirtualKeyboardPointerUp, { passive: false });
|
||
canvas.addEventListener("pointercancel", this.handleVirtualKeyboardPointerUp, { passive: false });
|
||
canvas.addEventListener("pointerleave", this.handleVirtualKeyboardPointerUp, { passive: false });
|
||
|
||
keyboardHost.appendChild(canvas);
|
||
this.element.appendChild(keyboardHost);
|
||
this.mobileVirtualKeyboardHost = keyboardHost;
|
||
this.mobileVirtualKeyboard = canvas;
|
||
this.mobileVirtualKeyboardBounds = [];
|
||
this.updateVirtualKeyboardHostHeight();
|
||
this.updateMobileKeyboardDockLayout();
|
||
this.addTrackedListener(window, "resize", () => {
|
||
this.updateVirtualKeyboardHostHeight();
|
||
this.updateMobileKeyboardDockLayout();
|
||
this.mobileVirtualKeyboardBounds = [];
|
||
this.fit();
|
||
this.requestMobileVirtualKeyboardDraw();
|
||
});
|
||
this.setMobileKeyboardVisible(false);
|
||
this.clearUserError();
|
||
this.requestMobileVirtualKeyboardDraw();
|
||
}
|
||
|
||
/** Setup bottom-docked mobile extended keyboard bar */
|
||
private setupMobileKeybar(): void {
|
||
const keybar = document.createElement("div");
|
||
keybar.className = "mobile-keybar";
|
||
|
||
const keysPanel = document.createElement("div");
|
||
keysPanel.className = "keybar-panel keybar-keys";
|
||
keysPanel.innerHTML = `
|
||
<button class="keybar-settings" title="Settings">⚙</button>
|
||
<button data-modifier="fn" title="Fn modifier">Fn</button>
|
||
<button data-key="\\x09" title="Tab">Tab</button>
|
||
<button data-key="\\x1b[D" title="Left arrow">←</button>
|
||
<button data-key="\\x1b[B" title="Down arrow">↓</button>
|
||
<button data-key="\\x1b[A" title="Up arrow">↑</button>
|
||
<button data-key="\\x1b[C" title="Right arrow">→</button>
|
||
<span class="keybar-label keybar-label-grow">Bar</span>
|
||
<button class="keybar-hide" title="Hide keyboard">⌄</button>
|
||
`;
|
||
|
||
const settingsPanel = document.createElement("div");
|
||
settingsPanel.className = "keybar-panel keybar-settings-panel";
|
||
settingsPanel.style.display = "none";
|
||
settingsPanel.innerHTML = `
|
||
<button class="keybar-back" title="Back">◀</button>
|
||
<span class="keybar-label">Bar</span>
|
||
<button class="keybar-bar-shrink" title="Shrink bar">−</button>
|
||
<button class="keybar-bar-grow" title="Grow bar">+</button>
|
||
<span class="keybar-label">Font</span>
|
||
<button class="keybar-font-shrink" title="Smaller font">A−</button>
|
||
<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);
|
||
keybar.appendChild(settingsPanel);
|
||
|
||
// Inject styles
|
||
const style = document.createElement("style");
|
||
style.textContent = `
|
||
.mobile-keybar {
|
||
position: absolute;
|
||
left: 0;
|
||
right: 0;
|
||
background: #0f172a;
|
||
touch-action: none;
|
||
user-select: none;
|
||
-webkit-user-select: none;
|
||
border-top: 1px solid rgba(148, 163, 184, 0.18);
|
||
z-index: 9999;
|
||
}
|
||
.mobile-virtual-keyboard {
|
||
background: #0f172a;
|
||
border-top: 1px solid rgba(148, 163, 184, 0.18);
|
||
}
|
||
.mobile-virtual-keyboard canvas {
|
||
display: block;
|
||
width: 100%;
|
||
height: 100%;
|
||
touch-action: none;
|
||
}
|
||
.keybar-panel {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 4px;
|
||
padding: 6px;
|
||
}
|
||
.mobile-keybar button {
|
||
flex: 1 1 0;
|
||
min-width: 0;
|
||
height: ${this.keybarButtonHeight}px;
|
||
padding: 0 4px;
|
||
border: 0;
|
||
border-radius: 10px;
|
||
background: #334155;
|
||
color: #f8fafc;
|
||
font-size: 15px;
|
||
font-weight: 500;
|
||
font-family: system-ui, sans-serif;
|
||
cursor: pointer;
|
||
touch-action: manipulation;
|
||
box-shadow: 0 3px 0 #020617;
|
||
}
|
||
.mobile-keybar button:active {
|
||
background: #475569;
|
||
transform: translateY(2px);
|
||
box-shadow: 0 1px 0 #020617;
|
||
}
|
||
.mobile-keybar button.active {
|
||
background: #e2e8f0;
|
||
color: #0f172a;
|
||
box-shadow: 0 3px 0 #94a3b8;
|
||
}
|
||
.keybar-label {
|
||
display: flex;
|
||
align-items: center;
|
||
color: #94a3b8;
|
||
font-size: 12px;
|
||
letter-spacing: 0.04em;
|
||
text-transform: uppercase;
|
||
font-family: system-ui, sans-serif;
|
||
padding: 0 4px;
|
||
}
|
||
.keybar-label-grow {
|
||
flex: 1 1 auto;
|
||
}
|
||
`;
|
||
document.head.appendChild(style);
|
||
this.mobileKeybarStyle = style;
|
||
this.element.appendChild(keybar);
|
||
this.mobileKeybar = keybar;
|
||
this.updateMobileKeyboardDockLayout();
|
||
this.fit();
|
||
this.setMobileKeyboardVisible(false);
|
||
|
||
// Toggle between keys and settings panels
|
||
const showPanel = (panel: "keys" | "settings") => {
|
||
keysPanel.style.display = panel === "keys" ? "" : "none";
|
||
settingsPanel.style.display = panel === "settings" ? "" : "none";
|
||
this.updateMobileKeyboardDockLayout();
|
||
this.fit();
|
||
};
|
||
|
||
keysPanel.querySelector(".keybar-settings")!.addEventListener("touchstart", (e) => {
|
||
e.preventDefault();
|
||
showPanel("settings");
|
||
});
|
||
|
||
keysPanel.querySelector(".keybar-hide")!.addEventListener("touchstart", (e) => {
|
||
e.preventDefault();
|
||
this.setMobileKeyboardVisible(false);
|
||
});
|
||
keysPanel.querySelector(".keybar-hide")!.addEventListener("click", (e) => {
|
||
e.preventDefault();
|
||
this.setMobileKeyboardVisible(false);
|
||
});
|
||
|
||
settingsPanel.querySelector(".keybar-back")!.addEventListener("touchstart", (e) => {
|
||
e.preventDefault();
|
||
showPanel("keys");
|
||
});
|
||
|
||
// Bar size controls
|
||
const updateButtonHeight = (delta: number) => {
|
||
this.keybarButtonHeight = Math.max(28, Math.min(72, this.keybarButtonHeight + delta));
|
||
keybar.querySelectorAll("button").forEach((btn) => {
|
||
(btn as HTMLElement).style.height = `${this.keybarButtonHeight}px`;
|
||
});
|
||
this.updateMobileKeyboardDockLayout();
|
||
this.requestMobileVirtualKeyboardDraw();
|
||
this.fit();
|
||
};
|
||
|
||
settingsPanel.querySelector(".keybar-bar-shrink")!.addEventListener("touchstart", (e) => {
|
||
e.preventDefault();
|
||
updateButtonHeight(-4);
|
||
});
|
||
|
||
settingsPanel.querySelector(".keybar-bar-grow")!.addEventListener("touchstart", (e) => {
|
||
e.preventDefault();
|
||
updateButtonHeight(4);
|
||
});
|
||
|
||
// Font size controls
|
||
settingsPanel.querySelector(".keybar-font-shrink")!.addEventListener("touchstart", (e) => {
|
||
e.preventDefault();
|
||
this.fontSize = Math.max(8, this.fontSize - 1);
|
||
this.terminal.options.fontSize = this.fontSize;
|
||
this.fit();
|
||
});
|
||
|
||
settingsPanel.querySelector(".keybar-font-grow")!.addEventListener("touchstart", (e) => {
|
||
e.preventDefault();
|
||
this.fontSize = Math.max(8, this.fontSize + 1);
|
||
this.terminal.options.fontSize = this.fontSize;
|
||
this.fit();
|
||
});
|
||
|
||
const voiceModeButton = settingsPanel.querySelector(".keybar-voice-mode") as HTMLButtonElement | null;
|
||
const updateVoiceModeButton = () => {
|
||
if (!voiceModeButton) {
|
||
return;
|
||
}
|
||
voiceModeButton.textContent = this.voiceMode === "cleanup" ? "Clean" : "Live";
|
||
voiceModeButton.classList.toggle("active", this.voiceMode === "cleanup");
|
||
};
|
||
updateVoiceModeButton();
|
||
voiceModeButton?.addEventListener("touchstart", (e) => {
|
||
e.preventDefault();
|
||
this.setVoiceMode(this.voiceMode === "cleanup" ? "live" : "cleanup");
|
||
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) => {
|
||
e.preventDefault();
|
||
let key = (btn as HTMLElement).dataset.key || "";
|
||
// Unescape the key sequences
|
||
key = key.replace(/\\x([0-9a-fA-F]{2})/g, (_, hex) =>
|
||
String.fromCharCode(parseInt(hex, 16))
|
||
);
|
||
key = key.replace(/\\x1b/g, "\x1b");
|
||
if (key.length === 1) {
|
||
this.sendMobileText(key);
|
||
} else {
|
||
this.sendMobileSequence(key);
|
||
}
|
||
});
|
||
});
|
||
|
||
// Handle modifier toggles
|
||
keysPanel.querySelectorAll("button[data-modifier]").forEach((btn) => {
|
||
btn.addEventListener("touchstart", (e) => {
|
||
e.preventDefault();
|
||
const modifier = (btn as HTMLElement).dataset.modifier;
|
||
if (modifier === "ctrl") {
|
||
this.toggleModifierState("ctrl");
|
||
} else if (modifier === "alt") {
|
||
this.toggleModifierState("alt");
|
||
} else if (modifier === "shift") {
|
||
this.toggleModifierState("shift");
|
||
} else if (modifier === "fn") {
|
||
this.toggleModifierState("fn");
|
||
}
|
||
this.focusMobileInput();
|
||
});
|
||
});
|
||
|
||
}
|
||
|
||
/** Deactivate all modifiers */
|
||
private deactivateModifiers(): void {
|
||
this.ctrlActive = false;
|
||
this.altActive = false;
|
||
this.shiftActive = false;
|
||
this.fnActive = false;
|
||
this.pendingCtrl = false;
|
||
this.pendingAlt = false;
|
||
this.pendingShift = false;
|
||
this.pendingFn = false;
|
||
this.syncModifierButtons();
|
||
this.syncVirtualKeyboardState();
|
||
}
|
||
|
||
/** Focus the mobile input to show keyboard */
|
||
private focusMobileInput(): void {
|
||
if (this.usesVirtualKeyboard()) {
|
||
return;
|
||
}
|
||
// For programmatic focus (not from user gesture), this may not show keyboard on iOS
|
||
this.mobileInput?.focus();
|
||
}
|
||
|
||
/** Wait for fonts to be loaded */
|
||
private async waitForFonts(): Promise<void> {
|
||
if (!("fonts" in document)) {
|
||
return;
|
||
}
|
||
try {
|
||
await document.fonts.ready;
|
||
} catch {
|
||
// Ignore font loading errors
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Custom fit method that doesn't reserve space for scrollbar.
|
||
* The FitAddon subtracts 15px for a scrollbar, but ghostty-web
|
||
* uses canvas rendering without a visible scrollbar.
|
||
*/
|
||
private fit(): void {
|
||
// Try to get metrics from renderer (private but accessible at runtime)
|
||
const termAny = this.terminal as unknown as Record<string, unknown>;
|
||
const renderer = termAny.renderer as { getMetrics?: () => { width: number; height: number } } | undefined;
|
||
|
||
let cellWidth: number;
|
||
let cellHeight: number;
|
||
|
||
if (renderer?.getMetrics) {
|
||
const metrics = renderer.getMetrics();
|
||
if (metrics && metrics.width > 0 && metrics.height > 0) {
|
||
cellWidth = metrics.width;
|
||
cellHeight = metrics.height;
|
||
} else {
|
||
// Fall back to measuring
|
||
const dims = this.measureCellSize();
|
||
if (!dims) {
|
||
this.fitAddon.fit();
|
||
return;
|
||
}
|
||
cellWidth = dims.width;
|
||
cellHeight = dims.height;
|
||
}
|
||
} else {
|
||
// Fall back to measuring
|
||
const dims = this.measureCellSize();
|
||
if (!dims) {
|
||
this.fitAddon.fit();
|
||
return;
|
||
}
|
||
cellWidth = dims.width;
|
||
cellHeight = dims.height;
|
||
}
|
||
|
||
const style = window.getComputedStyle(this.element);
|
||
const paddingTop = parseInt(style.paddingTop) || 0;
|
||
const paddingBottom = parseInt(style.paddingBottom) || 0;
|
||
const paddingLeft = parseInt(style.paddingLeft) || 0;
|
||
const paddingRight = parseInt(style.paddingRight) || 0;
|
||
|
||
const availableWidth = this.element.clientWidth - paddingLeft - paddingRight;
|
||
const availableHeight = this.element.clientHeight - paddingTop - paddingBottom;
|
||
|
||
if (availableWidth <= 0 || availableHeight <= 0) {
|
||
return;
|
||
}
|
||
|
||
const cols = Math.max(2, Math.floor(availableWidth / cellWidth));
|
||
const rows = Math.max(1, Math.floor(availableHeight / cellHeight));
|
||
|
||
if (cols !== this.terminal.cols || rows !== this.terminal.rows) {
|
||
this.terminal.resize(cols, rows);
|
||
}
|
||
}
|
||
|
||
/** Measure cell size by creating a test character */
|
||
private measureCellSize(): { width: number; height: number } | null {
|
||
const testElement = document.createElement('span');
|
||
testElement.style.visibility = 'hidden';
|
||
testElement.style.position = 'absolute';
|
||
testElement.style.fontFamily = this.fontFamily;
|
||
testElement.style.fontSize = `${this.fontSize}px`;
|
||
testElement.style.lineHeight = 'normal';
|
||
testElement.textContent = 'W';
|
||
|
||
document.body.appendChild(testElement);
|
||
const width = testElement.offsetWidth;
|
||
const height = testElement.offsetHeight;
|
||
document.body.removeChild(testElement);
|
||
|
||
if (width > 0 && height > 0) {
|
||
return { width, height };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** Setup resize observer for container */
|
||
private setupResizeObserver(): void {
|
||
this.resizeObserver = new ResizeObserver(() => {
|
||
// Debounce resize events
|
||
if (this.resizeDebounceTimer) {
|
||
clearTimeout(this.resizeDebounceTimer);
|
||
}
|
||
this.resizeDebounceTimer = window.setTimeout(() => {
|
||
this.fit();
|
||
}, 100);
|
||
});
|
||
this.resizeObserver.observe(this.element);
|
||
}
|
||
|
||
private resizeDebounceTimer: number | undefined;
|
||
|
||
/** Validate terminal dimensions */
|
||
private isValidSize(cols: number, rows: number): boolean {
|
||
return cols >= 2 && cols <= 500 && rows >= 1 && rows <= 500;
|
||
}
|
||
|
||
/** Connect to WebSocket server */
|
||
connect(): void {
|
||
if (this.socket?.readyState === WebSocket.OPEN) {
|
||
return;
|
||
}
|
||
if (this.isOffline) {
|
||
this.offlinePendingReconnect = true;
|
||
this.showUserError("Offline. Will reconnect when network returns.");
|
||
return;
|
||
}
|
||
|
||
const gen = ++this.socketGeneration;
|
||
this.socket = new WebSocket(this.wsUrl);
|
||
this.socket.binaryType = "arraybuffer";
|
||
|
||
this.socket.addEventListener("open", () => {
|
||
if (gen !== this.socketGeneration) return;
|
||
this.reconnectAttempts = 0;
|
||
this.clearUserError();
|
||
if (!this.isTabHidden) {
|
||
this.startHeartbeatWatchdog();
|
||
}
|
||
this.element.classList.add("-connected");
|
||
this.element.classList.remove("-disconnected");
|
||
|
||
// Flush any batched stdin and process queued messages
|
||
this.flushStdin();
|
||
this.processMessageQueue();
|
||
|
||
// Send initial size
|
||
const cols = this.terminal.cols;
|
||
const rows = this.terminal.rows;
|
||
if (this.isValidSize(cols, rows)) {
|
||
this.lastValidSize = { cols, rows };
|
||
this.send(["resize", { width: cols, height: rows }]);
|
||
}
|
||
|
||
if (!this.usesVirtualKeyboard()) {
|
||
this.terminal.focus();
|
||
}
|
||
});
|
||
|
||
this.socket.addEventListener("close", (event) => {
|
||
if (gen !== this.socketGeneration) return;
|
||
this.stopHeartbeatWatchdog();
|
||
this.element.classList.remove("-connected");
|
||
this.element.classList.add("-disconnected");
|
||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||
this.showUserError("Disconnected. Max reconnection attempts reached.");
|
||
} else if (!event.wasClean) {
|
||
this.showUserError("Connection lost. Reconnecting...");
|
||
}
|
||
this.scheduleReconnect();
|
||
});
|
||
|
||
this.socket.addEventListener("error", () => {
|
||
this.showUserError("WebSocket error. Reconnecting...");
|
||
});
|
||
|
||
this.socket.addEventListener("message", (event) => {
|
||
if (gen !== this.socketGeneration) return;
|
||
this.handleMessage(event.data);
|
||
});
|
||
}
|
||
|
||
/** Handle incoming WebSocket message */
|
||
private handleTextMessage(data: string): void {
|
||
try {
|
||
const envelope = JSON.parse(data) as [string, unknown];
|
||
const [type, payload] = envelope;
|
||
|
||
switch (type) {
|
||
case "stdout":
|
||
if (this.isTabHidden) {
|
||
this.bufferWhileHidden(payload as string);
|
||
} else {
|
||
this.terminal.write(payload as string);
|
||
}
|
||
break;
|
||
case "pong":
|
||
this.lastPongAt = Date.now();
|
||
break;
|
||
default:
|
||
wsLog.debug("Unknown WebSocket message type", { type });
|
||
}
|
||
} catch {
|
||
if (this.isTabHidden) {
|
||
this.bufferWhileHidden(data);
|
||
} else {
|
||
this.terminal.write(data);
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Handle incoming WebSocket message */
|
||
private handleMessage(data: string | ArrayBuffer | Blob): void {
|
||
this.lastMessageAt = Date.now();
|
||
if (data instanceof ArrayBuffer) {
|
||
const bytes = new Uint8Array(data);
|
||
if (this.isTabHidden) {
|
||
this.bufferWhileHidden(bytes);
|
||
} else {
|
||
this.terminal.write(bytes);
|
||
}
|
||
return;
|
||
}
|
||
if (data instanceof Blob) {
|
||
void data.text().then((text) => {
|
||
this.lastMessageAt = Date.now();
|
||
this.handleTextMessage(text);
|
||
}).catch(() => {
|
||
// Ignore blob decode failures; reconnect watchdog will recover if needed.
|
||
});
|
||
return;
|
||
}
|
||
this.handleTextMessage(data);
|
||
}
|
||
|
||
private startHeartbeatWatchdog(): void {
|
||
this.stopHeartbeatWatchdog();
|
||
const now = Date.now();
|
||
this.lastMessageAt = now;
|
||
this.lastPongAt = now;
|
||
this.heartbeatTimer = window.setInterval(() => {
|
||
if (this.socket?.readyState !== WebSocket.OPEN) {
|
||
return;
|
||
}
|
||
const now = Date.now();
|
||
const lastInbound = Math.max(this.lastMessageAt, this.lastPongAt);
|
||
if (now - lastInbound > this.stallTimeoutMs) {
|
||
wsLog.warn("WebSocket inbound stream stalled; reconnecting", {
|
||
stallTimeoutMs: this.stallTimeoutMs,
|
||
lastInboundAt: lastInbound,
|
||
now,
|
||
});
|
||
this.socket.close();
|
||
return;
|
||
}
|
||
this.send(["ping", String(now)]);
|
||
}, this.heartbeatIntervalMs);
|
||
}
|
||
|
||
private stopHeartbeatWatchdog(): void {
|
||
if (this.heartbeatTimer) {
|
||
clearInterval(this.heartbeatTimer);
|
||
this.heartbeatTimer = undefined;
|
||
}
|
||
}
|
||
|
||
/** Start periodic resource cleanup to prevent memory leaks */
|
||
private startResourceCleanup(): void {
|
||
this.cleanupTimer = window.setInterval(() => {
|
||
this.trimMessageQueue();
|
||
}, RESOURCE_CLEANUP_INTERVAL_MS);
|
||
}
|
||
|
||
/** Stop periodic resource cleanup */
|
||
private stopResourceCleanup(): void {
|
||
if (this.cleanupTimer) {
|
||
clearInterval(this.cleanupTimer);
|
||
this.cleanupTimer = undefined;
|
||
}
|
||
}
|
||
|
||
/** Drop oldest messages when the queue exceeds the cap */
|
||
private trimMessageQueue(): void {
|
||
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);
|
||
wsLog.warn("Trimmed stale messages from queue", { dropped, queueSize: this.messageQueue.length });
|
||
}
|
||
}
|
||
|
||
/** Buffer terminal data while the tab is hidden instead of writing to WASM */
|
||
private bufferWhileHidden(data: string | Uint8Array): void {
|
||
const chunk = typeof data === "string"
|
||
? WebTerminal.sharedTextEncoder.encode(data)
|
||
: data;
|
||
while (
|
||
this.hiddenBufferBytes + chunk.byteLength > MAX_HIDDEN_BUFFER_BYTES &&
|
||
this.hiddenBuffer.length > 0
|
||
) {
|
||
const evicted = this.hiddenBuffer.shift()!;
|
||
this.hiddenBufferBytes -= evicted.byteLength;
|
||
}
|
||
this.hiddenBuffer.push(chunk);
|
||
this.hiddenBufferBytes += chunk.byteLength;
|
||
}
|
||
|
||
/** Discard hidden buffer and reconnect to get clean state from server replay */
|
||
private refreshConnection(): void {
|
||
this.hiddenBuffer.length = 0;
|
||
this.hiddenBufferBytes = 0;
|
||
this.reconnectAttempts = 0;
|
||
this.stopHeartbeatWatchdog();
|
||
if (this.socket) {
|
||
this.socket.close();
|
||
this.socket = null;
|
||
}
|
||
this.connect();
|
||
}
|
||
|
||
/** Queue stdin data for batched sending */
|
||
private sendStdin(data: string): void {
|
||
if (!data) {
|
||
return;
|
||
}
|
||
|
||
this.pendingStdin += data;
|
||
|
||
// Flush immediately for large payloads (e.g. paste) to avoid excessive buffering.
|
||
if (this.pendingStdin.length >= STDIN_BATCH_MAX_CHARS) {
|
||
this.flushStdin();
|
||
return;
|
||
}
|
||
|
||
if (this.pendingStdinTimer) {
|
||
return;
|
||
}
|
||
|
||
this.pendingStdinTimer = window.setTimeout(() => {
|
||
this.pendingStdinTimer = undefined;
|
||
this.flushStdin();
|
||
}, STDIN_BATCH_DELAY_MS);
|
||
}
|
||
|
||
private flushStdin(): void {
|
||
if (this.pendingStdinTimer) {
|
||
clearTimeout(this.pendingStdinTimer);
|
||
this.pendingStdinTimer = undefined;
|
||
}
|
||
if (!this.pendingStdin) {
|
||
return;
|
||
}
|
||
const chunk = this.pendingStdin;
|
||
this.pendingStdin = "";
|
||
this.send(["stdin", chunk]);
|
||
}
|
||
|
||
/** Send message to server with queueing support */
|
||
private send(message: [string, unknown]): void {
|
||
// Preserve ordering: flush any pending stdin before non-stdin messages (resize/ping/etc).
|
||
if (message[0] !== "stdin" && this.pendingStdin) {
|
||
this.flushStdin();
|
||
}
|
||
|
||
if (this.messageQueue.length >= MAX_MESSAGE_QUEUE_SIZE) {
|
||
this.messageQueue = this.messageQueue.slice(-Math.floor(MAX_MESSAGE_QUEUE_SIZE / 2));
|
||
wsLog.warn("Message queue overflow; trimmed old messages", {
|
||
queueSize: this.messageQueue.length,
|
||
messageType: message[0],
|
||
});
|
||
}
|
||
this.messageQueue.push(message);
|
||
this.processMessageQueue();
|
||
}
|
||
|
||
/** Process queued messages when WebSocket is ready */
|
||
private processMessageQueue(): void {
|
||
if (this.socket?.readyState !== WebSocket.OPEN) {
|
||
return;
|
||
}
|
||
|
||
while (this.messageQueue.length > 0) {
|
||
const message = this.messageQueue.shift();
|
||
try {
|
||
if (message) {
|
||
this.socket.send(JSON.stringify(message));
|
||
}
|
||
} catch (e) {
|
||
wsLog.error("Failed to send WebSocket message", {
|
||
error: e,
|
||
messageType: message?.[0],
|
||
});
|
||
if (message) {
|
||
this.messageQueue.unshift(message);
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Schedule reconnection attempt */
|
||
private scheduleReconnect(): void {
|
||
if (this.isOffline) {
|
||
this.offlinePendingReconnect = true;
|
||
this.showUserError("Offline. Will reconnect when network returns.");
|
||
return;
|
||
}
|
||
|
||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||
wsLog.error("Max reconnection attempts reached", {
|
||
reconnectAttempts: this.reconnectAttempts,
|
||
maxReconnectAttempts: this.maxReconnectAttempts,
|
||
});
|
||
this.showUserError("Disconnected. Max reconnection attempts reached.");
|
||
return;
|
||
}
|
||
|
||
this.reconnectAttempts++;
|
||
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
|
||
|
||
setTimeout(() => {
|
||
wsLog.info("Reconnecting WebSocket", {
|
||
attempt: this.reconnectAttempts,
|
||
delayMs: delay,
|
||
});
|
||
this.connect();
|
||
}, delay);
|
||
}
|
||
|
||
/** Clean up resources */
|
||
dispose(): void {
|
||
this.wakeLockEnabled = false;
|
||
void this.releaseWakeLock();
|
||
void this.stopVoiceInput();
|
||
this.stopResourceCleanup();
|
||
this.stopHeartbeatWatchdog();
|
||
if (this.pendingStdinTimer) {
|
||
clearTimeout(this.pendingStdinTimer);
|
||
this.pendingStdinTimer = undefined;
|
||
}
|
||
if (this.mobileVirtualKeyboardDrawFrame !== null) {
|
||
cancelAnimationFrame(this.mobileVirtualKeyboardDrawFrame);
|
||
this.mobileVirtualKeyboardDrawFrame = null;
|
||
}
|
||
this.mobileVirtualKeyboardActivePresses.forEach((press) => {
|
||
this.clearVirtualKeyboardRepeat(press);
|
||
});
|
||
this.mobileVirtualKeyboardActivePresses.clear();
|
||
this.pendingStdin = "";
|
||
if (this.resizeDebounceTimer) {
|
||
clearTimeout(this.resizeDebounceTimer);
|
||
this.resizeDebounceTimer = undefined;
|
||
}
|
||
this.socket?.close();
|
||
this.socket = null;
|
||
this.messageQueue.length = 0;
|
||
this.hiddenBuffer.length = 0;
|
||
this.hiddenBufferBytes = 0;
|
||
// Remove all tracked event listeners
|
||
for (const { target, type, handler, options } of this.boundHandlers) {
|
||
target.removeEventListener(type, handler, options);
|
||
}
|
||
this.boundHandlers.length = 0;
|
||
// Disconnect resize observer
|
||
if (this.resizeObserver) {
|
||
this.resizeObserver.disconnect();
|
||
this.resizeObserver = null;
|
||
}
|
||
if (this.mobileInput) {
|
||
this.mobileInput.remove();
|
||
this.mobileInput = null;
|
||
}
|
||
if (this.mobileKeybar) {
|
||
this.mobileKeybar.remove();
|
||
this.mobileKeybar = null;
|
||
}
|
||
if (this.mobileVirtualKeyboard) {
|
||
this.mobileVirtualKeyboard.remove();
|
||
this.mobileVirtualKeyboard = null;
|
||
}
|
||
if (this.mobileVirtualKeyboardHost) {
|
||
this.mobileVirtualKeyboardHost.remove();
|
||
this.mobileVirtualKeyboardHost = null;
|
||
}
|
||
this.mobileVirtualKeyboardBounds = [];
|
||
if (this.mobileKeybarStyle) {
|
||
this.mobileKeybarStyle.remove();
|
||
this.mobileKeybarStyle = null;
|
||
}
|
||
if (this.errorOverlay) {
|
||
this.errorOverlay.remove();
|
||
this.errorOverlay = null;
|
||
}
|
||
if (this.voiceControls) {
|
||
this.voiceControls.remove();
|
||
this.voiceControls = null;
|
||
}
|
||
this.voiceStatus = null;
|
||
this.destroyVoiceEngine();
|
||
this.fitAddon.dispose();
|
||
this.terminal.dispose();
|
||
}
|
||
|
||
/** Set terminal theme dynamically (accesses private renderer) */
|
||
setTheme(theme: ITheme): void {
|
||
// Use the Terminal's options proxy so handleOptionChange fires,
|
||
// which updates the renderer theme AND triggers a re-render.
|
||
(this.terminal as unknown as { options: { theme: ITheme } }).options.theme = theme;
|
||
}
|
||
|
||
/** Get a named theme from the built-in themes */
|
||
static getTheme(name: string): ITheme | undefined {
|
||
return THEMES[name.toLowerCase()];
|
||
}
|
||
}
|
||
|
||
// Store instances for potential external access
|
||
const instances: Map<HTMLElement, WebTerminal> = new Map();
|
||
|
||
function renderTerminalStartupError(container: HTMLElement, message: string): void {
|
||
if (window.getComputedStyle(container).position === "static") {
|
||
container.style.position = "relative";
|
||
}
|
||
let overlay = container.querySelector<HTMLDivElement>(".webterm-startup-error");
|
||
if (!overlay) {
|
||
overlay = document.createElement("div");
|
||
overlay.className = "webterm-startup-error";
|
||
overlay.style.cssText = `
|
||
position: absolute;
|
||
inset: 12px 12px auto 12px;
|
||
z-index: 40;
|
||
padding: 10px 12px;
|
||
border-radius: 8px;
|
||
background: rgba(95, 15, 15, 0.92);
|
||
border: 1px solid rgba(255, 120, 120, 0.45);
|
||
color: #fff3f3;
|
||
font: 13px/1.4 system-ui, sans-serif;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||
`;
|
||
container.appendChild(overlay);
|
||
}
|
||
overlay.textContent = message;
|
||
}
|
||
|
||
// Periodically sweep stale terminal instances whose containers were removed from the DOM
|
||
setInterval(() => {
|
||
for (const [el, terminal] of instances) {
|
||
if (!el.isConnected) {
|
||
terminal.dispose();
|
||
instances.delete(el);
|
||
uiLog.info("Cleaned up stale terminal instance");
|
||
}
|
||
}
|
||
}, RESOURCE_CLEANUP_INTERVAL_MS);
|
||
|
||
/** Initialize all terminal containers on page load */
|
||
async function initTerminals(): Promise<void> {
|
||
const containers = document.querySelectorAll<HTMLElement>(".webterm-terminal");
|
||
|
||
for (const el of containers) {
|
||
const wsUrl = el.dataset.sessionWebsocketUrl;
|
||
if (!wsUrl) {
|
||
bugLog.error("Missing data-session-websocket-url on terminal container");
|
||
renderTerminalStartupError(el, "Terminal startup failed: missing websocket URL.");
|
||
continue;
|
||
}
|
||
|
||
const config = parseConfig(el);
|
||
try {
|
||
const terminal = await WebTerminal.create(el, wsUrl, config);
|
||
instances.set(el, terminal);
|
||
} catch (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}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Auto-initialize on DOM ready
|
||
if (document.readyState === "loading") {
|
||
document.addEventListener("DOMContentLoaded", () => initTerminals());
|
||
} else {
|
||
initTerminals();
|
||
}
|
||
|
||
if ("serviceWorker" in navigator) {
|
||
navigator.serviceWorker.register("/sw.js").catch(() => undefined);
|
||
}
|
||
|
||
// Export for potential external use
|
||
export { WebTerminal, initTerminals, instances, THEMES };
|