feat: add terminal soft keys

This commit is contained in:
Federico Jaramillo Martinez
2026-05-28 08:17:53 +02:00
parent 50f1ddc9c5
commit f569467769
7 changed files with 471 additions and 2 deletions
+5
View File
@@ -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.
+97 -2
View File
@@ -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<this>): 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`
<button
type="button"
class=${this.softKeysEnabled ? "soft-keys-toggle selected" : "soft-keys-toggle"}
title=${this.softKeysEnabled ? "Hide terminal soft keys" : "Show terminal soft keys"}
aria-label=${this.softKeysEnabled ? "Hide terminal soft keys" : "Show terminal soft keys"}
aria-pressed=${String(this.softKeysEnabled)}
@click=${() => { this.toggleSoftKeys(); }}
>
<svg class="keyboard-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<rect x="3" y="5" width="18" height="14" rx="2"></rect>
<path d="M7 9h.01M10 9h.01M13 9h.01M16 9h.01M7 12h.01M10 12h.01M13 12h.01M16 12h.01M8 16h8"></path>
</svg>
<span>Keys</span>
</button>
`;
}
private renderSoftKeys() {
return html`
<terminal-soft-keys
.modes=${this.terminal?.modes}
.refocusOnClick=${!this.defaultSoftKeysEnvironment}
.onInput=${(data: string, options: TerminalSoftKeyInputOptions) => { this.sendSoftKeyInput(data, options); }}
></terminal-soft-keys>
`;
}
override render() {
return html`
<section class="terminal-shell">
<div class="terminal-tabs">
${this.renderSoftKeysToggle()}
${this.terminals.map((terminal) => html`
<button class=${this.selectedId === terminal.id ? "selected" : ""} @click=${() => { this.selectTerminal(terminal.id); }}>
<span>${terminal.name}${terminal.exited ? " · exited" : ""}</span>
@@ -436,6 +528,7 @@ export class TerminalPanel extends LitElement {
</div>
${this.error === undefined ? null : html`<p class="error">${this.error}</p>`}
${this.renderCommandRunNotice()}
${this.shouldShowSoftKeys() ? this.renderSoftKeys() : null}
${this.loading ? html`<p class="muted">Loading terminals…</p>` : null}
<div class="terminal-host"></div>
</section>
@@ -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); }
@@ -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`
<div class="terminal-soft-keys" role="toolbar" aria-label="Terminal soft keys">
${TERMINAL_SOFT_KEYS.map((key) => html`
<button
type="button"
class="soft-key"
title=${key.title}
aria-label=${key.ariaLabel}
@pointerdown=${(event: PointerEvent) => { this.onSoftKeyPointerDown(event, key); }}
@pointermove=${(event: PointerEvent) => { this.onSoftKeyPointerMove(event); }}
@pointerup=${(event: PointerEvent) => { this.onSoftKeyPointerUp(event, key); }}
@pointercancel=${(event: PointerEvent) => { this.onSoftKeyPointerCancel(event); }}
@click=${(event: MouseEvent) => { this.onSoftKeyClick(event, key); }}
>${key.label}</button>
`)}
</div>
`;
}
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;
}
+43
View File
@@ -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");
});
});
+98
View File
@@ -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}`;
}
@@ -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<string, string>();
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");
}
}
@@ -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<Storage, "getItem" | "setItem">;
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;
}
}