feat(maestro): add paging and symbol keys to the mobile key bar
ci / web (push) Successful in 1m22s
ci / rust (push) Successful in 7m24s
ci / flake (push) Successful in 8m50s

PgUp/PgDn and the `- / | ~` symbols the soft keyboard buries behind a page
switch join the bar, in an 8-column grid that keeps the cursor inverted-T.
Columns hold a thumb's width, so a narrow phone scrolls the bar sideways
instead of shrinking the caps.

Closes #7
This commit is contained in:
Miguel Palhas
2026-09-02 16:01:08 +01:00
parent 8cb6aeb61e
commit 3ce1a6f331
4 changed files with 76 additions and 17 deletions
+32 -7
View File
@@ -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;<n><final>` form.
* - `tilde` is a `~`-terminated navigation key (PgUp/PgDn), identified by its
* CSI parameter, whose modified form is `\x1b[<code>;<n>~`.
* - `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);
+14 -1
View File
@@ -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~");
});
});
+14 -2
View File
@@ -23,6 +23,18 @@ export function applyModsToChar(ch: string, ctrl: boolean, alt: boolean): string
* pending — `\x1b[1;<n><final>`, 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[<code>;<n>~` rather than the cursor keys' `1;<n>`
* form. */
export function tildeSeq(code: number, ctrl: boolean, alt: boolean): string {
if (!ctrl && !alt) return `\x1b[${code}~`;
return `\x1b[${code};${modParam(ctrl, alt)}~`;
}
+16 -7
View File
@@ -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