fix: restore mobile virtual keyboard flow

This commit is contained in:
2026-05-12 12:00:44 -04:00
parent 541e0e1fe8
commit 1696391441
5 changed files with 190 additions and 242 deletions
+4
View File
@@ -24,6 +24,10 @@ Use `gofmt` for Go formatting; run `make format` before submitting Go-heavy chan
Go tests use the standard `testing` package. Name tests `TestXxx` and fuzz tests `FuzzXxx`; keep them next to the code they validate. Prefer focused unit tests for `webterm/` and `internal/` changes. Run `make test` for normal work and `make check` before opening a PR. Frontend changes should at minimum pass `bun run typecheck` via `make build`.
## Debugging Rule
Before making a second patch for the same bug, identify the exact callsite that causes the side effect. Trace the real owner of the behavior end-to-end, including library or dependency code when needed, instead of only patching app-level symptoms.
## Commit & Pull Request Guidelines
Recent history uses short imperative subjects, sometimes with prefixes such as `feat:`, `fix:`, and `deps:`. Keep commits focused, e.g. `fix: restore websocket reconnect on hidden-tab resume`. PRs should explain user-visible behavior, note test coverage, and include screenshots or recordings for terminal/UI changes. Link related issues when applicable.
+4
View File
@@ -54,3 +54,7 @@ Run `go run ./cmd/webterm` to start the server on port 8080.
- The `SessionConnector` interface decouples session I/O from the WebSocket layer
- Docker integration uses raw HTTP against the Docker socket (no Docker SDK dependency)
- Version is read from the `VERSION` file and injected via `-ldflags` at build time
## Debugging Rule
Before making a second patch for the same bug, identify the exact callsite that causes the side effect. Trace the real owner of the behavior end-to-end, including dependency code such as `ghostty-web` when necessary, instead of stacking app-level symptom fixes.
+12
View File
@@ -1,3 +1,15 @@
# Deferred: Ghostty mobile long-press copy
## Summary
Mobile long-press copy for highlighted terminal text should be implemented in Ghostty ownership layer, not in `webterm/static/js/terminal.ts`.
## Note
- Selection, copy, context menu, and touch-selection semantics are owned by `ghostty-web`, mainly `lib/selection-manager.ts`.
- Wrapper-level gesture handling in `webterm/static/js/terminal.ts` is not correct long-term fix for “long press highlighted text to copy”.
- Future work should patch Ghostty selection manager directly so long-press copy uses Ghosttys real selection state and copy path.
# Bug: Render loop dies silently on uncaught exception
## Summary
File diff suppressed because one or more lines are too long
+149 -221
View File
@@ -791,10 +791,12 @@ const VIRTUAL_KEYBOARD_ALPHA_LAYOUT: VirtualKeyboardKey[][] = [
{ kind: "action", label: "⌫", value: "Backspace", seq: "\x7f" },
],
[
{ kind: "action", label: "123", actionId: "mode-symbol" },
{ kind: "space", label: "", value: " ", width: 4 },
{ kind: "action", label: "", seq: "\r" },
{ kind: "action", label: "✂︎", actionId: "mode-selection" },
{ kind: "action", label: "123", actionId: "mode-symbol", width: 1.05 },
{ kind: "modifier", label: "Ctrl", modifier: "ctrl", width: 1.05 },
{ kind: "modifier", label: "Alt", modifier: "alt", width: 1.05 },
{ kind: "space", label: "", value: " ", width: 3.1 },
{ kind: "action", label: "Mic", actionId: "toggle-voice", width: 1.15 },
{ kind: "action", label: "⏎", seq: "\r", width: 1.15 },
],
];
@@ -843,33 +845,10 @@ const VIRTUAL_KEYBOARD_SYMBOL_LAYOUT: VirtualKeyboardKey[][] = [
],
];
const VIRTUAL_KEYBOARD_SELECTION_LAYOUT: VirtualKeyboardKey[][] = [
[
{ kind: "action", label: "Select", actionId: "toggle-selection-adjust", width: 1.4 },
{ kind: "action", label: "←", value: "ArrowLeft", seq: "\x1b[D" },
{ kind: "action", label: "→", value: "ArrowRight", seq: "\x1b[C" },
],
[
{ kind: "action", label: "Copy", actionId: "copy-selection", width: 1.4 },
{ kind: "action", label: "Paste", actionId: "paste-selection", width: 1.4 },
{ kind: "action", label: "Cut", actionId: "cut-selection", width: 1.2 },
{ kind: "action", label: "⌫", value: "Backspace", seq: "\x7f" },
],
[
{ kind: "action", label: "✂︎", actionId: "mode-alpha" },
{ kind: "space", label: "␣", value: " ", width: 2.8 },
{ kind: "action", label: "⏎", seq: "\r" },
],
];
type VirtualKeyboardActionId =
| "mode-alpha"
| "mode-selection"
| "mode-symbol"
| "toggle-selection-adjust"
| "copy-selection"
| "paste-selection"
| "cut-selection";
| "toggle-voice";
type VirtualKeyboardKeyBounds = {
x: number;
@@ -1016,12 +995,9 @@ class WebTerminal {
private mobileVirtualKeyboard: HTMLCanvasElement | null = null;
private mobileKeyboardVisible = false;
private mobileKeyboardMode: "alpha" | "symbol" = "alpha";
private mobileSelectionMode = false;
private mobileSelectionAdjusting = false;
private mobileVirtualKeyboardBounds: VirtualKeyboardKeyBounds[] = [];
private mobileVirtualKeyboardActivePresses = new Map<number, ActiveVirtualKeyboardPress>();
private mobileVirtualKeyboardDrawFrame: number | null = null;
private lastMobileKeyboardTouchAt = 0;
private ctrlActive = false;
private altActive = false;
private shiftActive = false;
@@ -1030,6 +1006,7 @@ class WebTerminal {
private pendingAlt = false;
private pendingShift = false;
private pendingFn = false;
private allowMobileKeyboardOpen = false;
private fontFamily: string;
private fontSize: number;
private cleanupTimer: number | undefined;
@@ -1044,7 +1021,6 @@ class WebTerminal {
private bellActive = false;
private routeKey: string;
private voiceControls: HTMLElement | null = null;
private voiceButton: HTMLButtonElement | null = null;
private voiceStatus: HTMLElement | null = null;
private voiceRecognizer: SherpaOfflineRecognizer | null = null;
private voiceVad: SherpaVad | null = null;
@@ -1058,6 +1034,7 @@ class WebTerminal {
private voiceSpeechDetected = false;
private voiceReceivedAudio = false;
private isVoiceStarting = false;
private voiceState: "idle" | "loading" | "listening" | "error" | "unsupported" = "idle";
private voiceStartupErrorCleanup: (() => void) | null = null;
private static sharedTextEncoder = new TextEncoder();
@@ -1092,6 +1069,47 @@ class WebTerminal {
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;
@@ -1226,6 +1244,7 @@ class WebTerminal {
routeKey,
baseTitle
);
instance.disableNativeMobileTerminalInput();
instance.initialize();
return instance;
}
@@ -1282,7 +1301,7 @@ class WebTerminal {
this.setupMobileKeyboard();
}
this.addTrackedListener(
this.element,
document,
"focusin",
((event: FocusEvent) => {
if (!this.usesVirtualKeyboard()) {
@@ -1305,45 +1324,6 @@ class WebTerminal {
// Setup mobile extended keybar (only on mobile devices)
if (isMobileDevice()) {
this.setupMobileKeybar();
this.addTrackedListener(this.element, "touchstart", ((event: TouchEvent) => {
if (!this.usesVirtualKeyboard()) {
return;
}
const target = event.target;
if (
target instanceof Node &&
((this.mobileVirtualKeyboardHost && this.mobileVirtualKeyboardHost.contains(target)) ||
(this.mobileKeybar && this.mobileKeybar.contains(target)))
) {
return;
}
event.preventDefault();
event.stopPropagation();
this.lastMobileKeyboardTouchAt = Date.now();
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
this.toggleMobileKeyboard();
}) as EventListener, { passive: false });
this.addTrackedListener(this.element, "click", ((event: MouseEvent) => {
if (!this.usesVirtualKeyboard()) {
return;
}
if (Date.now() - this.lastMobileKeyboardTouchAt < 750) {
event.preventDefault();
event.stopPropagation();
return;
}
const target = event.target;
if (
target instanceof Node &&
((this.mobileVirtualKeyboardHost && this.mobileVirtualKeyboardHost.contains(target)) ||
(this.mobileKeybar && this.mobileKeybar.contains(target)))
) {
return;
}
this.toggleMobileKeyboard();
}) as EventListener);
this.addTrackedListener(document, "click", ((event: MouseEvent) => {
if (!this.usesVirtualKeyboard() || !this.mobileKeyboardVisible) {
return;
@@ -1650,6 +1630,7 @@ class WebTerminal {
if (!canvas) return;
const LONG_PRESS_MS = 300;
const COPY_LONG_PRESS_MS = 500;
const MOVE_THRESHOLD = 10;
const SCROLL_SPEED = 1.5;
@@ -1658,6 +1639,8 @@ class WebTerminal {
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;
@@ -1681,6 +1664,10 @@ class WebTerminal {
clearTimeout(longPressTimer);
longPressTimer = null;
}
if (copyLongPressTimer !== null) {
clearTimeout(copyLongPressTimer);
copyLongPressTimer = null;
}
};
const stopMomentum = () => {
@@ -1733,10 +1720,25 @@ class WebTerminal {
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") {
if (mode === "undecided" && !copiedSelection) {
mode = "select";
dispatchMouse("mousedown", touch);
}
@@ -1797,6 +1799,12 @@ class WebTerminal {
e.preventDefault();
cancelLongPress();
if (copiedSelection) {
copiedSelection = false;
mode = "undecided";
return;
}
if (mode === "select") {
dispatchMouse("mouseup", touch);
} else if (mode === "scroll") {
@@ -1804,8 +1812,9 @@ class WebTerminal {
if (Math.abs(velocityY) > 0.5) {
momentumFrame = requestAnimationFrame(doMomentumScroll);
}
} else if (!this.mobileKeyboardVisible) {
this.openMobileKeyboardFromControl();
}
// If still "undecided", it was a tap — no action needed (focus handled elsewhere)
mode = "undecided";
}) as EventListener,
@@ -1825,18 +1834,12 @@ class WebTerminal {
const controls = document.createElement("div");
controls.className = "webterm-voice-controls";
controls.innerHTML = `
<button type="button" class="webterm-voice-button" aria-pressed="false" title="Start voice input">
Voice
</button>
<span class="webterm-voice-status">Ready</span>
`;
controls.innerHTML = `<span class="webterm-voice-status">Ready</span>`;
this.element.appendChild(controls);
this.voiceControls = controls;
this.voiceButton = controls.querySelector(".webterm-voice-button");
this.voiceStatus = controls.querySelector(".webterm-voice-status");
if (!this.voiceButton || !this.voiceStatus) {
if (!this.voiceStatus) {
return;
}
@@ -1847,8 +1850,8 @@ class WebTerminal {
zIndex: "4",
display: "flex",
alignItems: "center",
gap: "8px",
padding: "8px 10px",
gap: "0",
padding: "8px 12px",
borderRadius: "999px",
background: "rgba(9, 14, 19, 0.78)",
backdropFilter: "blur(8px)",
@@ -1856,16 +1859,6 @@ class WebTerminal {
pointerEvents: "auto",
} satisfies Partial<CSSStyleDeclaration>);
Object.assign(this.voiceButton.style, {
border: "1px solid rgba(255, 255, 255, 0.18)",
borderRadius: "999px",
background: "#12202b",
color: "#f3f6fa",
font: '600 12px "Fira Code", "FiraCode Nerd Font", monospace',
padding: "7px 12px",
cursor: "pointer",
} satisfies Partial<CSSStyleDeclaration>);
Object.assign(this.voiceStatus.style, {
color: "#c9d5e0",
font: '500 11px "Fira Code", "FiraCode Nerd Font", monospace',
@@ -1874,10 +1867,6 @@ class WebTerminal {
maxWidth: "44ch",
overflowWrap: "anywhere",
} satisfies Partial<CSSStyleDeclaration>);
this.voiceButton.addEventListener("click", () => {
void this.toggleVoiceInput();
});
if (
typeof navigator === "undefined" ||
!navigator.mediaDevices?.getUserMedia ||
@@ -2245,7 +2234,10 @@ class WebTerminal {
state: "idle" | "loading" | "listening" | "error" | "unsupported",
message: string
): void {
if (!this.voiceButton || !this.voiceStatus) {
this.voiceState = state;
this.syncVirtualKeyboardState();
if (!this.voiceStatus) {
return;
}
@@ -2258,31 +2250,16 @@ class WebTerminal {
this.voiceStatus.textContent = displayMessage;
this.voiceStatus.title = message;
this.voiceButton.disabled = state === "loading" || state === "unsupported";
this.voiceButton.textContent = state === "listening" ? "Stop" : "Voice";
this.voiceButton.setAttribute("aria-pressed", state === "listening" ? "true" : "false");
this.voiceButton.title =
state === "error"
? message
: state === "listening"
? "Stop voice input"
: "Start voice input";
if (state === "listening") {
this.voiceButton.style.background = "#7c1d1d";
this.voiceButton.style.borderColor = "rgba(255, 120, 120, 0.5)";
if (this.voiceControls) {
this.voiceControls.style.background = "rgba(45, 10, 10, 0.78)";
}
} else if (state === "error") {
this.voiceButton.style.background = "#3d2a12";
this.voiceButton.style.borderColor = "rgba(255, 187, 92, 0.55)";
if (this.voiceControls) {
this.voiceControls.style.background = "rgba(46, 29, 8, 0.82)";
}
} else {
this.voiceButton.style.background = "#12202b";
this.voiceButton.style.borderColor = "rgba(255, 255, 255, 0.18)";
if (this.voiceControls) {
this.voiceControls.style.background = "rgba(9, 14, 19, 0.78)";
}
@@ -2305,6 +2282,10 @@ class WebTerminal {
}
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";
@@ -2312,8 +2293,9 @@ class WebTerminal {
if (this.mobileKeybar) {
this.mobileKeybar.style.display = visible ? "" : "none";
}
this.updateMobileKeyboardDockLayout();
this.fit();
if (visible) {
this.updateVirtualKeyboardHostHeight();
this.requestMobileVirtualKeyboardDraw();
}
}
@@ -2322,6 +2304,11 @@ class WebTerminal {
this.setMobileKeyboardVisible(!this.mobileKeyboardVisible);
}
private openMobileKeyboardFromControl(): void {
this.allowMobileKeyboardOpen = true;
this.setMobileKeyboardVisible(true);
}
private getMobileModifierState(): {
useShift: boolean;
useCtrl: boolean;
@@ -2426,9 +2413,6 @@ class WebTerminal {
}
private currentVirtualKeyboardLayout(): VirtualKeyboardKey[][] {
if (this.mobileSelectionMode) {
return VIRTUAL_KEYBOARD_SELECTION_LAYOUT;
}
return this.mobileKeyboardMode === "symbol"
? VIRTUAL_KEYBOARD_SYMBOL_LAYOUT
: VIRTUAL_KEYBOARD_ALPHA_LAYOUT;
@@ -2450,11 +2434,34 @@ class WebTerminal {
this.mobileVirtualKeyboardHost.style.height = `${Math.round(this.virtualKeyboardBaseHeight / scale)}px`;
}
private updateMobileKeyboardDockLayout(): void {
if (this.mobileVirtualKeyboardHost) {
this.mobileVirtualKeyboardHost.style.bottom = "0";
}
if (this.mobileKeybar) {
const keyboardHeight = this.mobileVirtualKeyboardHost?.offsetHeight ?? 0;
this.mobileKeybar.style.bottom = `${keyboardHeight}px`;
}
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") {
return "Mic...";
}
return "Mic";
}
if (key.kind === "char") {
return this.shiftActive && key.shiftLabel ? key.shiftLabel : key.label;
}
@@ -2531,15 +2538,12 @@ class WebTerminal {
if (key.key.modifier === "fn") {
return this.fnActive;
}
if (key.key.actionId === "toggle-selection-adjust") {
return this.mobileSelectionAdjusting;
}
if (key.key.actionId === "mode-selection" || key.key.actionId === "mode-alpha") {
return this.mobileSelectionMode;
}
if (key.key.actionId === "mode-symbol") {
return this.mobileKeyboardMode === "symbol";
}
if (key.key.actionId === "toggle-voice") {
return this.voiceState === "listening" || this.voiceState === "loading";
}
return false;
}
@@ -2712,113 +2716,26 @@ class WebTerminal {
this.mobileVirtualKeyboardActivePresses.set(press.pointerId, press);
}
private async copyTerminalSelection(): Promise<void> {
const terminal = this.terminal as unknown as {
getSelection?: () => string;
hasSelection?: () => boolean;
clearSelection?: () => void;
};
const selectedText =
(terminal.hasSelection?.() ? terminal.getSelection?.() : "") ||
document.getSelection()?.toString() ||
"";
if (!selectedText) {
return;
}
await navigator.clipboard?.writeText(selectedText);
}
private async pasteFromClipboard(): Promise<void> {
const text = await navigator.clipboard?.readText();
if (text) {
this.sendStdin(text);
}
}
private async cutTerminalSelection(): Promise<void> {
const terminal = this.terminal as unknown as {
clearSelection?: () => void;
};
await this.copyTerminalSelection();
terminal.clearSelection?.();
}
private sendSelectionArrow(seq: "\x1b[D" | "\x1b[C"): void {
const { useCtrl, useAlt, useFn } = this.getMobileModifierState();
const useShift = this.shiftActive || this.pendingShift || this.mobileSelectionAdjusting;
let output: string = seq;
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 (useAlt) {
output = applyAltModifier(output);
}
if (useFn && output.length === 1) {
const fnApplied = applyFnModifier(output, useShift);
if (fnApplied) {
output = fnApplied;
}
}
this.sendStdin(output);
this.deactivateModifiers();
this.syncVirtualKeyboardState();
}
private async dispatchVirtualKeyboardKey(key: VirtualKeyboardKeyBounds): Promise<void> {
const actionId = key.key.actionId;
if (actionId === "mode-alpha") {
this.mobileSelectionMode = false;
this.mobileKeyboardMode = "alpha";
this.mobileSelectionAdjusting = false;
this.syncVirtualKeyboardState();
return;
}
if (actionId === "mode-selection") {
this.mobileSelectionMode = true;
this.mobileSelectionAdjusting = false;
this.syncVirtualKeyboardState();
return;
}
if (actionId === "mode-symbol") {
this.mobileSelectionMode = false;
this.mobileKeyboardMode = this.mobileKeyboardMode === "symbol" ? "alpha" : "symbol";
this.syncVirtualKeyboardState();
return;
}
if (actionId === "toggle-selection-adjust") {
this.mobileSelectionAdjusting = !this.mobileSelectionAdjusting;
this.syncVirtualKeyboardState();
return;
}
if (actionId === "copy-selection") {
await this.copyTerminalSelection().catch(() => undefined);
return;
}
if (actionId === "paste-selection") {
await this.pasteFromClipboard().catch(() => undefined);
return;
}
if (actionId === "cut-selection") {
await this.cutTerminalSelection().catch(() => undefined);
if (actionId === "toggle-voice") {
await this.toggleVoiceInput();
return;
}
if (key.key.modifier) {
this.toggleModifierState(key.key.modifier);
return;
}
if (key.value === "ArrowLeft") {
this.sendSelectionArrow("\x1b[D");
return;
}
if (key.value === "ArrowRight") {
this.sendSelectionArrow("\x1b[C");
return;
}
if (key.key.seq) {
this.sendMobileSequence(key.key.seq);
return;
@@ -2904,19 +2821,22 @@ class WebTerminal {
};
private setupVirtualKeyboard(): void {
if (this.mobileVirtualKeyboard || this.mobileVirtualKeyboardHost || !document.body) {
if (this.mobileVirtualKeyboard || this.mobileVirtualKeyboardHost) {
return;
}
const keyboardHost = document.createElement("div");
keyboardHost.className = "mobile-virtual-keyboard";
keyboardHost.style.cssText = `
flex-shrink: 0;
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");
@@ -2933,14 +2853,17 @@ class WebTerminal {
canvas.addEventListener("pointerleave", this.handleVirtualKeyboardPointerUp, { passive: false });
keyboardHost.appendChild(canvas);
document.body.appendChild(keyboardHost);
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);
@@ -2987,15 +2910,17 @@ class WebTerminal {
const style = document.createElement("style");
style.textContent = `
.mobile-keybar {
flex-shrink: 0;
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 {
flex-shrink: 0;
background: #0f172a;
border-top: 1px solid rgba(148, 163, 184, 0.18);
}
@@ -3053,14 +2978,18 @@ class WebTerminal {
`;
document.head.appendChild(style);
this.mobileKeybarStyle = style;
document.body.appendChild(keybar);
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) => {
@@ -3088,6 +3017,7 @@ class WebTerminal {
keybar.querySelectorAll("button").forEach((btn) => {
(btn as HTMLElement).style.height = `${this.keybarButtonHeight}px`;
});
this.updateMobileKeyboardDockLayout();
this.requestMobileVirtualKeyboardDraw();
this.fit();
};
@@ -3172,7 +3102,6 @@ class WebTerminal {
/** Focus the mobile input to show keyboard */
private focusMobileInput(): void {
if (this.usesVirtualKeyboard()) {
this.setMobileKeyboardVisible(true);
return;
}
// For programmatic focus (not from user gesture), this may not show keyboard on iOS
@@ -3643,7 +3572,6 @@ class WebTerminal {
this.voiceControls.remove();
this.voiceControls = null;
}
this.voiceButton = null;
this.voiceStatus = null;
this.destroyVoiceEngine();
this.fitAddon.dispose();