From 2aa31ef09069e7b6b5cd5cfae8b00b1c77b438b6 Mon Sep 17 00:00:00 2001 From: Hako Date: Tue, 6 Oct 2026 07:43:33 +0900 Subject: [PATCH 1/3] feat(terminal): an Alt key in the touch key bar, and optional keys chosen in Settings Alt holds for one key like Ctrl: ESC before a character, the Alt modifier on cursor and editing keys. Settings > Appearance > Key bar turns Alt off or adds Shift+Tab, Home/End, PgUp/PgDn, ^D, ^Z, |, ~ and /. --- CHANGELOG.md | 7 ++++ docs/guide.md | 4 ++- docs/terminal-input.md | 8 +++++ src/components/KeyBar.tsx | 49 ++++++++++++++++++++++++++-- src/components/PaneTerminal.tsx | 20 +++++++++++- src/components/SettingsDialog.css | 33 +++++++++++++++++++ src/components/SettingsDialog.tsx | 17 ++++++++++ src/lib/i18n.ja.ts | 8 +++++ src/lib/i18n.ko.ts | 8 +++++ src/lib/i18n.test.ts | 3 +- src/lib/i18n.zh.ts | 8 +++++ src/lib/keys.test.ts | 46 ++++++++++++++++++++++++++- src/lib/keys.ts | 53 +++++++++++++++++++++++++++++-- src/lib/settings.ts | 5 +++ 14 files changed, 260 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91301b02d2..8f32cb7c2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa ## [Unreleased] +### Added +- 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. + ## [0.3.50] - 2026-10-06 ### Changed diff --git a/docs/guide.md b/docs/guide.md index 615ce4f83b..f6e6493bb0 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 0c090668d8..e8d4b8137f 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. @@ -1207,6 +1216,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. @@ -1664,6 +1681,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 923340e7b5..d76d0c7994 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 b79fe9202c..933f38743b 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 521069882f..e912f4d00d 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, From 11f429939f3d80ea3a34a2e2eb1821d890e27f6a Mon Sep 17 00:00:00 2001 From: Hako Date: Tue, 6 Oct 2026 07:43:55 +0900 Subject: [PATCH 2/3] docs(changelog): link #487 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f32cb7c2d..8fc7dded9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa 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. + the arrows and Ctrl+C stay as they were. ([#487](https://github.com/devswha/herdr-web-ui/pull/487)) ## [0.3.50] - 2026-10-06 From 695363b62c8d80316e79107a58520a084187d5ca Mon Sep 17 00:00:00 2001 From: Hako Date: Tue, 6 Oct 2026 08:18:12 +0900 Subject: [PATCH 3/3] fix(terminal): a one-shot Ctrl or Alt armed in one pane does not reach the next pane's first key Review finding on #487: the pane-change reset cleared xterm but left the key bar's armed modifier, so switching panes with Alt (or Ctrl, which had the same gap) armed sent the modifier with the first key typed in the new pane. Both arms are cleared with the rest of the pane-change reset. Co-Authored-By: Claude Fable 5.1 --- src/components/PaneTerminal.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/components/PaneTerminal.tsx b/src/components/PaneTerminal.tsx index e8d4b8137f..4de2ba7867 100644 --- a/src/components/PaneTerminal.tsx +++ b/src/components/PaneTerminal.tsx @@ -1173,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();