feat(frontend): terminal pilotable au doigt sur téléphone (#69)

Lots 3 et 4 du ticket #69. Sans ça un agent CLI est indriveable depuis un
téléphone : le clavier virtuel n'a ni Esc, ni Tab, ni Ctrl, ni flèches, donc
on peut taper un prompt mais pas l'interrompre, compléter un chemin, sortir
d'un éditeur ou rappeler l'historique.

- `TerminalView` expose un `onReady(api)` optionnel (donc desktop inchangé,
  et inerte quand xterm ne monte pas). `api.send` passe par `term.input()` :
  le *même* chemin qu'une frappe réelle, donc le relais PTY, le comptage de
  lignes et la suspension du write-portal s'appliquent à l'identique. Écrire
  sur le handle aurait court-circuité le portal.
- `TerminalKeyBar` (web-only) : Esc/Tab/Ctrl-C/Ctrl-D/flèches en chips
  tactiles 44px, scroll horizontal. Chaque tap annule le déplacement de
  focus et refocalise xterm — sinon le clavier virtuel se referme à chaque
  touche.
- La cellule agent passe de `h-64` fixe (une letterbox d'~20 lignes) à
  `h-[60dvh]` sur téléphone, `sm:h-80` au-delà.
- Tests : séquences d'octets émises, préservation du focus, état désactivé
  avant montage de xterm, et garde de non-régression sur la frontière —
  le client web ne monte aucun layout-grid/split/dock desktop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 13:28:09 +02:00
parent 48b8853214
commit 62ecefe1e8
7 changed files with 354 additions and 10 deletions

View File

@ -0,0 +1,90 @@
/**
* Mobile key toolbar for the web terminal — ticket #69, lot 3.
*
* A phone's virtual keyboard has no Esc, Tab, Ctrl or arrow keys, so on a
* touch device a CLI agent is effectively undrivable: you can type a prompt but
* you cannot interrupt it (Ctrl-C), complete a path (Tab), leave an editor
* (Esc) or recall history (↑). This bar renders those keys as tappable chips.
*
* Two details carry the whole design:
*
* - **Focus must not move.** Tapping a chip would normally blur xterm's hidden
* textarea, which collapses the virtual keyboard — so every tap would cost
* the user their keyboard. Each chip therefore cancels the default
* pointer-down focus shift and re-focuses the terminal, keeping the keyboard
* up across taps.
* - **Keys are injected, not written.** `send` goes through
* {@link TerminalInputApi.send} (xterm's `input()`), the same path a typed
* key takes, so the PTY relay and the agent write-portal behave identically
* to real typing.
*
* It is web-only presentation: it holds no transport, and is rendered by
* {@link WebAgentCell} below the terminal.
*/
import type { TerminalInputApi } from "@/features/terminals";
import { Button, cn } from "@/shared";
interface TerminalKeyBarProps {
/** Input API of the mounted terminal; `null` until xterm reports ready. */
api: TerminalInputApi | null;
className?: string;
}
/** A key chip: what the user reads, and the bytes a real keyboard would emit. */
interface KeyChip {
label: string;
/** Accessible name, when the glyph alone is not speakable (arrows). */
aria?: string;
/** The exact sequence a physical key sends (xterm consumes it verbatim). */
data: string;
}
const KEYS: readonly KeyChip[] = [
{ label: "Esc", data: "\x1b" },
{ label: "Tab", data: "\t" },
{ label: "Ctrl-C", aria: "Ctrl-C (interrompre)", data: "\x03" },
{ label: "Ctrl-D", aria: "Ctrl-D (fin de saisie)", data: "\x04" },
{ label: "↑", aria: "Flèche haut", data: "\x1b[A" },
{ label: "↓", aria: "Flèche bas", data: "\x1b[B" },
{ label: "←", aria: "Flèche gauche", data: "\x1b[D" },
{ label: "→", aria: "Flèche droite", data: "\x1b[C" },
];
export function TerminalKeyBar({ api, className }: TerminalKeyBarProps) {
return (
<div
role="toolbar"
aria-label="Touches du terminal"
data-testid="terminal-key-bar"
// Scrolls horizontally rather than wrapping: the chips stay on one line at
// any width, so the bar's height never changes and can't shove the
// terminal around while the keyboard is open.
className={cn(
"flex shrink-0 items-center gap-1 overflow-x-auto border-t border-border bg-raised/40 px-2 py-1.5",
className,
)}
>
{KEYS.map((key) => (
<Button
key={key.label}
size="sm"
variant="secondary"
aria-label={key.aria ?? key.label}
disabled={!api}
// Keep the virtual keyboard up: cancel the focus shift the tap would
// otherwise cause, then hand focus back to the terminal explicitly.
onPointerDown={(e) => e.preventDefault()}
onClick={() => {
api?.send(key.data);
api?.focus();
}}
// 44px touch target (min-h-11) — below that these are hard to hit.
className="min-h-11 shrink-0 font-mono"
>
{key.label}
</Button>
))}
</div>
);
}