This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/web/src/attach.ts
T
Miguel PalhasandClaude Opus 5 b2cbb23fe2 feat(maestro): open the bar with ctrl-k over a focused pane
The global listener already ran in the capture phase, but deferred to
isTypingTarget, so xterm's hidden textarea kept Ctrl-K. The bar now treats
that textarea as not a typing target, while real form fields — the spawn
form's inputs — keep their own Ctrl-K.

Leader then ctrl-k (ctrl-a ctrl-k) sends the literal 0x0B to the focused
pane through the leader chord the command set already resolves, so
readline's kill-to-end-of-line stays reachable. The pool grows a
sendToFocused for it, over a new AttachConnection.sendInput.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 17:03:19 +01:00

690 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { FitAddon } from "@xterm/addon-fit";
import { WebglAddon } from "@xterm/addon-webgl";
import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
import { attachWsUrl } from "./routes.js";
import { FOCUS_SIDEBAR_EVENT } from "./catalog.js";
import { CommandSet, defineCommands } from "./commands.js";
import { escapeHtml } from "./html.js";
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.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";
import { WebglPolicy } from "./webglRenderer.js";
// The pane's half of the command registry (#148): plain Escape hands the
// keyboard back to the session list, the pair to the list's Tab.
const paneKeys = new CommandSet(
defineCommands({
"pane.focusList": () => document.dispatchEvent(new CustomEvent(FOCUS_SIDEBAR_EVENT)),
}),
{ preventDefault: false },
);
/** One budget for the whole page — see webglRenderer.ts for why contexts are
* rationed rather than handed to every connection. */
const webglPolicy = new WebglPolicy();
/**
* A single live attach: an xterm terminal bound to the daemon's attach WebSocket.
*
* The connection is **decoupled from the UI**: it owns a long-lived WS that
* keeps streaming PTY output into the terminal buffer whether or not the view
* is currently mounted. The shell moves `el` between the visible content pane
* and a hidden cache; `onMount`/`onUnmount` only adjust focus + sizing, never
* the connection. Only `dispose()` (eviction, an explicit disconnect, or the
* session ending) tears the socket down — so switching away and back is
* instant, with scrollback already in place.
*/
export interface AttachConnection {
readonly sessionId: Uuid;
/** The `.attach` element (header + terminal body) to mount into a pane. */
readonly el: HTMLElement;
/** Becomes the visible view: fit to the pane, focus, sync size.
* `focus: false` mounts without taking the keyboard — what a sidebar
* preview needs, so j/k keeps working while the pane is on screen. */
onMount(opts?: { focus?: boolean }): void;
/** Parked into the cache: keep streaming, just stop driving focus/resize. */
onUnmount(): void;
/** Put the keyboard back into this terminal — what leaving the sidebar does. */
focusTerminal(): void;
/** Type raw bytes straight into the PTY, bypassing the terminal's own key
* handling — the leader's literal Ctrl-K escape hatch
* (docs/COMMAND-BAR.md). */
sendInput(data: string): void;
/** Fully disconnect: close the WS, dispose the terminal, remove the element. */
dispose(): void;
}
export interface AttachOptions {
/** Invoked when the user clicks the view's explicit "disconnect" control. */
onRequestClose?: (sessionId: Uuid) => void;
/** Invoked when the socket closes on its own — the session ended or the link
* dropped — but NOT on an intentional `dispose()` (eviction / user close). The
* shell uses this to auto-close the pane rather than leave a dead one. */
onClosed?: (sessionId: Uuid) => void;
/** Asked on every close: should the socket be retried? The shell answers
* from the catalog — see `shouldRetryClose`. */
shouldReconnect?: (sessionId: Uuid, code: number) => boolean;
}
/** What the header shows about the session (name / command). Optional — falls
* back to a short session id when the session isn't known yet. */
export interface AttachInfo {
name: string;
command: string;
}
/** Header echoes the sidebar: the program becomes its tool icon when we have a
* mark for it and stays as its bare name when we do not. Arguments are
* dropped, as on the card. */
function commandFragment(command: string): string {
const { tool, program } = splitCommand(command);
const icon = tool ? toolIconSvg(tool) : null;
const text = icon ? "" : program;
return `<span class="at-cmd">${icon ?? ""}${text ? escapeHtml(text) : ""}</span>`;
}
export function createAttachConnection(
sessionId: Uuid,
opts: AttachOptions = {},
info?: AttachInfo,
): AttachConnection {
const el = document.createElement("div");
el.className = "attach";
// Useful identity over a bare UUID: name · command. The full id stays in the
// tooltip for when you really need it.
const titleHtml = info
? `<span class="at-name">${escapeHtml(info.name)}</span>` +
(info.command && info.command !== info.name ? commandFragment(info.command) : "")
: `session <span class="sid">${escapeHtml(sessionId.slice(0, 8))}</span>`;
const titleAttr = info ? `${info.name} · ${info.command} (${sessionId})` : sessionId;
el.innerHTML = `
<header class="attach-header">
<button type="button" class="btn btn-ghost btn-icon sessions-btn" aria-label="Show sessions" title="Show sessions">☰</button>
<span class="attach-title" title="${escapeHtml(titleAttr)}">${titleHtml}</span>
<span class="attach-state" data-state="connecting">connecting…</span>
<button class="btn btn-ghost btn-icon disconnect" type="button" aria-label="Disconnect" title="Disconnect this session">⏻</button>
</header>
<div class="attach-body"></div>
`;
const body = el.querySelector(".attach-body") as HTMLDivElement;
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;
};
// Mobile-only (CSS hides it on desktop, where the sidebar is always in view):
// the session list is an off-canvas drawer and this is what opens it.
(el.querySelector(".sessions-btn") as HTMLElement).addEventListener("click", () =>
requestToggleDrawer(),
);
disconnectBtn.addEventListener("click", () => {
opts.onRequestClose?.(sessionId);
});
const term = new Terminal({
cursorBlink: true,
fontFamily:
'"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
fontSize: 13,
convertEol: false,
theme: {
// The terminal stays dark in both app themes (convention); colors are
// tuned to the maestro palette — signal-green cursor, slate background.
background: "#121419",
foreground: "#e6e8ec",
cursor: "#5fe3a1",
cursorAccent: "#121419",
selectionBackground: "#2b6b4f",
},
scrollback: 5000,
allowProposedApi: true,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.open(body);
// GPU rendering is scoped to the mounted view, not the connection: acquired
// in onMount, released in onUnmount. See webglRenderer.ts.
let webgl: WebglAddon | null = null;
let dprMedia: MediaQueryList | null = null;
// The glyph atlas is rasterised for one device pixel ratio. Dragging the
// window to a display with a different one (or an Android browser-zoom
// change) leaves every cell blurry until the atlas is rebuilt.
const onDprChange = () => {
try {
term.clearTextureAtlas();
} catch {
// ignore — nothing to rebuild if the renderer already went away
}
watchDpr();
};
function watchDpr(): void {
dprMedia?.removeEventListener("change", onDprChange);
dprMedia = null;
try {
dprMedia = window.matchMedia?.(`(resolution: ${window.devicePixelRatio}dppx)`) ?? null;
dprMedia?.addEventListener("change", onDprChange);
} catch {
// ignore — a browser without `resolution` media queries just keeps the
// atlas it has, which is the pre-WebGL behaviour anyway
}
}
function releaseWebgl(): void {
dprMedia?.removeEventListener("change", onDprChange);
dprMedia = null;
if (!webgl) return;
const addon = webgl;
webgl = null;
try {
addon.dispose(); // xterm falls back to the DOM renderer
} catch {
// ignore — a lost context can throw on the way out
}
}
function acquireWebgl(): void {
if (webgl || disposed || !webglPolicy.enabled()) return;
try {
const addon = new WebglAddon();
// Context loss is routine on Android — backgrounding the tab is enough.
// Drop back to the DOM renderer, then retry only while the shared budget
// allows and this view is still the one on screen.
addon.onContextLoss(() => {
webglPolicy.noteContextLoss();
releaseWebgl();
if (mounted) acquireWebgl();
});
term.loadAddon(addon);
webgl = addon;
watchDpr();
} catch {
// Context creation failed outright (no GPU, or the browser is already at
// its context cap). Count it like a loss so we stop asking, and stay on
// the DOM renderer.
webglPolicy.noteContextLoss();
webgl = null;
}
}
// Plain Escape hands the keyboard back to the session list instead of the
// shell/app. `false` tells xterm to skip its own handling of this event —
// which is also why the set doesn't `preventDefault`: swallowing here is
// xterm's return value, not the browser's.
term.attachCustomKeyEventHandler((e) => {
if (e.type !== "keydown") return true;
return !paneKeys.handle(e);
});
// Touch scrolling: xterm's built-in touch handling is jittery (drags crawl a
// line at a time and stall), so map finger drags ourselves. Two routes:
// • normal buffer → term.scrollLines walks the scrollback.
// • mouse-tracking apps (tmux, Claude Code on the alt screen) → synthesize
// wheel events so xterm encodes them as mouse-wheel reports the app reads,
// scrolling *its* content (the only scroll that exists on the alt screen).
// • alt screen with no mouse tracking → nothing to do; the app owns input.
// `touch-action: none` (CSS) keeps the browser from cancelling the custom
// gesture stream; only the terminal surface opts out of native page panning.
// Listeners MUST sit on `.xterm`, not `.xterm-viewport`: `.xterm-screen` is a
// later sibling of the viewport, so it paints over it and takes every touch.
{
const root = term.element as HTMLElement | undefined;
const screen = el.querySelector(".xterm-screen") as HTMLElement | null;
const cellH = () => {
const core = (term as unknown as {
_core?: { _renderService?: { dimensions?: { css?: { cell?: { height: number } } } } };
})._core;
return core?._renderService?.dimensions?.css?.cell?.height || 16;
};
if (root) {
let lastY = 0;
let lastX = 0;
let remainder = 0;
root.addEventListener(
"touchstart",
(e) => {
if (e.touches.length !== 1) return;
lastY = e.touches[0].clientY;
lastX = e.touches[0].clientX;
remainder = 0;
},
{ passive: true },
);
root.addEventListener(
"touchmove",
(e) => {
if (e.touches.length !== 1) return;
const ch = cellH();
const y = e.touches[0].clientY;
const px = lastY - y + remainder; // finger up → scroll toward newer
lastY = y;
const lines = Math.trunc(px / ch);
remainder = px - lines * ch;
if (lines === 0) return;
// term.modes.mouseTrackingMode is "none" unless the app turned mouse
// reporting on — that's the signal to forward instead of scrollback.
const mouseTracked = term.modes.mouseTrackingMode !== "none";
if (mouseTracked && screen) {
// One wheel event per crossed cell, dispatched at the touch point so
// xterm computes valid cell coords for the mouse report.
const dir = lines > 0 ? 1 : -1;
for (let i = 0; i < Math.abs(lines); i++) {
screen.dispatchEvent(
new WheelEvent("wheel", {
deltaY: dir * ch,
deltaMode: 0,
clientX: lastX,
clientY: y,
bubbles: true,
cancelable: true,
}),
);
}
e.preventDefault();
} else if (term.buffer.active.type !== "alternate") {
term.scrollLines(lines);
e.preventDefault();
}
},
{ passive: false },
);
}
}
// Mobile: the special-key bar is only useful while the soft keyboard is up,
// so reveal it only when the terminal's hidden textarea is focused (CSS still
// gates it to coarse pointers). Tapping a keycap keeps focus via pointerdown
// preventDefault, so the bar stays put during use. Re-fit on the toggle since
// showing/hiding the bar changes the terminal's available height.
const ta = term.textarea;
if (ta) {
ta.addEventListener("focus", () => {
keybar.el.classList.add("keybar-active");
requestAnimationFrame(tryFit);
});
ta.addEventListener("blur", () => {
keybar.el.classList.remove("keybar-active");
requestAnimationFrame(tryFit);
});
}
let mounted = false;
// A previewed pane is visible but must not steal the keyboard from the
// sidebar, so mounting can opt out of the focus grab (and out of the one
// the socket does when it finishes connecting).
let focusOnMount = true;
// We compute the fit ourselves instead of FitAddon.fit(): the addon subtracts
// a scrollbar width that xterm reports as `offsetWidth - scrollArea || 15`,
// so with the scrollbar hidden it falls back to a phantom 15px and the grid
// stops ~1–2 columns short of the edge. Same math, minus that subtraction.
const tryFit = () => {
if (!mounted) return; // parked views have no meaningful size to fit to
try {
const el2 = term.element;
const parent = el2?.parentElement;
const core = (term as unknown as {
_core?: { _renderService?: { dimensions?: { css?: { cell?: { width: number; height: number } } } } };
})._core;
const cell = core?._renderService?.dimensions?.css?.cell;
if (!el2 || !parent || !cell || !cell.width || !cell.height) {
fit.fit(); // fall back if internals aren't ready yet
return;
}
// Measure the viewport's real inner width (clientWidth excludes any
// scrollbar), not the container's — so columns always fit inside the
// viewport whether or not xterm reserves a scrollbar. Falls back to the
// padded container box before the viewport exists.
const vp = el2.querySelector(".xterm-viewport") as HTMLElement | null;
let availW: number;
let availH: number;
if (vp && vp.clientWidth > 0) {
availW = vp.clientWidth;
availH = vp.clientHeight;
} else {
const ps = getComputedStyle(parent);
const ts = getComputedStyle(el2);
availW =
parseInt(ps.width) -
(parseInt(ts.paddingLeft) + parseInt(ts.paddingRight) || 0);
availH =
parseInt(ps.height) -
(parseInt(ts.paddingTop) + parseInt(ts.paddingBottom) || 0);
}
const cols = Math.max(2, Math.floor(availW / cell.width));
const rows = Math.max(1, Math.floor(availH / cell.height));
if (cols !== term.cols || rows !== term.rows) term.resize(cols, rows);
} catch {
try {
fit.fit();
} catch {
// ignore — fit fails if the container is detached or zero-sized
}
}
};
let ws: WebSocket;
let reconnectMs = INITIAL_RECONNECT_MS;
let reconnectTimer: number | null = null;
const sendControl = (msg: ClientControl) => {
if (ws.readyState !== WebSocket.OPEN) return;
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 });
};
function connect(): void {
ws = new WebSocket(attachWsUrl(sessionId));
ws.binaryType = "arraybuffer";
ws.onopen = () => {
reconnectMs = INITIAL_RECONNECT_MS;
setState("connected", "open");
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
if (mounted && focusOnMount) term.focus();
};
ws.onmessage = (ev) => {
if (typeof ev.data === "string") {
try {
JSON.parse(ev.data) as ClientControl;
} catch {
// ignore unknown control frames
}
return;
}
if (ev.data instanceof ArrayBuffer) {
term.write(new Uint8Array(ev.data));
} else if (ev.data instanceof Blob) {
// Some browsers may default to Blob despite binaryType="arraybuffer".
ev.data.arrayBuffer().then((b) => term.write(new Uint8Array(b)));
}
};
ws.onerror = () => {
setState("error", "error");
};
ws.onclose = (ev) => {
// The web proxy relays a daemon rejection (unknown session) as an
// application close code (4000+) carrying a human reason. Show it in the
// pane — both the header and the terminal body — and KEEP the pane up so
// the user can read why, instead of letting it flash and vanish (#39).
const reason = ev.reason?.trim();
if (ev.code >= 4000 && reason && !disposed) {
setState(reason, "error");
term.write(`\r\n\x1b[31m⚠ cannot attach: ${reason}\x1b[0m\r\n`);
return;
}
// An intentional dispose has already set `disposed`; eviction and user
// close handle their own pane teardown.
if (disposed) return;
if (opts.shouldReconnect?.(sessionId, ev.code)) {
const delay = Math.min(reconnectMs, MAX_RECONNECT_MS);
setState("reconnecting…", "connecting");
reconnectTimer = window.setTimeout(() => {
reconnectTimer = null;
reconnectMs = Math.min(reconnectMs * 2, MAX_RECONNECT_MS);
connect();
}, delay);
return;
}
setState(`closed (${ev.code})`, "closed");
opts.onClosed?.(sessionId);
};
}
connect();
// 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) => {
sendInput(keybar.consume(data));
});
const resizeDisp = term.onResize(({ cols, rows }) => {
sendControl({ type: "Resize", cols, rows });
});
window.addEventListener("resize", onResize);
const visual = window.visualViewport ?? null;
visual?.addEventListener("resize", onResize);
// FitAddon only recomputes on the events we call it from — a window resize
// misses container-driven changes (sidebar drag, mobile layout settling,
// orientation, late font load). Observe the body directly so columns always
// match the real rendered width and the terminal fills edge to edge.
const ro = new ResizeObserver(() => tryFit());
ro.observe(body);
// Re-fit once the monospace font is actually loaded — cell width changes when
// the fallback is swapped out, which otherwise leaves stale columns.
document.fonts?.ready?.then(() => tryFit()).catch(() => {});
let disposed = false;
return {
sessionId,
el,
onMount(mountOpts?: { focus?: boolean }) {
mounted = true;
focusOnMount = mountOpts?.focus !== false;
acquireWebgl();
// Defer a tick so the element has its content-pane dimensions before fit.
requestAnimationFrame(() => {
tryFit();
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
if (focusOnMount && ws.readyState === WebSocket.OPEN) term.focus();
});
},
onUnmount() {
mounted = false;
// Parked views hand their GPU context back: the browser's cap is small
// (smaller still on Android) and MAX_PARKED connections outlive the pane.
releaseWebgl();
},
focusTerminal() {
term.focus();
},
// The closure above: the same bytes-out path keystrokes and the mobile key
// bar already take.
sendInput,
dispose() {
if (disposed) return;
disposed = true;
if (reconnectTimer !== null) {
window.clearTimeout(reconnectTimer);
reconnectTimer = null;
}
ro.disconnect();
window.removeEventListener("resize", onResize);
visual?.removeEventListener("resize", onResize);
dataDisp.dispose();
resizeDisp.dispose();
releaseWebgl();
try {
ws.close();
} catch {
// ignore
}
term.dispose();
el.remove();
},
};
}
/**
* One key in the mobile key bar.
* - `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";
/** CSI final bytes: arrows A–D, Home `H`, End `F`. */
type CursorFinal = "A" | "B" | "C" | "D" | "H" | "F";
/**
* The keys the mobile soft keyboard hides, ordered to flow row-major into an
* 8-column / 2-row grid (#7):
*
* Esc Tab Home ↑ End PgUp - /
* Ctrl Alt ← ↓ → PgDn | ~
*
* — 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" },
{ label: "Tab", seq: "\t" },
{ 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: "~" },
];
/**
* Fold a single character into its control code: letters and `@[\]^_` map to
* 0x00–0x1f, space → NUL, `?` → DEL. Anything else is returned unchanged.
*/
interface KeyBar {
readonly el: HTMLElement;
/** Fold pending modifiers into typed input (soft keyboard), consuming them. */
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 and Alt are one-shot sticky modifiers: arm them and the very next key —
* tapped here or typed on the soft keyboard — carries the modifier, then they
* disarm. 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");
const armed: Record<Modifier, boolean> = { ctrl: false, alt: false };
const modBtns: Partial<Record<Modifier, HTMLButtonElement>> = {};
const setMod = (mod: Modifier, on: boolean) => {
armed[mod] = on;
modBtns[mod]?.setAttribute("aria-pressed", String(on));
};
const clearMods = () => {
setMod("ctrl", false);
setMod("alt", false);
};
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", `${def.label} modifier`);
modBtns[def.mod] = btn;
btn.addEventListener("click", () => setMod(def.mod, !armed[def.mod]));
} else if ("cursor" in def) {
btn.addEventListener("click", () => {
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);
clearMods();
});
}
// Keep the terminal focused (soft keyboard up) — don't let the tap steal it.
// preventDefault suppresses the native `:active` style, so flash an explicit
// press animation here for tactile feedback.
btn.addEventListener("pointerdown", (e) => {
e.preventDefault();
btn.classList.remove("is-pressed");
void btn.offsetWidth; // restart the animation if re-tapped mid-flash
btn.classList.add("is-pressed");
});
btn.addEventListener("animationend", () => btn.classList.remove("is-pressed"));
el.appendChild(btn);
}
return {
el,
consume(data: string): string {
if ((armed.ctrl || armed.alt) && data.length === 1) {
const out = applyModsToChar(data, armed.ctrl, armed.alt);
clearMods();
return out;
}
return data;
},
};
}