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:
+128
-3
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user