feat(maestro): mobile special-key bar for web attach (NAP-342)

On touch devices the soft keyboard omits the keys terminals depend on
(Esc, Tab, Ctrl, arrows, Home/End, PgUp/PgDn). Add a Termux-style
scrollable key bar to the attach view that injects the right xterm
sequences into the existing PTY input path.

- Ctrl is a one-shot sticky modifier: arm it and the next character —
  tapped here or typed on the soft keyboard — is sent as its control
  code, so Ctrl+C and friends work on mobile.
- Taps preventDefault on pointerdown so the terminal keeps focus and the
  keyboard stays open.
- Bar is revealed only on coarse-pointer devices; a physical keyboard
  already has these keys.
- Shell height now tracks the visual viewport (installViewportSizing) so
  the bar lands above the soft keyboard instead of behind it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-20 13:37:51 +00:00
co-authored by Claude Opus 4.8
parent a05966ed2f
commit 4b775ec7cb
4 changed files with 232 additions and 4 deletions
+128 -3
View File
@@ -92,6 +92,9 @@ export function createAttachConnection(
const stateEl = el.querySelector(".attach-state") as HTMLSpanElement;
const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement;
const keybar = buildKeyBar((data) => sendInput(data));
el.appendChild(keybar.el);
const setState = (s: string, kind: "connecting" | "open" | "closed" | "error") => {
stateEl.textContent = s;
stateEl.dataset.state = kind;
@@ -181,6 +184,13 @@ export function createAttachConnection(
ws.send(JSON.stringify(msg));
};
/** PTY input: raw bytes out as a binary frame. Shared by keystrokes and the
* mobile key bar so both flow through the same path. */
const sendInput = (data: string) => {
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(new TextEncoder().encode(data));
};
const onResize = () => {
tryFit();
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
@@ -217,10 +227,10 @@ export function createAttachConnection(
setState(`closed (${ev.code})`, "closed");
};
// PTY input: every keystroke goes out as a binary frame.
// PTY input: every keystroke goes out as a binary frame. A pending key-bar
// Ctrl modifier (mobile) folds into the next typed character here.
const dataDisp = term.onData((data) => {
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(new TextEncoder().encode(data));
sendInput(keybar.consume(data));
});
const resizeDisp = term.onResize(({ cols, rows }) => {
sendControl({ type: "Resize", cols, rows });
@@ -275,3 +285,118 @@ export function createAttachConnection(
},
};
}
/**
* One key in the mobile key bar. `seq` is the raw bytes sent to the PTY.
* `printable` marks single characters that a pending Ctrl can fold into a
* control code (e.g. `c` → `\x03`); `mod` marks the sticky Ctrl toggle itself.
*/
type KeyDef =
| { label: string; seq: string; printable?: boolean }
| { label: string; mod: "ctrl" };
/**
* The keys the mobile soft keyboard hides: escape, tab, the sticky Ctrl
* modifier, cursor/navigation keys, and a few shell-common symbols. Cursor keys
* use the normal (DECCKM-off) sequences, which is what shells and full-screen
* apps expect by default.
*/
const KEY_DEFS: KeyDef[] = [
{ label: "Esc", seq: "\x1b" },
{ label: "Tab", seq: "\t" },
{ label: "Ctrl", mod: "ctrl" },
{ label: "←", seq: "\x1b[D" },
{ label: "↑", seq: "\x1b[A" },
{ label: "↓", seq: "\x1b[B" },
{ label: "→", seq: "\x1b[C" },
{ label: "Home", seq: "\x1b[H" },
{ label: "End", seq: "\x1b[F" },
{ label: "PgUp", seq: "\x1b[5~" },
{ label: "PgDn", seq: "\x1b[6~" },
{ label: "-", seq: "-", printable: true },
{ label: "/", seq: "/", printable: true },
{ label: "|", seq: "|", printable: true },
{ label: "~", seq: "~", printable: true },
];
/**
* Fold a single character into its control code: letters and `@[\]^_` map to
* 0x00–0x1f, space → NUL, `?` → DEL. Anything else is returned unchanged.
*/
function toCtrl(ch: string): string {
if (ch.length !== 1) return ch;
const upper = ch.toUpperCase().codePointAt(0) ?? 0;
if (upper >= 0x40 && upper <= 0x5f) return String.fromCodePoint(upper & 0x1f);
if (ch === " ") return "\x00";
if (ch === "?") return "\x7f";
return ch;
}
interface KeyBar {
readonly el: HTMLElement;
/** Apply a pending Ctrl to typed input (soft keyboard), consuming the arm. */
consume(data: string): string;
}
/**
* Build the Termux-style special-key bar. Hidden by default; CSS reveals it on
* coarse-pointer (touch) devices. `send` delivers bytes to the PTY.
*
* Ctrl is a one-shot sticky modifier: arm it, and the very next character —
* whether tapped here or typed on the soft keyboard — is sent as its control
* code. Taps `preventDefault` on pointerdown so the terminal keeps focus and the
* keyboard stays open.
*/
function buildKeyBar(send: (data: string) => void): KeyBar {
const el = document.createElement("div");
el.className = "attach-keybar";
el.setAttribute("role", "toolbar");
el.setAttribute("aria-label", "Special keys");
let ctrlArmed = false;
let ctrlBtn: HTMLButtonElement | null = null;
const setCtrl = (on: boolean) => {
ctrlArmed = on;
ctrlBtn?.setAttribute("aria-pressed", String(on));
};
for (const def of KEY_DEFS) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "keycap";
btn.textContent = def.label;
if ("mod" in def) {
btn.classList.add("keycap-mod");
btn.setAttribute("aria-pressed", "false");
btn.setAttribute("aria-label", "Ctrl modifier");
ctrlBtn = btn;
btn.addEventListener("click", () => setCtrl(!ctrlArmed));
} else {
btn.addEventListener("click", () => {
if (ctrlArmed) {
send(def.printable ? toCtrl(def.seq) : def.seq);
setCtrl(false);
} else {
send(def.seq);
}
});
}
// Keep the terminal focused (soft keyboard up) — don't let the tap steal it.
btn.addEventListener("pointerdown", (e) => e.preventDefault());
el.appendChild(btn);
}
return {
el,
consume(data: string): string {
if (ctrlArmed && data.length === 1) {
setCtrl(false);
return toCtrl(data);
}
return data;
},
};
}
+2
View File
@@ -4,9 +4,11 @@ import { CatalogStore } from "./events.js";
import { openSpawnModal } from "./spawn.js";
import { initTheme } from "./theme.js";
import { sessionName } from "./types.js";
import { installViewportSizing } from "./viewport.js";
import "./styles.css";
initTheme();
installViewportSizing();
const rootEl = document.getElementById("app");
if (!rootEl) {
+70 -1
View File
@@ -209,7 +209,10 @@ body {
}
#app {
height: 100%;
/* `--app-vh` tracks the *visual* viewport (set by installViewportSizing) so
the shell stays above the mobile soft keyboard; falls back to the layout
viewport where `visualViewport` is unavailable. */
height: var(--app-vh, 100%);
display: flex;
flex-direction: column;
overflow: hidden;
@@ -1168,6 +1171,72 @@ textarea {
color: var(--danger);
}
/* ---------- mobile special-key bar (Termux-style) ---------- */
/* Hidden on devices with a precise pointer (a physical keyboard already has
these keys). Revealed on touch, where the soft keyboard omits them. The shell
tracks the visual viewport (see `#app`), so this row lands above the keyboard. */
.attach-keybar {
display: none;
}
@media (pointer: coarse) {
.attach-keybar {
display: flex;
flex: 0 0 auto;
align-items: stretch;
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;
overflow-y: hidden;
overscroll-behavior-x: contain;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.attach-keybar::-webkit-scrollbar {
display: none;
}
}
.attach-keybar .keycap {
flex: 0 0 auto;
min-width: var(--tap);
height: var(--tap);
padding: 0 var(--sp-3);
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
font-size: var(--fs-14);
color: var(--ink);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--r-1);
user-select: none;
-webkit-user-select: none;
touch-action: manipulation;
cursor: pointer;
transition:
background var(--dur-fast) var(--ease-out),
border-color var(--dur-fast) var(--ease-out);
}
.attach-keybar .keycap:active {
background: var(--surface-3);
border-color: var(--border-strong);
}
/* Armed sticky Ctrl: lit in signal-green until it folds into the next key. */
.attach-keybar .keycap-mod[aria-pressed="true"] {
color: var(--accent-ink);
background: var(--accent);
border-color: var(--accent-strong);
}
/* ---------- modal ---------- */
.modal-backdrop {
+32
View File
@@ -0,0 +1,32 @@
/**
* Bind the app shell's height to the *visual* viewport.
*
* On mobile the layout viewport does not shrink when the on-screen keyboard
* opens — only the visual viewport does. A shell sized at `height: 100%` (or
* `100dvh`) therefore extends behind the keyboard, hiding anything pinned to the
* bottom: the attach view's special-key bar (NAP-342). Mirroring
* `visualViewport.height` onto a CSS custom property keeps the shell exactly as
* tall as the visible area, so the key bar lands just above the keyboard.
*
* No-ops where `visualViewport` is unavailable; the CSS falls back to `100%`.
*/
export function installViewportSizing(): void {
const vv = window.visualViewport;
if (!vv) return;
const root = document.documentElement;
let raf = 0;
const sync = () => {
raf = 0;
root.style.setProperty("--app-vh", `${Math.round(vv.height)}px`);
};
const schedule = () => {
if (raf) return;
raf = requestAnimationFrame(sync);
};
vv.addEventListener("resize", schedule);
vv.addEventListener("scroll", schedule);
sync();
}