diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d3ba9b5bc..a72099eba4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,12 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa back automatically. Available on supporting bridges, for interact connections only. ([#368](https://github.com/devswha/herdr-web-ui/pull/368) by @WOULDU-pres) +- The terminal's key bar on a touch screen has an **Alt** key beside Ctrl. Like Ctrl it holds for + one key: Alt then a letter, Backspace or Enter sends ESC before it, and Alt then an arrow sends + Alt+arrow. **Settings → Appearance → Key bar** turns Alt off or adds Shift+Tab, Home/End, + PgUp/PgDn, Ctrl+D, Ctrl+Z, `|`, `~` and `/`, each in a fixed place in the row. Esc, Tab, Ctrl, + the arrows and Ctrl+C stay as they were. ([#487](https://github.com/devswha/herdr-web-ui/pull/487)) + ### Fixed - On macOS, a gjc pane's chat finds its session from the terminal breadcrumb gjc leaves. Before, the process start time it is matched by was read as UTC, so on any other time zone every fresh diff --git a/docs/guide.md b/docs/guide.md index c3ed7ab5b8..57635cbd89 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -222,7 +222,9 @@ whether the test was sent or failed; a missing subscription offers **Turn alerts On a phone: - Agent panes open in the chat. -- The terminal gets a key bar above the keyboard (Esc, Tab, Ctrl, arrows, Ctrl+C). +- The terminal gets a key bar above the keyboard (Esc, Tab, Ctrl, Alt, arrows, Ctrl+C). + **Settings → Appearance → Key bar** adds or removes Alt, Shift+Tab, Home/End, PgUp/PgDn, + Ctrl+D, Ctrl+Z, `|`, `~` and `/`. - Dragging the terminal scrolls the real herdr pane. - **Settings → Phone → Keep screen on** keeps the screen awake while a terminal or chat pane is open. It is off by default, releases when the app is hidden, and resumes when diff --git a/docs/terminal-input.md b/docs/terminal-input.md index 24dfd3bb01..3d3c853599 100644 --- a/docs/terminal-input.md +++ b/docs/terminal-input.md @@ -27,6 +27,14 @@ Control sequences are counted as discarded, never saved for later execution. No on reconnect. The input-line and chat Send buttons preserve an active composition, and the key bar waits for composition to finish. Leaving the input clears its composition guard. +The key bar always has Esc, Tab, Ctrl, the arrows and ^C. Settings → Appearance → Key bar adds +Alt (on by default), Shift+Tab, Home/End, PgUp/PgDn, ^D, ^Z, `|`, `~` and `/`, each in a fixed +place in the row. Ctrl and Alt are one-shot: an armed Alt puts ESC before the next character +(Alt+Backspace, Alt+Enter) and adds the Alt modifier to an arrow, Home, End or Page key +(`CSI 1;3D`); armed together they send ESC and the control code. A paste or a report the +terminal answers with passes through and leaves Alt armed. Neither applies to the input line +or the chat composer, which send their text as typed. + On macOS, Cmd+Left and Cmd+Right in direct typing send Ctrl+A and Ctrl+E, moving to the beginning and end of the input line in shells and agents that use those bindings. Pending IME text is sent first. Additional modifiers retain xterm's behavior; the input line diff --git a/src/components/KeyBar.tsx b/src/components/KeyBar.tsx index 57afbc2cd3..5c2221e58a 100644 --- a/src/components/KeyBar.tsx +++ b/src/components/KeyBar.tsx @@ -3,7 +3,7 @@ import { Keyboard } from "lucide-react"; import "./KeyBar.css"; -import type { KeyBarKey } from "../lib/keys.ts"; +import type { KeyBarExtra, KeyBarKey } from "../lib/keys.ts"; import { useT } from "../lib/i18n.ts"; export type { KeyBarKey }; @@ -14,6 +14,10 @@ export interface KeyBarProps { onKey: (key: KeyBarKey) => void; ctrlArmed: boolean; onToggleCtrl: () => void; + altArmed: boolean; + onToggleAlt: () => void; + /** the optional keys chosen in Settings, drawn in their fixed places */ + extras: readonly KeyBarExtra[]; /** on a touch screen: whether the keyboard types straight into the terminal (else the input line) */ directTyping?: boolean; onToggleDirect?: () => void; @@ -79,13 +83,27 @@ export const ARROWS: ReadonlyArray<{ key: KeyBarKey; label: string; direction: D { key: "ArrowRight", label: "Right", direction: "right" }, ]; +/** Settings' name for each optional key: its cap, and a spoken name where the cap does not read as one. */ +export const EXTRA_KEY_CAPS: Record = { + alt: { cap: "Alt" }, + "shift-tab": { cap: "⇧Tab", label: "Shift Tab" }, + "home-end": { cap: "Home End" }, + "page-up-down": { cap: "PgUp PgDn", label: "Page up and Page down" }, + "ctrl-d": { cap: "^D", label: "Control D" }, + "ctrl-z": { cap: "^Z", label: "Control Z" }, + pipe: { cap: "|" }, + tilde: { cap: "~" }, + slash: { cap: "/" }, +}; + /** * Touch key bar under the terminal. Keys are tabIndex -1 on purpose: they exist * for touch, a hardware keyboard already has all of them. Hence role="group", not * toolbar: a toolbar promises arrow-key navigation between items, which these skip. */ -export function KeyBar({ onKey, ctrlArmed, onToggleCtrl, directTyping, onToggleDirect, disabled }: KeyBarProps) { +export function KeyBar({ onKey, ctrlArmed, onToggleCtrl, altArmed, onToggleAlt, extras, directTyping, onToggleDirect, disabled }: KeyBarProps) { const t = useT(); + const has = (extra: KeyBarExtra): boolean => extras.includes(extra); return (
{/* first: on a narrow cover screen the row scrolls, and the mode toggle must not be the key cut off */} @@ -100,17 +118,44 @@ export function KeyBar({ onKey, ctrlArmed, onToggleCtrl, directTyping, onToggleD onKey("Tab")}> Tab + {has("shift-tab") && ( + onKey("BackTab")}> + ⇧Tab + + )} Ctrl + {has("alt") && ( + + Alt + + )} {ARROWS.map((arrow) => ( onKey(arrow.key)}> ))} + {has("home-end") && ( + <> + onKey("Home")}>Home + onKey("End")}>End + + )} + {has("page-up-down") && ( + <> + onKey("PageUp")}>PgUp + onKey("PageDown")}>PgDn + + )} onKey("ctrl-c")}> ^C + {has("ctrl-d") && onKey("ctrl-d")}>^D} + {has("ctrl-z") && onKey("ctrl-z")}>^Z} + {has("pipe") && onKey("pipe")}>|} + {has("tilde") && onKey("tilde")}>~} + {has("slash") && onKey("slash")}>/}
); } diff --git a/src/components/PaneTerminal.tsx b/src/components/PaneTerminal.tsx index d673ef855e..f90d3b4ab3 100644 --- a/src/components/PaneTerminal.tsx +++ b/src/components/PaneTerminal.tsx @@ -7,7 +7,7 @@ import "@xterm/xterm/css/xterm.css"; import "./PaneTerminal.css"; import { HerdrSocket } from "../lib/ws.ts"; -import { controlCode, ctrlEnterSequence, isPrintable, keySequence, modifyOtherKeysLevel, type KeyBarKey } from "../lib/keys.ts"; +import { altSequence, controlCode, ctrlEnterSequence, isPrintable, keySequence, modifyOtherKeysLevel, type KeyBarKey } from "../lib/keys.ts"; import { EMPTY_DRAFT, applyToDraft, draftIsEmpty, type InputDraft } from "../lib/draft.ts"; import { messageQueues } from "../lib/messageQueue.ts"; import { heldCountShown, heldOpenAtFold, heldOpenOnFocus, heldRefocusDue, heldRowError, heldRowsFold, heldRowsHidden, heldToggleShown, SHORT_PHONE_QUERY } from "../lib/heldRows.ts"; @@ -163,6 +163,8 @@ export function PaneTerminal({ const setComposing = useCallback((active: boolean) => { composingRef.current = active; setComposingState(active); }, []); const ctrlRef = useRef(false); const [ctrlArmed, setCtrlArmed] = useState(false); + const altRef = useRef(false); + const [altArmed, setAltArmed] = useState(false); // observe mode: the ref is what onData and the resize listeners read mid-stream const observeRef = useRef(false); // a mirrored pane (no terminal attach on its PC): the grid is the pane's own in herdr, adopted like an observer's @@ -868,6 +870,13 @@ export function PaneTerminal({ setCtrlArmed(false); input = controlCode(data) ?? data; } + // after Ctrl, so an armed pair sends ESC + the control code (Ctrl+Alt+key) + const alt = altRef.current ? altSequence(input) : null; + if (alt !== null) { + altRef.current = false; + setAltArmed(false); + input = alt; + } if (socket.sendInput(current, input)) return; // A closed socket, an attachment still opening, or a failed synchronous send: // keep printable input for explicit review, never replay it automatically. @@ -1164,6 +1173,11 @@ export function PaneTerminal({ draftPaneRef.current = paneId; term.reset(); modifyOtherKeysRef.current = 0; + // a one-shot Ctrl or Alt armed for the pane that was open does not reach the next pane's first key + ctrlRef.current = false; + setCtrlArmed(false); + altRef.current = false; + setAltArmed(false); if (!paneId) return; try { fit?.fit(); @@ -1207,6 +1221,14 @@ export function PaneTerminal({ if (!inputLineRef.current) termRef.current?.focus(); }, []); + const toggleAlt = useCallback(() => { + if (composingRef.current) return; + const armed = !altRef.current; + altRef.current = armed; + setAltArmed(armed); + if (!inputLineRef.current) termRef.current?.focus(); + }, []); + // ask the server for the role change; the role-ack handler applies the local // consequences (stdin gate, grid adoption or reclamation) once it is confirmed. // The initial default is skipped: the server already treats fresh connections as interact. @@ -1667,6 +1689,7 @@ export function PaneTerminal({ )} {paneId !== null && !secretActive && !observing && !ended && inputLine && } {paneId !== null && !secretActive && !observing && !chatView && } ); diff --git a/src/components/SettingsDialog.css b/src/components/SettingsDialog.css index ac08d91d80..94b98cf14b 100644 --- a/src/components/SettingsDialog.css +++ b/src/components/SettingsDialog.css @@ -80,6 +80,39 @@ min-width: 0; } +/* the key bar's optional keys, drawn as the caps they put in the bar (KeyBar.css) */ +.key-bar-extras { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.key-bar-extras button { + min-width: var(--touch-target); + height: var(--touch-target); + padding: 0 var(--space-2); + background: transparent; + border: var(--hairline) solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-dim); + font-family: var(--font-mono); + font-size: var(--fs-sm); + white-space: nowrap; + cursor: pointer; +} + +@media (hover: hover) { + .key-bar-extras button:hover { + background: var(--bg-hover); + } +} + +.key-bar-extras button[aria-pressed="true"] { + background: var(--bg-elevated); + border-color: var(--accent); + color: var(--text-strong); +} + .settings-toggle { position: relative; flex: 0 0 auto; diff --git a/src/components/SettingsDialog.tsx b/src/components/SettingsDialog.tsx index ff49ea6e51..0221619f7d 100644 --- a/src/components/SettingsDialog.tsx +++ b/src/components/SettingsDialog.tsx @@ -8,6 +8,8 @@ import { useInstallPrompt } from "../lib/install.ts"; import { SHORTCUTS, formatKeys, shortcutKeys, shortcutConflict } from "../lib/shortcuts.ts"; import { CHAT_FONT_MAX, CHAT_FONT_MIN, CHAT_WIDTHS, chatFontSize, DEFAULT_SETTINGS, QUICK_REPLIES_MAX, QUICK_REPLY_MAX_CHARS, TERMINAL_FONT_MAX, TERMINAL_FONT_MIN, TERMINAL_WHEEL_SPEED_MAX, TERMINAL_WHEEL_SPEED_MIN, useSettings, forgetPaneViews } from "../lib/settings.ts"; import { LANGUAGE_NAMES, LANGUAGE_SETTINGS, useT } from "../lib/i18n.ts"; +import { KEY_BAR_EXTRAS } from "../lib/keys.ts"; +import { EXTRA_KEY_CAPS } from "./KeyBar.tsx"; import { FONT_FAMILY_MAX_CHARS, sanitizeFontFamily } from "../lib/fontFamily.ts"; import type { UpdatesModel } from "../lib/updates.ts"; import type { MachineSettings } from "../../shared/machines.ts"; @@ -297,6 +299,21 @@ export function SettingsDialog({ open, onClose, actions, updates, auth, herdrVer +
+
{t("Key bar")}{t("Extra keys in the bar under the terminal on a touch screen. Esc, Tab, Ctrl, the arrows and ^C are always there.")}
+
+
+ {KEY_BAR_EXTRAS.map((extra) => { + const { cap, label } = EXTRA_KEY_CAPS[extra]; + const on = settings.keyBarExtras.includes(extra); + return ( + + ); + })} +
diff --git a/src/lib/i18n.ja.ts b/src/lib/i18n.ja.ts index cfd3b28324..db2135271b 100644 --- a/src/lib/i18n.ja.ts +++ b/src/lib/i18n.ja.ts @@ -372,6 +372,14 @@ export const JA: Record = { "Left": "左", "Right": "右", "Control C": "Control C", + "Control D": "Control D", + "Control Z": "Control Z", + "Shift Tab": "Shift Tab", + "Page up": "ページアップ", + "Page down": "ページダウン", + "Page up and Page down": "ページアップとページダウン", + "Key bar": "キーバー", + "Extra keys in the bar under the terminal on a touch screen. Esc, Tab, Ctrl, the arrows and ^C are always there.": "タッチ画面でターミナルの下のキーバーに加えるキーです。Esc、Tab、Ctrl、矢印、^C は常にあります。", // ---- prompt card ---- "Submit": "送信", diff --git a/src/lib/i18n.ko.ts b/src/lib/i18n.ko.ts index 21b792bf2d..d9869d0515 100644 --- a/src/lib/i18n.ko.ts +++ b/src/lib/i18n.ko.ts @@ -370,6 +370,14 @@ export const KO: Record = { "Left": "왼쪽", "Right": "오른쪽", "Control C": "Control C", + "Control D": "Control D", + "Control Z": "Control Z", + "Shift Tab": "Shift Tab", + "Page up": "페이지 업", + "Page down": "페이지 다운", + "Page up and Page down": "페이지 업, 페이지 다운", + "Key bar": "키 바", + "Extra keys in the bar under the terminal on a touch screen. Esc, Tab, Ctrl, the arrows and ^C are always there.": "터치 화면에서 터미널 아래 키 바에 더할 키입니다. Esc, Tab, Ctrl, 화살표, ^C는 항상 있습니다.", // ---- prompt card ---- "Submit": "제출", diff --git a/src/lib/i18n.test.ts b/src/lib/i18n.test.ts index ab0ceda3ab..8f0ba20ce5 100644 --- a/src/lib/i18n.test.ts +++ b/src/lib/i18n.test.ts @@ -66,6 +66,7 @@ function labelMapKeys(): string[] { ...grab("components/Composer.tsx", "SOURCE_LABEL", value), ...grab("components/DevicesPanel.tsx", "VIA", value), ...grab("components/KeyBar.tsx", "ARROWS", /label: "([^"]+)"/g), + ...grab("components/KeyBar.tsx", "EXTRA_KEY_CAPS", /label: "([^"]+)"/g), ...grab("lib/shortcuts.ts", "SHORTCUTS", /label: "([^"]+)"/g), ...grab("lib/bridgeProgress.ts", "STAGES", /label: "([^"]+)"/g), ...grab("lib/updateProgress.ts", "STEPS", /label: "([^"]+)"/g), @@ -102,7 +103,7 @@ describe.each(Object.entries(DICTIONARIES))("%s dictionary", (_name, dictionary) it("is not just the English repeated", () => { // names and key caps read the same in every language; Chinese UIs leave "Shell" and "Agent" in English - const sameOnPurpose = new Set(["PC {name}", "Control C", "Shell", "Agent", "Catppuccin"]); + const sameOnPurpose = new Set(["PC {name}", "Control C", "Control D", "Control Z", "Shift Tab", "Shell", "Agent", "Catppuccin"]); const same = Object.entries(dictionary).filter(([en, text]) => en === text && /[a-z]{3}/i.test(en) && !sameOnPurpose.has(en)); expect(same.map(([en]) => en)).toEqual([]); }); diff --git a/src/lib/i18n.zh.ts b/src/lib/i18n.zh.ts index d22ffda741..1ed05cad29 100644 --- a/src/lib/i18n.zh.ts +++ b/src/lib/i18n.zh.ts @@ -374,6 +374,14 @@ export const ZH: Record = { "Left": "左", "Right": "右", "Control C": "Control C", + "Control D": "Control D", + "Control Z": "Control Z", + "Shift Tab": "Shift Tab", + "Page up": "向上翻页", + "Page down": "向下翻页", + "Page up and Page down": "向上翻页和向下翻页", + "Key bar": "按键栏", + "Extra keys in the bar under the terminal on a touch screen. Esc, Tab, Ctrl, the arrows and ^C are always there.": "触屏上终端下方按键栏中的额外按键。Esc、Tab、Ctrl、方向键和 ^C 始终都在。", // ---- prompt card ---- "Submit": "提交", diff --git a/src/lib/keys.test.ts b/src/lib/keys.test.ts index 3f6e54541b..20cada4429 100644 --- a/src/lib/keys.test.ts +++ b/src/lib/keys.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { controlCode, ctrlEnterSequence, isPrintable, keySequence, modifyOtherKeysLevel } from "./keys.ts"; +import { altSequence, controlCode, ctrlEnterSequence, isPrintable, keySequence, modifyOtherKeysLevel, sanitizeKeyBarExtras } from "./keys.ts"; describe("controlCode", () => { it("maps letters to their control code regardless of case", () => { @@ -50,6 +50,50 @@ describe("keySequence", () => { expect(keySequence("ArrowUp", true)).toBe("\u001bOA"); expect(keySequence("ArrowLeft", true)).toBe("\u001bOD"); }); + + it("sends the optional keys as xterm does: Home/End follow DECCKM, the rest are fixed", () => { + expect(keySequence("BackTab", false)).toBe("\u001b[Z"); + expect(keySequence("Home", false)).toBe("\u001b[H"); + expect(keySequence("End", false)).toBe("\u001b[F"); + expect(keySequence("Home", true)).toBe("\u001bOH"); + expect(keySequence("End", true)).toBe("\u001bOF"); + expect(keySequence("PageUp", true)).toBe("\u001b[5~"); + expect(keySequence("PageDown", false)).toBe("\u001b[6~"); + expect(keySequence("ctrl-d", false)).toBe("\u0004"); + expect(keySequence("ctrl-z", false)).toBe("\u001a"); + expect([keySequence("pipe", false), keySequence("tilde", false), keySequence("slash", false)]).toEqual(["|", "~", "/"]); + }); +}); + +describe("altSequence", () => { + it("puts ESC before one character, control characters and IME syllables included", () => { + expect(altSequence("b")).toBe("\u001bb"); + expect(altSequence("\u007f")).toBe("\u001b\u007f"); + expect(altSequence("\r")).toBe("\u001b\r"); + expect(altSequence("\u0003")).toBe("\u001b\u0003"); + expect(altSequence("한")).toBe("\u001b한"); + expect(altSequence("😀")).toBe("\u001b😀"); + }); + + it("adds the Alt modifier to cursor and editing keys in either cursor mode", () => { + expect(altSequence("\u001b[D")).toBe("\u001b[1;3D"); + expect(altSequence("\u001bOA")).toBe("\u001b[1;3A"); + expect(altSequence("\u001bOH")).toBe("\u001b[1;3H"); + expect(altSequence("\u001b[5~")).toBe("\u001b[5;3~"); + }); + + it("leaves pastes and terminal reports alone, so they do not use up the armed Alt", () => { + for (const data of ["ab", "", "\u001b[12;5R", "\u001b[I", "\u001b[<0;3;4M", "\u001b[200~x\u001b[201~", "\u001b[Z"]) expect(altSequence(data)).toBeNull(); + }); +}); + +describe("sanitizeKeyBarExtras", () => { + it("keeps known keys once, in the bar's order, and the default for a missing list", () => { + expect(sanitizeKeyBarExtras(["slash", "alt", "nope", "alt", 3], ["alt"])).toEqual(["alt", "slash"]); + expect(sanitizeKeyBarExtras([], ["alt"])).toEqual([]); + expect(sanitizeKeyBarExtras(undefined, ["alt"])).toEqual(["alt"]); + expect(sanitizeKeyBarExtras("alt", ["alt"])).toEqual(["alt"]); + }); }); describe("modifyOtherKeysLevel", () => { diff --git a/src/lib/keys.ts b/src/lib/keys.ts index b09d72b5b9..e112c785b0 100644 --- a/src/lib/keys.ts +++ b/src/lib/keys.ts @@ -5,8 +5,20 @@ * differently from a terminal are below them. */ -/** Keys a soft keyboard has no room for; ctrl-c is a chord, the rest are DOM key names. */ -export type KeyBarKey = "Escape" | "Tab" | "ArrowUp" | "ArrowDown" | "ArrowLeft" | "ArrowRight" | "ctrl-c"; +/** Keys a soft keyboard has no room for; ctrl-* are chords, pipe/tilde/slash the characters, the rest DOM key names. */ +export type KeyBarKey = + | "Escape" | "Tab" | "BackTab" | "ArrowUp" | "ArrowDown" | "ArrowLeft" | "ArrowRight" + | "Home" | "End" | "PageUp" | "PageDown" | "ctrl-c" | "ctrl-d" | "ctrl-z" | "pipe" | "tilde" | "slash"; + +/** The key bar's optional keys, as Settings lists them; a pair is one choice. Esc, Tab, Ctrl, the arrows and ^C are always there. */ +export type KeyBarExtra = "alt" | "shift-tab" | "home-end" | "page-up-down" | "ctrl-d" | "ctrl-z" | "pipe" | "tilde" | "slash"; +export const KEY_BAR_EXTRAS: readonly KeyBarExtra[] = ["alt", "shift-tab", "home-end", "page-up-down", "ctrl-d", "ctrl-z", "pipe", "tilde", "slash"]; + +/** The chosen optional keys: known ones only, each once, in KEY_BAR_EXTRAS order; anything but a list keeps the default. */ +export function sanitizeKeyBarExtras(value: unknown, fallback: readonly KeyBarExtra[]): KeyBarExtra[] { + if (!Array.isArray(value)) return [...fallback]; + return KEY_BAR_EXTRAS.filter((extra) => value.includes(extra)); +} /** A single printable character: what the one-shot Control modifier consumes. */ export function isPrintable(data: string): boolean { @@ -26,14 +38,34 @@ export function controlCode(ch: string): string | null { * while a full-screen program has application cursor keys on, CSI otherwise. */ export function keySequence(key: KeyBarKey, applicationCursorKeys: boolean): string { - const cursor = (final: "A" | "B" | "C" | "D"): string => (applicationCursorKeys ? "\u001bO" : "\u001b[") + final; + const cursor = (final: "A" | "B" | "C" | "D" | "H" | "F"): string => (applicationCursorKeys ? "\u001bO" : "\u001b[") + final; switch (key) { case "Escape": return "\u001b"; case "Tab": return "\t"; + case "BackTab": + return "\u001b[Z"; case "ctrl-c": return "\u0003"; + case "ctrl-d": + return "\u0004"; + case "ctrl-z": + return "\u001a"; + case "pipe": + return "|"; + case "tilde": + return "~"; + case "slash": + return "/"; + case "Home": + return cursor("H"); + case "End": + return cursor("F"); + case "PageUp": + return "\u001b[5~"; + case "PageDown": + return "\u001b[6~"; case "ArrowUp": return cursor("A"); case "ArrowDown": @@ -45,6 +77,21 @@ export function keySequence(key: KeyBarKey, applicationCursorKeys: boolean): str } } +/** + * One keystroke under the key bar's one-shot Alt, as xterm sends it with metaSendsEscape: ESC + * before a single character (Alt+b, Alt+Backspace, Alt+Enter), and the Alt modifier in a cursor + * or editing key's sequence (Alt+Left is CSI 1;3D, Alt+PageUp CSI 5;3~). Null for anything else, + * such as a paste or a report the terminal answers with, which the armed Alt lets through as it is. + */ +export function altSequence(data: string): string | null { + if ([...data].length === 1) return "\u001b" + data; + const cursor = /^\u001b[[O]([A-DHF])$/.exec(data); + if (cursor) return `\u001b[1;3${cursor[1]}`; + const editing = /^\u001b\[(\d+)~$/.exec(data); + if (editing) return `\u001b[${editing[1]};3~`; + return null; +} + /** * xterm's modifyOtherKeys level after CSI > 4 ; Pv m, or CSI > 4 n, which turns it off. Other * resources leave it as it was. xterm.js 5.5 ignores the request and types Ctrl+Enter as a plain diff --git a/src/lib/settings.ts b/src/lib/settings.ts index abd5ad6f10..e262d36829 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -9,6 +9,7 @@ import { createContext, createElement, useCallback, useContext, useEffect, useMe import { LANGUAGE_SETTINGS, LOCALE_TAGS, resolveLanguage, setCurrentLanguage, type Language, type LanguageSetting } from "./i18n.ts"; import type { AlertPrefs, DoneAlerts } from "../../shared/notify-policy.ts"; import { chatFontStack, sanitizeFontFamily } from "./fontFamily.ts"; +import { sanitizeKeyBarExtras, type KeyBarExtra } from "./keys.ts"; export type ThemeSetting = "dark" | "light" | "system"; export type ResolvedTheme = "dark" | "light"; @@ -32,6 +33,8 @@ import { sanitizeShortcutOverrides, type ShortcutOverrides } from "./shortcutBin export interface Settings { terminalInputMode: "auto" | "line" | "direct"; + /** the touch key bar's optional keys (lib/keys.ts); they take their fixed places in the row */ + keyBarExtras: KeyBarExtra[]; shortcutOverrides: ShortcutOverrides; theme: ThemeSetting; density: Density; @@ -95,6 +98,7 @@ export interface Settings { export const DEFAULT_SETTINGS: Settings = { terminalInputMode: "auto", + keyBarExtras: ["alt"], shortcutOverrides: {}, theme: "dark", density: "comfortable", @@ -220,6 +224,7 @@ export function sanitizeSettings(raw: unknown): Settings { const chatFont = record["chatFontSize"]; return { terminalInputMode: record["terminalInputMode"] === "line" || record["terminalInputMode"] === "direct" ? record["terminalInputMode"] : "auto", + keyBarExtras: sanitizeKeyBarExtras(record["keyBarExtras"], DEFAULT_SETTINGS.keyBarExtras), shortcutOverrides: sanitizeShortcutOverrides(record["shortcutOverrides"]), theme: theme === "dark" || theme === "light" || theme === "system" ? theme : DEFAULT_SETTINGS.theme, density: density === "compact" || density === "comfortable" ? density : DEFAULT_SETTINGS.density,