From f569467769d639ecac922a42b4ac3731decaea4a Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 28 May 2026 08:17:53 +0200 Subject: [PATCH] feat: add terminal soft keys --- .changeset/soft-terminal-keys.md | 5 + src/client/src/components/TerminalPanel.ts | 99 ++++++++++++++++- src/client/src/components/TerminalSoftKeys.ts | 101 ++++++++++++++++++ src/client/src/terminalKeys.test.ts | 43 ++++++++ src/client/src/terminalKeys.ts | 98 +++++++++++++++++ .../src/terminalSoftKeysPreference.test.ts | 70 ++++++++++++ src/client/src/terminalSoftKeysPreference.ts | 57 ++++++++++ 7 files changed, 471 insertions(+), 2 deletions(-) create mode 100644 .changeset/soft-terminal-keys.md create mode 100644 src/client/src/components/TerminalSoftKeys.ts create mode 100644 src/client/src/terminalKeys.test.ts create mode 100644 src/client/src/terminalKeys.ts create mode 100644 src/client/src/terminalSoftKeysPreference.test.ts create mode 100644 src/client/src/terminalSoftKeysPreference.ts diff --git a/.changeset/soft-terminal-keys.md b/.changeset/soft-terminal-keys.md new file mode 100644 index 0000000..37612d5 --- /dev/null +++ b/.changeset/soft-terminal-keys.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add an optional terminal soft-key bar for common control, navigation, and Meta-style key sequences, with mobile-friendly defaults and a persistent toggle. diff --git a/src/client/src/components/TerminalPanel.ts b/src/client/src/components/TerminalPanel.ts index 941a093..f8ce43a 100644 --- a/src/client/src/components/TerminalPanel.ts +++ b/src/client/src/components/TerminalPanel.ts @@ -5,6 +5,9 @@ import { FitAddon, type ITerminalDimensions } from "@xterm/addon-fit"; import "@xterm/xterm/css/xterm.css"; import { terminalSocket, terminalsApi, type TerminalCommandRun, type TerminalInfo, type Workspace } from "../api"; import { selectFallbackTerminal, selectPreferredTerminal } from "../controllers/terminalSelection"; +import { createTerminalSoftKeysDefaultEnvironmentMedia, hasTerminalSoftKeysPreference, initialTerminalSoftKeysEnabled, isTerminalSoftKeysDefaultEnvironment, writeTerminalSoftKeysPreference } from "../terminalSoftKeysPreference"; +import "./TerminalSoftKeys"; +import type { TerminalSoftKeyInputOptions } from "./TerminalSoftKeys"; const TERMINAL_OPTIONS_BASE: ITerminalOptions = { cursorBlink: true, @@ -31,6 +34,8 @@ export class TerminalPanel extends LitElement { @state() private visible = false; @state() private cancellingRunIds: string[] = []; @state() private continuingTerminalIds: string[] = []; + @state() private defaultSoftKeysEnvironment = false; + @state() private softKeysEnabled = initialTerminalSoftKeysEnabled(); private terminal: Terminal | undefined; private fitAddon: FitAddon | undefined; @@ -43,9 +48,16 @@ export class TerminalPanel extends LitElement { private loadedCwd: string | undefined; private autoStartConsumedCwd: string | undefined; private commandRunPollTimer: number | undefined; + private readonly softKeysDefaultEnvironmentMedia = createTerminalSoftKeysDefaultEnvironmentMedia(); + private softKeysPreferenceStored = hasTerminalSoftKeysPreference(); + private readonly onSoftKeysDefaultEnvironmentChange = () => { + this.syncDefaultSoftKeysEnvironment(); + }; override connectedCallback(): void { super.connectedCallback(); + this.syncDefaultSoftKeysEnvironment(); + this.softKeysDefaultEnvironmentMedia?.addEventListener("change", this.onSoftKeysDefaultEnvironmentChange); this.themeObserver = new MutationObserver(() => { this.applyTerminalTheme(); }); this.themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style", "data-theme"] }); } @@ -62,11 +74,24 @@ export class TerminalPanel extends LitElement { this.intersectionObserver = undefined; this.themeObserver?.disconnect(); this.themeObserver = undefined; + this.softKeysDefaultEnvironmentMedia?.removeEventListener("change", this.onSoftKeysDefaultEnvironmentChange); this.updateCommandRunPolling(false); this.disposeTerminalView(); super.disconnectedCallback(); } + private syncDefaultSoftKeysEnvironment(): void { + const nextDefaultEnvironment = isTerminalSoftKeysDefaultEnvironment(this.softKeysDefaultEnvironmentMedia); + const previousSoftKeysEnabled = this.softKeysEnabled; + this.defaultSoftKeysEnvironment = nextDefaultEnvironment; + if (!this.softKeysPreferenceStored) this.softKeysEnabled = nextDefaultEnvironment; + if (this.softKeysEnabled !== previousSoftKeysEnabled) this.scheduleFitAndNotify(); + } + + private scheduleFitAndNotify(): void { + void this.updateComplete.then(() => { this.fitAndNotify(); }); + } + override willUpdate(changed: PropertyValues): void { const cwd = this.workspace?.path; if (cwd !== this.observedCwd) { @@ -286,8 +311,7 @@ export class TerminalPanel extends LitElement { this.resizeObserver.observe(terminalHost); terminal.onData((data) => { if (this.suppressTerminalInput) return; - const filtered = filterTerminalInput(data); - if (filtered !== "") this.send({ type: "input", data: filtered }); + this.sendTerminalInput(data); }); const initialSize = this.fitTerminal(); this.connectSocket(workspace.projectId, workspace.id, this.selectedId, terminal, initialSize); @@ -375,6 +399,23 @@ export class TerminalPanel extends LitElement { if (this.terminal !== undefined) this.terminal.options.theme = terminalTheme(this); } + private sendTerminalInput(data: string): void { + const filtered = filterTerminalInput(data); + if (filtered !== "") this.send({ type: "input", data: filtered }); + } + + private sendSoftKeyInput(data: string, options: TerminalSoftKeyInputOptions): void { + this.sendTerminalInput(data); + if (options.refocus) this.focusTerminal(); + } + + private focusTerminal(): void { + const terminal = this.terminal; + if (terminal === undefined) return; + terminal.focus(); + requestAnimationFrame(() => { terminal.focus(); }); + } + private send(message: { type: "input"; data: string } | { type: "resize"; cols: number; rows: number }): void { if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(JSON.stringify(message)); } @@ -422,10 +463,61 @@ export class TerminalPanel extends LitElement { return null; } + private selectedTerminalAcceptsInput(): boolean { + const terminal = this.selectedTerminalInfo(); + return terminal !== undefined && !terminal.exited; + } + + private shouldShowSoftKeys(): boolean { + return this.selectedTerminalAcceptsInput() && this.softKeysEnabled; + } + + private shouldShowSoftKeysToggle(): boolean { + return this.selectedTerminalAcceptsInput(); + } + + private toggleSoftKeys(): void { + this.softKeysEnabled = !this.softKeysEnabled; + this.softKeysPreferenceStored = true; + writeTerminalSoftKeysPreference(this.softKeysEnabled); + this.scheduleFitAndNotify(); + } + + private renderSoftKeysToggle() { + if (!this.shouldShowSoftKeysToggle()) return null; + return html` + + `; + } + + private renderSoftKeys() { + return html` + { this.sendSoftKeyInput(data, options); }} + > + `; + } + override render() { return html`
+ ${this.renderSoftKeysToggle()} ${this.terminals.map((terminal) => html`
${this.error === undefined ? null : html`

${this.error}

`} ${this.renderCommandRunNotice()} + ${this.shouldShowSoftKeys() ? this.renderSoftKeys() : null} ${this.loading ? html`

Loading terminals…

` : null}
@@ -449,6 +542,8 @@ export class TerminalPanel extends LitElement { button { display: inline-flex; align-items: center; gap: 6px; min-width: 0; max-width: 180px; border: 1px solid var(--pi-border); border-radius: 7px; background: var(--pi-surface); color: var(--pi-text); padding: 5px 7px; cursor: pointer; } button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); } button.new { flex: 0 0 auto; color: var(--pi-muted); } + .soft-keys-toggle { flex: 0 0 auto; } + .soft-keys-toggle .keyboard-icon { flex: 0 0 auto; width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; } button span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } button small { color: var(--pi-muted); font-size: 14px; line-height: 1; } button small:hover { color: var(--pi-danger); } diff --git a/src/client/src/components/TerminalSoftKeys.ts b/src/client/src/components/TerminalSoftKeys.ts new file mode 100644 index 0000000..1c9b0ad --- /dev/null +++ b/src/client/src/components/TerminalSoftKeys.ts @@ -0,0 +1,101 @@ +import { css, html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { TERMINAL_SOFT_KEYS, terminalSoftKeySequence, type TerminalModesSnapshot, type TerminalSoftKeyDefinition } from "../terminalKeys"; + +const SOFT_KEY_TAP_MOVE_THRESHOLD_PX = 8; +const SYNTHETIC_CLICK_SUPPRESSION_MS = 500; + +export interface TerminalSoftKeyInputOptions { + refocus: boolean; +} + +@customElement("terminal-soft-keys") +export class TerminalSoftKeys extends LitElement { + @property({ attribute: false }) modes: TerminalModesSnapshot | undefined; + @property({ type: Boolean }) refocusOnClick = true; + @property({ attribute: false }) onInput: (data: string, options: TerminalSoftKeyInputOptions) => void = () => undefined; + + private pointerStart: SoftKeyPointerStart | undefined; + private lastPointerFinishedAt = 0; + + private sendSoftKey(key: TerminalSoftKeyDefinition, options: TerminalSoftKeyInputOptions): void { + this.onInput(terminalSoftKeySequence(key.id, this.modes), options); + } + + private onSoftKeyPointerDown(event: PointerEvent, key: TerminalSoftKeyDefinition): void { + if (event.pointerType === "mouse" && event.button !== 0) return; + event.preventDefault(); + this.pointerStart = { pointerId: event.pointerId, key, clientX: event.clientX, clientY: event.clientY }; + } + + private onSoftKeyPointerMove(event: PointerEvent): void { + const start = this.pointerStart; + if (start?.pointerId !== event.pointerId) return; + if (pointerMovedBeyondTap(start, event)) this.finishSoftKeyPointer(); + } + + private onSoftKeyPointerUp(event: PointerEvent, key: TerminalSoftKeyDefinition): void { + const start = this.pointerStart; + if (start?.pointerId !== event.pointerId) return; + event.preventDefault(); + this.finishSoftKeyPointer(); + if (start.key.id !== key.id || pointerMovedBeyondTap(start, event)) return; + this.sendSoftKey(key, { refocus: event.pointerType === "mouse" }); + } + + private onSoftKeyPointerCancel(event: PointerEvent): void { + if (this.pointerStart?.pointerId === event.pointerId) this.finishSoftKeyPointer(); + } + + private finishSoftKeyPointer(): void { + this.pointerStart = undefined; + this.lastPointerFinishedAt = Date.now(); + } + + private onSoftKeyClick(event: MouseEvent, key: TerminalSoftKeyDefinition): void { + if (Date.now() - this.lastPointerFinishedAt < SYNTHETIC_CLICK_SUPPRESSION_MS) { + event.preventDefault(); + return; + } + this.sendSoftKey(key, { refocus: this.refocusOnClick }); + } + + override render() { + return html` + + `; + } + + static override styles = css` + :host { flex: 0 0 auto; display: block; } + .terminal-soft-keys { display: flex; gap: 6px; align-items: center; padding: 6px; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-bg); overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none; touch-action: pan-x; } + .terminal-soft-keys::-webkit-scrollbar { display: none; } + button { display: inline-flex; align-items: center; gap: 6px; flex: 0 0 auto; max-width: none; min-height: 34px; border: 1px solid var(--pi-border); border-radius: 7px; background: var(--pi-surface); color: var(--pi-text); padding: 6px 9px; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; cursor: pointer; touch-action: pan-x; -webkit-touch-callout: none; user-select: none; } + button:disabled { opacity: .5; cursor: not-allowed; } + `; +} + +interface SoftKeyPointerStart { + pointerId: number; + key: TerminalSoftKeyDefinition; + clientX: number; + clientY: number; +} + +function pointerMovedBeyondTap(start: SoftKeyPointerStart, event: PointerEvent): boolean { + return Math.hypot(event.clientX - start.clientX, event.clientY - start.clientY) > SOFT_KEY_TAP_MOVE_THRESHOLD_PX; +} diff --git a/src/client/src/terminalKeys.test.ts b/src/client/src/terminalKeys.test.ts new file mode 100644 index 0000000..93eee20 --- /dev/null +++ b/src/client/src/terminalKeys.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { terminalSoftKeySequence, type TerminalModesSnapshot } from "./terminalKeys"; + +describe("terminalSoftKeySequence", () => { + it("maps common control keys to terminal bytes", () => { + expect(terminalSoftKeySequence("escape")).toBe("\x1b"); + expect(terminalSoftKeySequence("tab")).toBe("\t"); + expect(terminalSoftKeySequence("ctrl-c")).toBe("\x03"); + expect(terminalSoftKeySequence("ctrl-d")).toBe("\x04"); + expect(terminalSoftKeySequence("ctrl-z")).toBe("\x1a"); + expect(terminalSoftKeySequence("ctrl-l")).toBe("\x0c"); + expect(terminalSoftKeySequence("ctrl-r")).toBe("\x12"); + }); + + it("maps navigation keys to xterm-compatible sequences", () => { + expect(terminalSoftKeySequence("arrow-up")).toBe("\x1b[A"); + expect(terminalSoftKeySequence("arrow-down")).toBe("\x1b[B"); + expect(terminalSoftKeySequence("arrow-right")).toBe("\x1b[C"); + expect(terminalSoftKeySequence("arrow-left")).toBe("\x1b[D"); + expect(terminalSoftKeySequence("home")).toBe("\x1b[H"); + expect(terminalSoftKeySequence("end")).toBe("\x1b[F"); + expect(terminalSoftKeySequence("page-up")).toBe("\x1b[5~"); + expect(terminalSoftKeySequence("page-down")).toBe("\x1b[6~"); + expect(terminalSoftKeySequence("delete")).toBe("\x1b[3~"); + expect(terminalSoftKeySequence("backspace")).toBe("\x7f"); + }); + + it("respects application cursor key mode", () => { + const applicationCursorMode: TerminalModesSnapshot = { applicationCursorKeysMode: true }; + + expect(terminalSoftKeySequence("arrow-up", applicationCursorMode)).toBe("\x1bOA"); + expect(terminalSoftKeySequence("arrow-down", applicationCursorMode)).toBe("\x1bOB"); + expect(terminalSoftKeySequence("arrow-right", applicationCursorMode)).toBe("\x1bOC"); + expect(terminalSoftKeySequence("arrow-left", applicationCursorMode)).toBe("\x1bOD"); + expect(terminalSoftKeySequence("home", applicationCursorMode)).toBe("\x1bOH"); + expect(terminalSoftKeySequence("end", applicationCursorMode)).toBe("\x1bOF"); + }); + + it("maps meta word movement to escape-prefixed sequences", () => { + expect(terminalSoftKeySequence("meta-backward-word")).toBe("\x1bb"); + expect(terminalSoftKeySequence("meta-forward-word")).toBe("\x1bf"); + }); +}); diff --git a/src/client/src/terminalKeys.ts b/src/client/src/terminalKeys.ts new file mode 100644 index 0000000..2bdbf6e --- /dev/null +++ b/src/client/src/terminalKeys.ts @@ -0,0 +1,98 @@ +export type TerminalSoftKeyId = + | "escape" + | "tab" + | "ctrl-c" + | "ctrl-d" + | "ctrl-z" + | "ctrl-l" + | "ctrl-r" + | "ctrl-u" + | "ctrl-w" + | "arrow-left" + | "arrow-up" + | "arrow-down" + | "arrow-right" + | "home" + | "end" + | "page-up" + | "page-down" + | "delete" + | "backspace" + | "meta-backward-word" + | "meta-forward-word"; + +export interface TerminalModesSnapshot { + applicationCursorKeysMode: boolean; +} + +export interface TerminalSoftKeyDefinition { + id: TerminalSoftKeyId; + label: string; + ariaLabel: string; + title: string; +} + +export const TERMINAL_SOFT_KEYS: readonly TerminalSoftKeyDefinition[] = [ + { id: "escape", label: "Esc", ariaLabel: "Escape", title: "Send Escape" }, + { id: "tab", label: "Tab", ariaLabel: "Tab", title: "Send Tab" }, + { id: "ctrl-c", label: "Ctrl+C", ariaLabel: "Control C", title: "Interrupt the foreground process" }, + { id: "ctrl-d", label: "Ctrl+D", ariaLabel: "Control D", title: "Send EOF / close input" }, + { id: "ctrl-z", label: "Ctrl+Z", ariaLabel: "Control Z", title: "Suspend the foreground process" }, + { id: "ctrl-l", label: "Ctrl+L", ariaLabel: "Control L", title: "Clear / redraw the terminal" }, + { id: "ctrl-r", label: "Ctrl+R", ariaLabel: "Control R", title: "Reverse search history" }, + { id: "ctrl-u", label: "Ctrl+U", ariaLabel: "Control U", title: "Delete to the start of the line" }, + { id: "ctrl-w", label: "Ctrl+W", ariaLabel: "Control W", title: "Delete the previous word" }, + { id: "arrow-left", label: "←", ariaLabel: "Left arrow", title: "Move left" }, + { id: "arrow-up", label: "↑", ariaLabel: "Up arrow", title: "Move up / previous command" }, + { id: "arrow-down", label: "↓", ariaLabel: "Down arrow", title: "Move down / next command" }, + { id: "arrow-right", label: "→", ariaLabel: "Right arrow", title: "Move right" }, + { id: "home", label: "Home", ariaLabel: "Home", title: "Move to the start" }, + { id: "end", label: "End", ariaLabel: "End", title: "Move to the end" }, + { id: "page-up", label: "PgUp", ariaLabel: "Page up", title: "Page up" }, + { id: "page-down", label: "PgDn", ariaLabel: "Page down", title: "Page down" }, + { id: "delete", label: "Del", ariaLabel: "Delete", title: "Delete forward" }, + { id: "backspace", label: "⌫", ariaLabel: "Backspace", title: "Backspace" }, + { id: "meta-backward-word", label: "M-B", ariaLabel: "Meta B", title: "Move backward one word" }, + { id: "meta-forward-word", label: "M-F", ariaLabel: "Meta F", title: "Move forward one word" }, +]; + +const ESC = "\x1b"; +const DEL = "\x7f"; + +export function terminalSoftKeySequence(key: TerminalSoftKeyId, modes?: TerminalModesSnapshot): string { + switch (key) { + case "escape": return ESC; + case "tab": return "\t"; + case "ctrl-c": return controlSequence("c"); + case "ctrl-d": return controlSequence("d"); + case "ctrl-z": return controlSequence("z"); + case "ctrl-l": return controlSequence("l"); + case "ctrl-r": return controlSequence("r"); + case "ctrl-u": return controlSequence("u"); + case "ctrl-w": return controlSequence("w"); + case "arrow-left": return cursorSequence("D", modes); + case "arrow-up": return cursorSequence("A", modes); + case "arrow-down": return cursorSequence("B", modes); + case "arrow-right": return cursorSequence("C", modes); + case "home": return cursorEndpointSequence("H", modes); + case "end": return cursorEndpointSequence("F", modes); + case "page-up": return `${ESC}[5~`; + case "page-down": return `${ESC}[6~`; + case "delete": return `${ESC}[3~`; + case "backspace": return DEL; + case "meta-backward-word": return `${ESC}b`; + case "meta-forward-word": return `${ESC}f`; + } +} + +function controlSequence(letter: string): string { + return String.fromCharCode(letter.toUpperCase().charCodeAt(0) - 64); +} + +function cursorSequence(code: "A" | "B" | "C" | "D", modes: TerminalModesSnapshot | undefined): string { + return modes?.applicationCursorKeysMode === true ? `${ESC}O${code}` : `${ESC}[${code}`; +} + +function cursorEndpointSequence(code: "F" | "H", modes: TerminalModesSnapshot | undefined): string { + return modes?.applicationCursorKeysMode === true ? `${ESC}O${code}` : `${ESC}[${code}`; +} diff --git a/src/client/src/terminalSoftKeysPreference.test.ts b/src/client/src/terminalSoftKeysPreference.test.ts new file mode 100644 index 0000000..0890119 --- /dev/null +++ b/src/client/src/terminalSoftKeysPreference.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { + parseTerminalSoftKeysPreference, + readTerminalSoftKeysPreference, + terminalSoftKeysEnabled, + TERMINAL_SOFT_KEYS_STORAGE_KEY, + writeTerminalSoftKeysPreference, +} from "./terminalSoftKeysPreference"; + +describe("terminal soft key preferences", () => { + it("uses stored preferences before environment defaults", () => { + expect(terminalSoftKeysEnabled(true, false)).toBe(true); + expect(terminalSoftKeysEnabled(false, true)).toBe(false); + expect(terminalSoftKeysEnabled(undefined, true)).toBe(true); + expect(terminalSoftKeysEnabled(undefined, false)).toBe(false); + }); + + it("parses boolean local storage values", () => { + expect(parseTerminalSoftKeysPreference("true")).toBe(true); + expect(parseTerminalSoftKeysPreference("false")).toBe(false); + expect(parseTerminalSoftKeysPreference(null)).toBeUndefined(); + expect(parseTerminalSoftKeysPreference("yes")).toBeUndefined(); + }); + + it("reads and writes the stored preference", () => { + const storage = new FakeStorage(); + + expect(readTerminalSoftKeysPreference(storage)).toBeUndefined(); + writeTerminalSoftKeysPreference(true, storage); + expect(storage.value(TERMINAL_SOFT_KEYS_STORAGE_KEY)).toBe("true"); + expect(readTerminalSoftKeysPreference(storage)).toBe(true); + + writeTerminalSoftKeysPreference(false, storage); + expect(storage.value(TERMINAL_SOFT_KEYS_STORAGE_KEY)).toBe("false"); + expect(readTerminalSoftKeysPreference(storage)).toBe(false); + }); + + it("ignores storage failures", () => { + const storage = new ThrowingStorage(); + + expect(readTerminalSoftKeysPreference(storage)).toBeUndefined(); + expect(() => { writeTerminalSoftKeysPreference(true, storage); }).not.toThrow(); + }); +}); + +class FakeStorage { + private readonly values = new Map(); + + getItem(key: string): string | null { + return this.values.get(key) ?? null; + } + + setItem(key: string, value: string): void { + this.values.set(key, value); + } + + value(key: string): string | undefined { + return this.values.get(key); + } +} + +class ThrowingStorage { + getItem(): string | null { + throw new Error("blocked"); + } + + setItem(): void { + throw new Error("blocked"); + } +} diff --git a/src/client/src/terminalSoftKeysPreference.ts b/src/client/src/terminalSoftKeysPreference.ts new file mode 100644 index 0000000..6474b38 --- /dev/null +++ b/src/client/src/terminalSoftKeysPreference.ts @@ -0,0 +1,57 @@ +export const TERMINAL_SOFT_KEYS_STORAGE_KEY = "pi-web.terminal.softKeys"; +export const TERMINAL_SOFT_KEYS_DEFAULT_ENVIRONMENT_MEDIA = "(pointer: coarse), (max-width: 760px)"; + +export type TerminalSoftKeysStorage = Pick; + +export function terminalSoftKeysEnabled(preference: boolean | undefined, defaultEnabled: boolean): boolean { + return preference ?? defaultEnabled; +} + +export function parseTerminalSoftKeysPreference(value: string | null): boolean | undefined { + if (value === "true") return true; + if (value === "false") return false; + return undefined; +} + +export function createTerminalSoftKeysDefaultEnvironmentMedia(): MediaQueryList | undefined { + return typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia(TERMINAL_SOFT_KEYS_DEFAULT_ENVIRONMENT_MEDIA) : undefined; +} + +export function isTerminalSoftKeysDefaultEnvironment(media: MediaQueryList | undefined): boolean { + return media?.matches === true; +} + +export function initialTerminalSoftKeysEnabled(media = createTerminalSoftKeysDefaultEnvironmentMedia()): boolean { + return terminalSoftKeysEnabled(readTerminalSoftKeysPreference(), isTerminalSoftKeysDefaultEnvironment(media)); +} + +export function hasTerminalSoftKeysPreference(): boolean { + return readTerminalSoftKeysPreference() !== undefined; +} + +export function readTerminalSoftKeysPreference(storage = browserStorage()): boolean | undefined { + if (storage === undefined) return undefined; + try { + return parseTerminalSoftKeysPreference(storage.getItem(TERMINAL_SOFT_KEYS_STORAGE_KEY)); + } catch { + return undefined; + } +} + +export function writeTerminalSoftKeysPreference(enabled: boolean, storage = browserStorage()): void { + if (storage === undefined) return; + try { + storage.setItem(TERMINAL_SOFT_KEYS_STORAGE_KEY, String(enabled)); + } catch { + // Ignore storage failures; the per-page toggle still works for this session. + } +} + +function browserStorage(): TerminalSoftKeysStorage | undefined { + if (typeof window === "undefined") return undefined; + try { + return window.localStorage; + } catch { + return undefined; + } +}