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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user