diff --git a/web/src/attach.ts b/web/src/attach.ts index 95d05dc..ec7d236 100644 --- a/web/src/attach.ts +++ b/web/src/attach.ts @@ -5,7 +5,7 @@ import "@xterm/xterm/css/xterm.css"; import { attachWsUrl } from "./routes.js"; import { escapeHtml } from "./html.js"; import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js"; -import { applyModsToChar, cursorSeq } from "./keys.js"; +import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js"; import { requestToggleDrawer } from "./drawer.js"; import { splitCommand, toolIconSvg } from "./toolIcon.js"; import type { ClientControl, Uuid } from "./types.js"; @@ -433,11 +433,17 @@ export function createAttachConnection( * - `mod` is a sticky one-shot modifier toggle (Ctrl / Alt). * - `cursor` is a cursor/navigation key, identified by its CSI final byte, so a * pending modifier can promote it to the `\x1b[1;` form. + * - `tilde` is a `~`-terminated navigation key (PgUp/PgDn), identified by its + * CSI parameter, whose modified form is `\x1b[;~`. + * - `char` is a literal character (the symbols the soft keyboard buries behind + * a page switch), folded through a pending modifier like typed input. * - `seq` is a plain key (Esc, Tab) sent verbatim. */ type KeyDef = | { label: string; mod: Modifier } | { label: string; cursor: CursorFinal } + | { label: string; tilde: number } + | { label: string; char: string } | { label: string; seq: string }; type Modifier = "ctrl" | "alt"; @@ -445,14 +451,17 @@ type Modifier = "ctrl" | "alt"; type CursorFinal = "A" | "B" | "C" | "D" | "H" | "F"; /** - * The keys the mobile soft keyboard hides, ordered to flow row-major into a - * 5-column / 2-row grid (NAP-342 review): + * The keys the mobile soft keyboard hides, ordered to flow row-major into an + * 8-column / 2-row grid (#7): * - * Esc Tab Home ↑ End - * Ctrl Alt ← ↓ → + * Esc Tab Home ↑ End PgUp - / + * Ctrl Alt ← ↓ → PgDn | ~ * - * — Esc/Tab + the Ctrl/Alt sticky modifiers on the left, and the cursor keys as - * a real-keyboard inverted-T on the right, with Home/End filling its top corners. + * — Esc/Tab + the Ctrl/Alt sticky modifiers on the left, the cursor keys as a + * real-keyboard inverted-T in the middle with Home/End filling its top corners, + * then the paging pair and the shell symbols that cost a soft-keyboard page + * switch to reach. Narrower screens scroll the bar sideways rather than + * shrinking the caps below a thumb's width. */ const KEY_DEFS: KeyDef[] = [ { label: "Esc", seq: "\x1b" }, @@ -460,11 +469,17 @@ const KEY_DEFS: KeyDef[] = [ { label: "Home", cursor: "H" }, { label: "↑", cursor: "A" }, { label: "End", cursor: "F" }, + { label: "PgUp", tilde: 5 }, + { label: "-", char: "-" }, + { label: "/", char: "/" }, { label: "Ctrl", mod: "ctrl" }, { label: "Alt", mod: "alt" }, { label: "←", cursor: "D" }, { label: "↓", cursor: "B" }, { label: "→", cursor: "C" }, + { label: "PgDn", tilde: 6 }, + { label: "|", char: "|" }, + { label: "~", char: "~" }, ]; /** @@ -521,6 +536,16 @@ function buildKeyBar(send: (data: string) => void): KeyBar { send(cursorSeq(def.cursor, armed.ctrl, armed.alt)); clearMods(); }); + } else if ("tilde" in def) { + btn.addEventListener("click", () => { + send(tildeSeq(def.tilde, armed.ctrl, armed.alt)); + clearMods(); + }); + } else if ("char" in def) { + btn.addEventListener("click", () => { + send(applyModsToChar(def.char, armed.ctrl, armed.alt)); + clearMods(); + }); } else { btn.addEventListener("click", () => { send(def.seq); diff --git a/web/src/keys.test.ts b/web/src/keys.test.ts index b4915e6..4205c53 100644 --- a/web/src/keys.test.ts +++ b/web/src/keys.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { applyModsToChar, cursorSeq, toCtrl } from "./keys.js"; +import { applyModsToChar, cursorSeq, tildeSeq, toCtrl } from "./keys.js"; describe("toCtrl", () => { it("maps letters to their control code, case-insensitively", () => { @@ -43,3 +43,16 @@ describe("cursorSeq", () => { expect(cursorSeq("A", true, true)).toBe("\x1b[1;7A"); }); }); + +describe("tildeSeq", () => { + it("uses the bare CSI form when unmodified", () => { + expect(tildeSeq(5, false, false)).toBe("\x1b[5~"); + expect(tildeSeq(6, false, false)).toBe("\x1b[6~"); + }); + + it("keeps the parameter in place when modified, unlike the cursor keys", () => { + expect(tildeSeq(5, true, false)).toBe("\x1b[5;5~"); + expect(tildeSeq(6, false, true)).toBe("\x1b[6;3~"); + expect(tildeSeq(6, true, true)).toBe("\x1b[6;7~"); + }); +}); diff --git a/web/src/keys.ts b/web/src/keys.ts index 6ef6c6e..c14c28b 100644 --- a/web/src/keys.ts +++ b/web/src/keys.ts @@ -23,6 +23,18 @@ export function applyModsToChar(ch: string, ctrl: boolean, alt: boolean): string * pending — `\x1b[1;`, n = 1 + alt(2) + ctrl(4) (xterm convention). */ export function cursorSeq(final: string, ctrl: boolean, alt: boolean): string { if (!ctrl && !alt) return `\x1b[${final}`; - const n = 1 + (alt ? 2 : 0) + (ctrl ? 4 : 0); - return `\x1b[1;${n}${final}`; + return `\x1b[1;${modParam(ctrl, alt)}${final}`; +} + +/** Modifier parameter of the xterm CSI forms: n = 1 + alt(2) + ctrl(4). */ +function modParam(ctrl: boolean, alt: boolean): number { + return 1 + (alt ? 2 : 0) + (ctrl ? 4 : 0); +} + +/** Sequence for the `~`-terminated navigation keys (PgUp `5`, PgDn `6`), which + * carry modifiers as `\x1b[;~` rather than the cursor keys' `1;` + * form. */ +export function tildeSeq(code: number, ctrl: boolean, alt: boolean): string { + if (!ctrl && !alt) return `\x1b[${code}~`; + return `\x1b[${code};${modParam(ctrl, alt)}~`; } diff --git a/web/src/styles.css b/web/src/styles.css index 97d6a22..af1a73c 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1499,20 +1499,29 @@ input.leaf-rename:focus { /* ---------- mobile special-key bar (Termux-style) ---------- */ -/* Two compact rows (Termux layout) in a 5-column grid the 10 keys flow into: - Esc/Tab + Ctrl/Alt on the left, cursor keys as a real-keyboard inverted-T on - the right (Home/End fill the top corners). Layout is always defined; only - *visibility* is gated: hidden on precise-pointer devices (a physical keyboard - already has these keys), shown on touch where the soft keyboard omits them. - The shell tracks the visual viewport (see `#app`), so it lands above it. */ +/* Two compact rows (Termux layout) in an 8-column grid the 16 keys flow into: + Esc/Tab + Ctrl/Alt on the left, cursor keys as a real-keyboard inverted-T in + the middle (Home/End fill the top corners), then PgUp/PgDn and the shell + symbols. Columns never shrink below a thumb's width — on a narrow phone the + bar scrolls sideways instead. Layout is always defined; only *visibility* is + gated: hidden on precise-pointer devices (a physical keyboard already has + these keys), shown on touch where the soft keyboard omits them. The shell + tracks the visual viewport (see `#app`), so it lands above it. */ .attach-keybar { display: none; - grid-template-columns: repeat(5, 1fr); + grid-template-columns: repeat(8, minmax(2.5rem, 1fr)); gap: var(--sp-1); padding: var(--sp-1) var(--sp-2); padding-bottom: calc(var(--sp-1) + env(safe-area-inset-bottom, 0px)); border-top: 1px solid var(--border); background: var(--surface); + overflow-x: auto; + overscroll-behavior-x: contain; + scrollbar-width: none; +} + +.attach-keybar::-webkit-scrollbar { + display: none; } /* Shown on touch only while *this* terminal is focused (`keybar-active`, toggled