feat(dashboard): mobile terminal input bar with sticky Ctrl accessory keys (U13)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-05 02:27:46 -07:00
parent e10db81393
commit 7a80d29d4c
5 changed files with 817 additions and 2 deletions

View File

@@ -0,0 +1,22 @@
---
"@runfusion/fusion": minor
---
Mobile terminal interaction for cli-agent sessions (U13). `SessionTerminal` now
detects mobile viewports via the canonical breakpoint
(`(max-width: 768px), (max-height: 480px)`) and renders a bottom input model in
place of relying on xterm's hidden-textarea (unreliable on mobile): a visible
text input that forwards typed text + `\r` as input frames on submit, plus an
accessory key bar emitting exact control sequences — Esc (`0x1B`), Tab (`0x09`),
a dedicated Ctrl-C (`0x03`), ANSI CSI cursor arrows (`CSI A/B/C/D`), and a sticky
Ctrl modifier whose next key combines into a control byte (Ctrl-C `0x03`,
Ctrl-D `0x04`, Ctrl-Z `0x1A`) with a visible active state.
Bar keys apply the iOS composer survival pattern (pointerdown/mousedown
preventDefault, action on click) so the input keeps focus, and the bar behaves as
a fixed footer that lifts above the virtual keyboard via `useMobileKeyboard`
(including its pinch-zoom `vv.scale > 1` guard, which is not treated as
keyboard-open). xterm `onData` input stays attached (the bar is primary, not
exclusive). Bar keys and the input are deliberate user keystrokes routed straight
to the session input path. All new strings are localized in the `app` i18n
catalog.

View File

@@ -163,3 +163,114 @@
background: var(--card);
border-color: var(--border);
}
/* ── Mobile input model (U13) ──────────────────────────────────────────────
* Visible input field + accessory key bar. xterm's hidden-textarea input is
* unreliable on mobile (KTD), so the bar is the primary input surface. The
* bar is a fixed footer that lifts above the virtual keyboard when it opens
* (driven by useMobileKeyboard's keyboardOverlap, applied inline).
*/
.cli-session-terminal__mobile-bar {
position: sticky;
bottom: 0;
z-index: 5;
display: flex;
flex-direction: column;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm)
calc(var(--space-sm) + env(safe-area-inset-bottom, 0px));
background: var(--surface);
border-top: 1px solid var(--border);
}
.cli-session-terminal__mobile-bar--keyboard-open {
position: fixed;
left: 0;
right: 0;
/* `bottom` is set inline to keyboardOverlap so the bar clears the keyboard. */
padding-bottom: var(--space-sm);
}
.cli-session-terminal__key-row {
display: flex;
gap: var(--space-xs);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.cli-session-terminal__key-row::-webkit-scrollbar {
display: none;
}
.cli-terminal-key {
flex: 0 0 auto;
min-width: 40px;
min-height: 36px;
padding: var(--space-xs) var(--space-sm);
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
font-size: 0.8125rem;
color: var(--text);
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
touch-action: manipulation;
}
.cli-terminal-key:active {
background: var(--card-hover);
}
.cli-terminal-key--ctrl.cli-terminal-key--active {
color: var(--accent-text, var(--button-primary-text));
background: var(--accent, var(--color-primary));
border-color: var(--accent, var(--color-primary));
}
.cli-terminal-key--ctrlc {
color: var(--warning, var(--color-warning));
border-color: var(--warning, var(--color-warning));
}
.cli-session-terminal__input-row {
display: flex;
gap: var(--space-xs);
}
.cli-session-terminal__mobile-input {
flex: 1 1 auto;
min-width: 0;
min-height: 38px;
padding: var(--space-xs) var(--space-sm);
font-size: 16px; /* >=16px avoids iOS focus zoom */
color: var(--text);
background: var(--input-bg, var(--bg));
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.cli-session-terminal__mobile-input:focus {
outline: none;
border-color: var(--accent, var(--color-primary));
}
.cli-session-terminal__mobile-send {
flex: 0 0 auto;
min-height: 38px;
padding: var(--space-xs) var(--space-md);
font-size: 0.8125rem;
color: var(--button-primary-text, var(--accent-text));
background: var(--button-primary-bg, var(--accent));
border: 1px solid transparent;
border-radius: var(--radius-sm);
cursor: pointer;
touch-action: manipulation;
}
/* On mobile the terminal viewport is read-mostly; the bar drives input. */
@media (max-width: 768px), (max-height: 480px) {
.cli-session-terminal__viewport {
-webkit-overflow-scrolling: touch;
}
}

View File

@@ -6,6 +6,7 @@ import { Terminal as TerminalIcon, ShieldAlert, Settings, Eye } from "lucide-rea
import type { Terminal as XTerm, ITerminalAddon } from "@xterm/xterm";
import { appendTokenQuery } from "../auth";
import { api } from "../api";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
/**
* SessionTerminal (CLI Agent Executor, U11) — shared xterm terminal for a CLI
@@ -26,6 +27,70 @@ import { api } from "../api";
const ACK_THRESHOLD_BYTES = 32 * 1024;
const RESIZE_DEBOUNCE_MS = 100;
/**
* Canonical mobile breakpoint (matches the repo CSS convention). Landscape
* phones exceed 768px wide, so the height clause covers them too.
*/
const MOBILE_MEDIA_QUERY = "(max-width: 768px), (max-height: 480px)";
/**
* Control sequences emitted by the accessory key bar (U13). These are
* deliberate user keystrokes routed straight to the session input path —
* exempt from U2's injected-text neutralization (which governs composed /
* injected strings, not real keystrokes).
*/
const SEQ_ESC = "\x1b"; // 0x1B
const SEQ_TAB = "\x09"; // 0x09
const SEQ_CTRL_C = "\x03"; // 0x03
const SEQ_ARROW_UP = "\x1b[A"; // CSI A
const SEQ_ARROW_DOWN = "\x1b[B"; // CSI B
const SEQ_ARROW_RIGHT = "\x1b[C"; // CSI C
const SEQ_ARROW_LEFT = "\x1b[D"; // CSI D
/**
* Resolve the control byte for a sticky-Ctrl + key combination. Ctrl maps a
* letter to its control code (A→0x01 … Z→0x1A): code = (toUpper(ch) & 0x1f).
* Returns null for keys that have no meaningful Ctrl combination.
*/
function ctrlCombo(key: string): string | null {
if (key.length !== 1) return null;
const upper = key.toUpperCase();
const code = upper.charCodeAt(0);
if (code >= 0x40 && code <= 0x5f) {
// @ A-Z [ \ ] ^ _ → 0x00-0x1F
return String.fromCharCode(code & 0x1f);
}
return null;
}
/** Reactive mobile-viewport detection via the repo breakpoint convention. */
function useIsMobileViewport(): boolean {
const [isMobile, setIsMobile] = useState<boolean>(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
return false;
}
return window.matchMedia(MOBILE_MEDIA_QUERY).matches;
});
useEffect(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
return;
}
const mql = window.matchMedia(MOBILE_MEDIA_QUERY);
const onChange = () => setIsMobile(mql.matches);
onChange();
// Safari < 14 only has addListener/removeListener.
if (typeof mql.addEventListener === "function") {
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}
mql.addListener(onChange);
return () => mql.removeListener(onChange);
}, []);
return isMobile;
}
/** The posture surfaced on the session record (denormalized at launch, U15). */
export interface SessionTerminalPosture {
/** Adapter display name (single Terminal icon for all adapters). */
@@ -109,6 +174,86 @@ export function SessionTerminal({
const [advanceDismissed, setAdvanceDismissed] = useState(false);
const [advancePending, setAdvancePending] = useState(false);
// ── Mobile input model (U13) ───────────────────────────────────────────────
const isMobile = useIsMobileViewport();
// Only arm keyboard tracking on mobile (the hook no-ops off-mobile anyway).
const { keyboardOpen, keyboardOverlap } = useMobileKeyboard({ enabled: isMobile });
const inputRef = useRef<HTMLInputElement | null>(null);
const [mobileInput, setMobileInput] = useState("");
// Sticky Ctrl: tap Ctrl, then the next tapped key combines into a control
// sequence (Ctrl-C → 0x03, Ctrl-D → 0x04, Ctrl-Z → 0x1A).
const [ctrlSticky, setCtrlSticky] = useState(false);
/** Write raw bytes to the session input path (mobile bar + submit). */
const sendInput = useCallback((data: string) => {
if (!data) return;
const ws = wsRef.current;
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "input", data }));
}
}, []);
/**
* Emit one accessory-bar key. If sticky Ctrl is active and the key has a
* Ctrl combination, send the combined control byte and clear the modifier;
* otherwise send the literal sequence. Keeps the input focused (the caller's
* pointerdown preventDefault stops the blur).
*/
const emitBarKey = useCallback(
(seq: string) => {
if (ctrlSticky) {
const combined = ctrlCombo(seq);
setCtrlSticky(false);
if (combined) {
sendInput(combined);
return;
}
}
sendInput(seq);
},
[ctrlSticky, sendInput],
);
/** iOS composer pattern: keep focus on the visible input when tapping a key. */
const keepFocus = useCallback((e: { preventDefault: () => void }) => {
e.preventDefault();
}, []);
const handleMobileSubmit = useCallback(
(e?: { preventDefault?: () => void }) => {
e?.preventDefault?.();
// User-typed text + Enter — deliberate input, no neutralization.
if (mobileInput) sendInput(mobileInput);
sendInput("\r");
setMobileInput("");
},
[mobileInput, sendInput],
);
/**
* Input onChange. When sticky Ctrl is armed, the next typed character is
* captured as a Ctrl combination (Ctrl-D `0x04`, Ctrl-Z `0x1A`, …) instead of
* landing in the field — this is how Ctrl-letter chords beyond the bar's
* dedicated Ctrl-C are reached on mobile. Otherwise the value updates
* normally for free-text + Enter submit.
*/
const handleMobileInputChange = useCallback(
(next: string) => {
if (ctrlSticky && next.length > mobileInput.length) {
// The newly-typed character is the last one appended.
const ch = next.slice(mobileInput.length, mobileInput.length + 1);
const combined = ctrlCombo(ch);
setCtrlSticky(false);
if (combined) {
sendInput(combined);
return; // swallow — do not echo the raw key into the field
}
}
setMobileInput(next);
},
[ctrlSticky, mobileInput, sendInput],
);
// Re-arm the strip whenever a fresh idle window is offered.
useEffect(() => {
if (showConfirmAdvance) setAdvanceDismissed(false);
@@ -323,7 +468,15 @@ export function SessionTerminal({
const flagSummary = posture?.elevatedFlags?.join(", ");
return (
<div className="cli-session-terminal" data-mode={mode} data-read-only={readOnly}>
<div
className={`cli-session-terminal${isMobile ? " cli-session-terminal--mobile" : ""}${
isMobile && keyboardOpen ? " cli-session-terminal--keyboard-open" : ""
}`}
data-mode={mode}
data-read-only={readOnly}
data-mobile={isMobile}
data-keyboard-open={isMobile && keyboardOpen}
>
<header className="cli-session-terminal__header">
{posture && (
<div className="cli-session-terminal__posture-wrap">
@@ -424,6 +577,154 @@ export function SessionTerminal({
</div>
</div>
)}
{isMobile && !readOnly && (
<div
className={`cli-session-terminal__mobile-bar${
keyboardOpen ? " cli-session-terminal__mobile-bar--keyboard-open" : ""
}`}
data-testid="cli-terminal-mobile-bar"
style={
// Lift the fixed footer above the virtual keyboard when it's open.
keyboardOpen ? { bottom: `${keyboardOverlap}px` } : undefined
}
>
<div
className="cli-session-terminal__key-row"
data-testid="cli-terminal-key-bar"
>
<button
type="button"
className={`cli-terminal-key cli-terminal-key--ctrl${
ctrlSticky ? " cli-terminal-key--active" : ""
}`}
data-testid="cli-key-ctrl"
aria-label={t("cliTerminal.mobileKeyCtrl", "Sticky Ctrl modifier")}
aria-pressed={ctrlSticky}
onPointerDown={keepFocus}
onMouseDown={keepFocus}
onClick={() => setCtrlSticky((v) => !v)}
>
Ctrl
</button>
<button
type="button"
className="cli-terminal-key"
data-testid="cli-key-esc"
aria-label={t("cliTerminal.mobileKeyEsc", "Send Escape")}
onPointerDown={keepFocus}
onMouseDown={keepFocus}
onClick={() => emitBarKey(SEQ_ESC)}
>
Esc
</button>
<button
type="button"
className="cli-terminal-key"
data-testid="cli-key-tab"
aria-label={t("cliTerminal.mobileKeyTab", "Send Tab")}
onPointerDown={keepFocus}
onMouseDown={keepFocus}
onClick={() => emitBarKey(SEQ_TAB)}
>
Tab
</button>
<button
type="button"
className="cli-terminal-key cli-terminal-key--ctrlc"
data-testid="cli-key-ctrl-c"
aria-label={t("cliTerminal.mobileKeyCtrlC", "Send Ctrl-C")}
onPointerDown={keepFocus}
onMouseDown={keepFocus}
onClick={() => {
// Dedicated shortcut: always Ctrl-C, regardless of sticky state.
setCtrlSticky(false);
sendInput(SEQ_CTRL_C);
}}
>
^C
</button>
<button
type="button"
className="cli-terminal-key"
data-testid="cli-key-arrow-up"
aria-label={t("cliTerminal.mobileKeyArrowUp", "Cursor up")}
onPointerDown={keepFocus}
onMouseDown={keepFocus}
onClick={() => emitBarKey(SEQ_ARROW_UP)}
>
↑
</button>
<button
type="button"
className="cli-terminal-key"
data-testid="cli-key-arrow-down"
aria-label={t("cliTerminal.mobileKeyArrowDown", "Cursor down")}
onPointerDown={keepFocus}
onMouseDown={keepFocus}
onClick={() => emitBarKey(SEQ_ARROW_DOWN)}
>
↓
</button>
<button
type="button"
className="cli-terminal-key"
data-testid="cli-key-arrow-left"
aria-label={t("cliTerminal.mobileKeyArrowLeft", "Cursor left")}
onPointerDown={keepFocus}
onMouseDown={keepFocus}
onClick={() => emitBarKey(SEQ_ARROW_LEFT)}
>
←
</button>
<button
type="button"
className="cli-terminal-key"
data-testid="cli-key-arrow-right"
aria-label={t("cliTerminal.mobileKeyArrowRight", "Cursor right")}
onPointerDown={keepFocus}
onMouseDown={keepFocus}
onClick={() => emitBarKey(SEQ_ARROW_RIGHT)}
>
→
</button>
</div>
<form
className="cli-session-terminal__input-row"
onSubmit={handleMobileSubmit}
>
<input
ref={inputRef}
type="text"
className="cli-session-terminal__mobile-input"
data-testid="cli-terminal-mobile-input"
value={mobileInput}
placeholder={t(
"cliTerminal.mobileInputPlaceholder",
"Type to send to the session…",
)}
autoCapitalize="off"
autoCorrect="off"
autoComplete="off"
spellCheck={false}
onChange={(e) => handleMobileInputChange(e.target.value)}
/>
<button
type="submit"
className="cli-session-terminal__mobile-send"
data-testid="cli-terminal-mobile-send"
aria-label={t("cliTerminal.mobileSend", "Send")}
// iOS pattern: act on click, preventDefault on pointer/mouse down
// so the input doesn't blur (which dismisses the keyboard).
onPointerDown={keepFocus}
onMouseDown={keepFocus}
onClick={() => handleMobileSubmit()}
>
{t("cliTerminal.mobileSend", "Send")}
</button>
</form>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,371 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { act, render, screen, fireEvent, waitFor } from "@testing-library/react";
import { _resetInitialViewportHeight } from "../../hooks/useMobileKeyboard";
// ── Mock xterm + addon dynamic imports (jsdom has no canvas/WebGL) ──────────
const mockTerm = {
loadAddon: vi.fn(),
open: vi.fn(),
onData: vi.fn(),
write: vi.fn((_data: string, cb?: () => void) => cb?.()),
dispose: vi.fn(),
unicode: { activeVersion: "6" },
cols: 80,
rows: 24,
};
vi.mock("@xterm/xterm", () => ({ Terminal: vi.fn(() => mockTerm) }));
vi.mock("@xterm/addon-fit", () => ({ FitAddon: vi.fn(() => ({ fit: vi.fn() })) }));
vi.mock("@xterm/addon-unicode11", () => ({ Unicode11Addon: vi.fn(() => ({})) }));
vi.mock("@xterm/addon-webgl", () => ({
WebglAddon: vi.fn(() => ({ onContextLoss: vi.fn(), dispose: vi.fn() })),
}));
const apiMock = vi.fn();
vi.mock("../../api", () => ({ api: (...args: unknown[]) => apiMock(...args) }));
vi.mock("../../auth", () => ({ appendTokenQuery: (u: string) => u }));
// ── Minimal WebSocket stub ──────────────────────────────────────────────────
class FakeWS {
static instances: FakeWS[] = [];
static OPEN = 1;
readyState = 1;
onopen: (() => void) | null = null;
onmessage: ((e: { data: string }) => void) | null = null;
onclose: (() => void) | null = null;
onerror: (() => void) | null = null;
sent: string[] = [];
constructor(public url: string) {
FakeWS.instances.push(this);
}
send(d: string) {
this.sent.push(d);
}
close() {
this.readyState = 3;
}
}
(globalThis as unknown as { WebSocket: typeof FakeWS }).WebSocket = FakeWS;
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
observe() {}
disconnect() {}
};
// ── matchMedia mock: drive the mobile breakpoint convention ─────────────────
let matchMediaMatches = true;
function installMatchMedia(matches: boolean) {
matchMediaMatches = matches;
Object.defineProperty(window, "matchMedia", {
writable: true,
configurable: true,
value: vi.fn((query: string) => ({
matches: matchMediaMatches,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
}
import { SessionTerminal } from "../SessionTerminal";
/** Pull the parsed input frames a WS has sent. */
function inputFrames(ws: FakeWS): string[] {
return ws.sent
.map((raw) => JSON.parse(raw))
.filter((m) => m.type === "input")
.map((m) => m.data as string);
}
/** Render and wait for the WS attach channel to open. */
async function renderMobile(props: Record<string, unknown> = {}) {
const utils = render(<SessionTerminal sessionId="s1" {...props} />);
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
return { ...utils, ws: FakeWS.instances[0] };
}
beforeEach(() => {
FakeWS.instances = [];
mockTerm.onData.mockReset();
mockTerm.write.mockClear();
apiMock.mockReset();
apiMock.mockResolvedValue({ ticket: "tkt-1", expiresAt: "", readOnly: false });
installMatchMedia(true); // mobile by default
_resetInitialViewportHeight();
});
afterEach(() => {
vi.clearAllMocks();
});
describe("SessionTerminal (mobile)", () => {
it("renders the mobile input bar + accessory key bar on mobile viewports", async () => {
await renderMobile();
expect(screen.getByTestId("cli-terminal-mobile-bar")).toBeTruthy();
expect(screen.getByTestId("cli-terminal-key-bar")).toBeTruthy();
expect(screen.getByTestId("cli-terminal-mobile-input")).toBeTruthy();
});
it("does not render the mobile bar off-mobile (desktop breakpoint)", async () => {
installMatchMedia(false);
await renderMobile();
expect(screen.queryByTestId("cli-terminal-mobile-bar")).toBeNull();
});
it("does not render the mobile bar when read-only", async () => {
apiMock.mockResolvedValue({ ticket: "tkt-1", expiresAt: "", readOnly: true });
await renderMobile({ readOnly: true });
expect(screen.queryByTestId("cli-terminal-mobile-bar")).toBeNull();
});
// ── Accessory bar control sequences ───────────────────────────────────────
it("Esc key emits 0x1b as an input frame", async () => {
const { ws } = await renderMobile();
fireEvent.click(screen.getByTestId("cli-key-esc"));
expect(inputFrames(ws)).toContain("\x1b");
});
it("Tab key emits 0x09", async () => {
const { ws } = await renderMobile();
fireEvent.click(screen.getByTestId("cli-key-tab"));
expect(inputFrames(ws)).toContain("\x09");
});
it("dedicated Ctrl-C shortcut emits 0x03", async () => {
const { ws } = await renderMobile();
fireEvent.click(screen.getByTestId("cli-key-ctrl-c"));
expect(inputFrames(ws)).toContain("\x03");
});
it("arrow keys emit ANSI CSI cursor sequences", async () => {
const { ws } = await renderMobile();
fireEvent.click(screen.getByTestId("cli-key-arrow-up"));
fireEvent.click(screen.getByTestId("cli-key-arrow-down"));
fireEvent.click(screen.getByTestId("cli-key-arrow-right"));
fireEvent.click(screen.getByTestId("cli-key-arrow-left"));
const frames = inputFrames(ws);
expect(frames).toContain("\x1b[A");
expect(frames).toContain("\x1b[B");
expect(frames).toContain("\x1b[C");
expect(frames).toContain("\x1b[D");
});
// ── Sticky Ctrl modifier ──────────────────────────────────────────────────
it("sticky Ctrl shows an active visual state", async () => {
await renderMobile();
const ctrl = screen.getByTestId("cli-key-ctrl");
expect(ctrl.getAttribute("aria-pressed")).toBe("false");
fireEvent.click(ctrl);
expect(ctrl.getAttribute("aria-pressed")).toBe("true");
expect(ctrl.className).toContain("cli-terminal-key--active");
// Tapping again toggles it back off.
fireEvent.click(ctrl);
expect(ctrl.getAttribute("aria-pressed")).toBe("false");
});
it("sticky Ctrl + c → 0x03, + d → 0x04, + z → 0x1a (combined, swallowed from field)", async () => {
const { ws } = await renderMobile();
const ctrl = screen.getByTestId("cli-key-ctrl");
const input = screen.getByTestId("cli-terminal-mobile-input") as HTMLInputElement;
// Ctrl + c
fireEvent.click(ctrl);
fireEvent.change(input, { target: { value: "c" } });
expect(input.value).toBe(""); // combined, not echoed
expect(ctrl.getAttribute("aria-pressed")).toBe("false"); // cleared
// Ctrl + d
fireEvent.click(ctrl);
fireEvent.change(input, { target: { value: "d" } });
// Ctrl + z
fireEvent.click(ctrl);
fireEvent.change(input, { target: { value: "z" } });
const frames = inputFrames(ws);
expect(frames).toContain("\x03"); // Ctrl-C
expect(frames).toContain("\x04"); // Ctrl-D
expect(frames).toContain("\x1a"); // Ctrl-Z
});
it("without sticky Ctrl, typed letters land in the field (no combine)", async () => {
await renderMobile();
const input = screen.getByTestId("cli-terminal-mobile-input") as HTMLInputElement;
fireEvent.change(input, { target: { value: "d" } });
expect(input.value).toBe("d");
});
it("sticky Ctrl + arrow does not combine (no ctrl combo) but clears modifier", async () => {
const { ws } = await renderMobile();
const ctrl = screen.getByTestId("cli-key-ctrl");
fireEvent.click(ctrl);
fireEvent.click(screen.getByTestId("cli-key-arrow-up"));
// Arrow has no Ctrl combo → literal CSI sequence is sent, modifier clears.
expect(inputFrames(ws)).toContain("\x1b[A");
expect(ctrl.getAttribute("aria-pressed")).toBe("false");
});
// ── Input field submit ────────────────────────────────────────────────────
it("submitting the input field sends the text then \\r", async () => {
const { ws } = await renderMobile();
const input = screen.getByTestId("cli-terminal-mobile-input") as HTMLInputElement;
fireEvent.change(input, { target: { value: "ls -la" } });
fireEvent.click(screen.getByTestId("cli-terminal-mobile-send"));
const frames = inputFrames(ws);
const idx = frames.indexOf("ls -la");
expect(idx).toBeGreaterThanOrEqual(0);
expect(frames[idx + 1]).toBe("\r");
// Field is cleared after submit.
expect(input.value).toBe("");
});
it("input is user keystrokes — text is forwarded verbatim (no neutralization)", async () => {
const { ws } = await renderMobile();
const input = screen.getByTestId("cli-terminal-mobile-input") as HTMLInputElement;
// An escape sequence typed by the user is sent verbatim (deliberate input).
fireEvent.change(input, { target: { value: "echo \x1b[31m" } });
fireEvent.click(screen.getByTestId("cli-terminal-mobile-send"));
expect(inputFrames(ws)).toContain("echo \x1b[31m");
});
// ── iOS composer pattern: bar keys do not blur the input ──────────────────
it("bar key pointerdown preventDefault keeps the input focused", async () => {
await renderMobile();
const esc = screen.getByTestId("cli-key-esc");
const pdEvent = new Event("pointerdown", { bubbles: true, cancelable: true });
esc.dispatchEvent(pdEvent);
expect(pdEvent.defaultPrevented).toBe(true);
});
it("send button pointerdown preventDefault keeps the input focused", async () => {
await renderMobile();
const send = screen.getByTestId("cli-terminal-mobile-send");
const pdEvent = new Event("pointerdown", { bubbles: true, cancelable: true });
send.dispatchEvent(pdEvent);
expect(pdEvent.defaultPrevented).toBe(true);
});
// ── AE6 mobile leg: same live bytes reach term.write ──────────────────────
it("mobile attach renders the same live session bytes (data → term.write)", async () => {
const { ws } = await renderMobile();
const b64 = Buffer.from("live-bytes", "utf8").toString("base64");
ws.onmessage?.({ data: JSON.stringify({ type: "data", data: b64 }) });
await waitFor(() =>
expect(mockTerm.write).toHaveBeenCalledWith("live-bytes", expect.any(Function)),
);
});
it("xterm onData input is still attached on mobile (bar is primary, not exclusive)", async () => {
await renderMobile();
expect(mockTerm.onData).toHaveBeenCalled();
});
});
// ── Keyboard-open (fixed-footer) + pinch-zoom guard ──────────────────────────
describe("SessionTerminal (mobile) — keyboard-open behavior", () => {
let savedVisualViewport: typeof window.visualViewport;
function installVisualViewport({
innerHeight,
vvHeight,
scale = 1,
vvOffsetTop = 0,
}: {
innerHeight: number;
vvHeight: number;
scale?: number;
vvOffsetTop?: number;
}) {
(window as unknown as { ontouchstart: unknown }).ontouchstart = null;
Object.defineProperty(navigator, "maxTouchPoints", { value: 5, configurable: true });
Object.defineProperty(window, "innerWidth", { value: 375, writable: true, configurable: true });
Object.defineProperty(window, "innerHeight", {
value: innerHeight,
writable: true,
configurable: true,
});
const listeners: Record<string, Array<() => void>> = { resize: [], scroll: [] };
const mockVV = {
width: 375,
height: vvHeight,
offsetTop: vvOffsetTop,
offsetLeft: 0,
scale,
addEventListener: vi.fn((event: string, cb: () => void) => {
listeners[event]?.push(cb);
}),
removeEventListener: vi.fn(),
};
Object.defineProperty(window, "visualViewport", {
value: mockVV,
writable: true,
configurable: true,
});
return { listeners, mockVV };
}
beforeEach(() => {
FakeWS.instances = [];
mockTerm.onData.mockReset();
apiMock.mockReset();
apiMock.mockResolvedValue({ ticket: "tkt-1", expiresAt: "", readOnly: false });
installMatchMedia(true);
_resetInitialViewportHeight();
savedVisualViewport = window.visualViewport;
});
afterEach(() => {
Object.defineProperty(window, "visualViewport", {
value: savedVisualViewport,
writable: true,
configurable: true,
});
_resetInitialViewportHeight();
vi.clearAllMocks();
});
it("keyboard-open applies the fixed-footer class so the bar is not occluded", async () => {
installVisualViewport({ innerHeight: 800, vvHeight: 600 });
const input = document.createElement("textarea");
document.body.appendChild(input);
input.focus();
render(<SessionTerminal sessionId="s1" />);
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
await waitFor(() => {
const bar = screen.getByTestId("cli-terminal-mobile-bar");
expect(bar.className).toContain("cli-session-terminal__mobile-bar--keyboard-open");
// Bar lifted above the keyboard by keyboardOverlap (800 - 600 = 200).
expect(bar.style.bottom).toBe("200px");
});
input.remove();
});
it("pinch-zoom (vv.scale > 1) is NOT treated as keyboard-open", async () => {
installVisualViewport({ innerHeight: 800, vvHeight: 600, scale: 2 });
const input = document.createElement("textarea");
document.body.appendChild(input);
input.focus();
render(<SessionTerminal sessionId="s1" />);
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
// Give the keyboard hook a beat to settle; it must stay closed.
await act(async () => {
await new Promise((r) => setTimeout(r, 60));
});
const bar = screen.getByTestId("cli-terminal-mobile-bar");
expect(bar.className).not.toContain("cli-session-terminal__mobile-bar--keyboard-open");
expect(bar.getAttribute("data-keyboard-open")).not.toBe("true");
input.remove();
});
});

View File

@@ -6914,6 +6914,16 @@
"adapterSettings": "Adapter settings",
"advancePrompt": "This session looks idle — advance to review?",
"advance": "Advance",
"notYet": "Not yet"
"notYet": "Not yet",
"mobileInputPlaceholder": "Type to send to the session…",
"mobileSend": "Send",
"mobileKeyEsc": "Send Escape",
"mobileKeyTab": "Send Tab",
"mobileKeyCtrlC": "Send Ctrl-C",
"mobileKeyCtrl": "Sticky Ctrl modifier",
"mobileKeyArrowUp": "Cursor up",
"mobileKeyArrowDown": "Cursor down",
"mobileKeyArrowLeft": "Cursor left",
"mobileKeyArrowRight": "Cursor right"
}
}