fix: improve mobile keyboard behavior
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -30,6 +30,7 @@ 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";
|
||||
@@ -39,8 +40,6 @@ type VoiceMode = "live" | "cleanup";
|
||||
type VoiceFinalizeAction = "insert" | "submit";
|
||||
type VoiceCommandAction = VoiceFinalizeAction | "cancel";
|
||||
|
||||
const VOICE_THINKING_SYSTEM_PROMPT = `You are a helpful voice-to-task translator. You will recieve a raw speech-to-text transcript that may contain filler words, false starts, and rambling. Then you will receive some instructions after which you'll analyze the text in the best way to help clean it up. Do not ask any questions. Just think outloud.`;
|
||||
const VOICE_THINKING_ANALYSIS_PROMPT = `Analyze the user's Intent and the Functional meaning of each sentence. Evaluate Correctness — are these genuine instructions or speech mistakes? Consider Efficiency of each phrase and whether there are better Alternatives or unnecessary filler words to remove.`;
|
||||
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 = {
|
||||
@@ -105,6 +104,12 @@ 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;
|
||||
@@ -1064,6 +1069,8 @@ class WebTerminal {
|
||||
private voicePendingSeparator = false;
|
||||
private voiceDraftTranscript = "";
|
||||
private voiceFinalizeToken = 0;
|
||||
private wakeLockSentinel: WakeLockSentinelLike | null = null;
|
||||
private wakeLockEnabled = false;
|
||||
private isVoiceStarting = false;
|
||||
private voiceState: "idle" | "loading" | "listening" | "processing" | "error" | "unsupported" = "idle";
|
||||
private voiceStartupErrorCleanup: (() => void) | null = null;
|
||||
@@ -1350,16 +1357,9 @@ class WebTerminal {
|
||||
}
|
||||
|
||||
private async cleanupVoiceTranscript(rawTranscript: string): Promise<string> {
|
||||
const analysisUser = `${rawTranscript}\n\n${VOICE_THINKING_ANALYSIS_PROMPT}`;
|
||||
const analysisResponse = await this.requestVoiceChatCompletion([
|
||||
{ role: "system", content: VOICE_THINKING_SYSTEM_PROMPT },
|
||||
{ role: "user", content: analysisUser },
|
||||
]);
|
||||
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: analysisUser },
|
||||
{ role: "assistant", content: analysisResponse },
|
||||
{ role: "user", content: finalUser },
|
||||
]);
|
||||
}
|
||||
@@ -1399,6 +1399,47 @@ class WebTerminal {
|
||||
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;
|
||||
sentinel.addEventListener?.("release", (() => {
|
||||
this.wakeLockSentinel = null;
|
||||
if (this.wakeLockEnabled && !document.hidden) {
|
||||
void this.acquireWakeLock();
|
||||
}
|
||||
}) as EventListener);
|
||||
} catch (error) {
|
||||
console.warn("[webterm] Failed to acquire wake lock:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private async releaseWakeLock(): Promise<void> {
|
||||
const sentinel = this.wakeLockSentinel;
|
||||
this.wakeLockSentinel = null;
|
||||
if (!sentinel) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await sentinel.release();
|
||||
} catch {
|
||||
// Ignore release failures on already-released sentinels.
|
||||
}
|
||||
}
|
||||
|
||||
private ensureErrorOverlay(): HTMLDivElement {
|
||||
if (this.errorOverlay) {
|
||||
return this.errorOverlay;
|
||||
@@ -1499,6 +1540,9 @@ class WebTerminal {
|
||||
|
||||
/** Initialize event handlers and connect */
|
||||
private initialize(): void {
|
||||
this.wakeLockEnabled = true;
|
||||
void this.acquireWakeLock();
|
||||
|
||||
// Wait for fonts to load before fitting to ensure correct measurements
|
||||
//
|
||||
// FONT INITIALIZATION (ghostty-web):
|
||||
@@ -1621,21 +1665,25 @@ class WebTerminal {
|
||||
if (document.hidden) {
|
||||
this.isTabHidden = true;
|
||||
this.stopHeartbeatWatchdog();
|
||||
void this.releaseWakeLock();
|
||||
} else {
|
||||
this.isTabHidden = false;
|
||||
this.refreshConnection();
|
||||
restoreFocus();
|
||||
void this.acquireWakeLock();
|
||||
}
|
||||
});
|
||||
|
||||
// Restore focus when browser window regains focus
|
||||
this.addTrackedListener(window, "focus", () => {
|
||||
restoreFocus();
|
||||
void this.acquireWakeLock();
|
||||
});
|
||||
|
||||
// Safari can restore tabs via bfcache without a focus event.
|
||||
this.addTrackedListener(window, "pageshow", () => {
|
||||
restoreFocus();
|
||||
void this.acquireWakeLock();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2755,16 +2803,34 @@ class WebTerminal {
|
||||
this.mobileVirtualKeyboardHost.style.height = `${Math.round(this.virtualKeyboardBaseHeight / scale)}px`;
|
||||
}
|
||||
|
||||
private getMobileKeyboardBottomInset(): number {
|
||||
if (typeof window === "undefined" || typeof navigator === "undefined") {
|
||||
return 0;
|
||||
}
|
||||
const standalone =
|
||||
window.matchMedia?.("(display-mode: standalone)")?.matches ||
|
||||
Boolean((navigator as Navigator & { standalone?: boolean }).standalone);
|
||||
const isiPhoneLike = /iPhone|iPod/.test(navigator.userAgent);
|
||||
return standalone && isiPhoneLike ? MOBILE_PWA_BOTTOM_INSET_PX : 0;
|
||||
}
|
||||
|
||||
private updateMobileKeyboardDockLayout(): void {
|
||||
const safeAreaBottom = this.getMobileKeyboardBottomInset();
|
||||
if (this.mobileVirtualKeyboardHost) {
|
||||
this.mobileVirtualKeyboardHost.style.bottom = "0";
|
||||
this.mobileVirtualKeyboardHost.style.paddingBottom = "0";
|
||||
this.mobileVirtualKeyboardHost.style.transform =
|
||||
safeAreaBottom > 0 ? `translateY(-${safeAreaBottom}px)` : "";
|
||||
}
|
||||
if (this.mobileKeybar) {
|
||||
const keyboardHeight = this.mobileVirtualKeyboardHost?.offsetHeight ?? 0;
|
||||
this.mobileKeybar.style.bottom = `${keyboardHeight}px`;
|
||||
this.mobileKeybar.style.paddingBottom = "0";
|
||||
this.mobileKeybar.style.transform =
|
||||
safeAreaBottom > 0 ? `translateY(-${safeAreaBottom}px)` : "";
|
||||
}
|
||||
const keyboardHeight = this.mobileKeyboardVisible
|
||||
? (this.mobileKeybar?.offsetHeight ?? 0) + (this.mobileVirtualKeyboardHost?.offsetHeight ?? 0)
|
||||
? (this.mobileKeybar?.offsetHeight ?? 0) + (this.mobileVirtualKeyboardHost?.offsetHeight ?? 0) + safeAreaBottom
|
||||
: 0;
|
||||
this.element.style.paddingBottom = `${keyboardHeight}px`;
|
||||
}
|
||||
@@ -3846,6 +3912,8 @@ class WebTerminal {
|
||||
|
||||
/** Clean up resources */
|
||||
dispose(): void {
|
||||
this.wakeLockEnabled = false;
|
||||
void this.releaseWakeLock();
|
||||
void this.stopVoiceInput();
|
||||
this.stopResourceCleanup();
|
||||
this.stopHeartbeatWatchdog();
|
||||
|
||||
Reference in New Issue
Block a user