fix: restore mobile virtual keyboard flow
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 Ghostty’s 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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user