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

@ -95,6 +95,32 @@ interface TerminalViewProps {
* cells.
*/
portal?: WritePortal;
/**
* Called once, when xterm has mounted, with an imperative
* {@link TerminalInputApi} for this cell (#69). Optional and inert when
* absent, so the desktop path is unchanged; the web client uses it to drive
* the mobile key toolbar. Not called when xterm fails to mount (headless).
*/
onReady?: (api: TerminalInputApi) => void;
}
/**
* Imperative handle over a mounted terminal (#69), handed to the caller by
* {@link TerminalViewProps.onReady}.
*
* It exists because a phone's virtual keyboard has no Esc, Tab, Ctrl or arrow
* keys, so the web client renders a key toolbar that needs to inject them. Both
* methods deliberately go through xterm rather than the PTY handle:
* `send` routes through `term.input()` — the *same* path a real keystroke takes
* — so the PTY relay, the write-portal's line counting and its suspension gate
* all keep applying. Writing to the handle directly would bypass the portal and
* let an injected key race a delegation.
*/
export interface TerminalInputApi {
/** Inject `data` exactly as if the user had typed it. */
send: (data: string) => void;
/** Focus the terminal — on a phone this summons the virtual keyboard. */
focus: () => void;
}
export function TerminalView({
@ -105,6 +131,7 @@ export function TerminalView({
onSessionId,
agentMode = false,
portal,
onReady,
}: TerminalViewProps) {
const { terminal } = useGateways();
const containerRef = useRef<HTMLDivElement | null>(null);
@ -136,6 +163,8 @@ export function TerminalView({
agentModeRef.current = agentMode;
const portalRef = useRef(portal);
portalRef.current = portal;
const onReadyRef = useRef(onReady);
onReadyRef.current = onReady;
useEffect(() => {
const container = containerRef.current;
@ -194,6 +223,19 @@ export function TerminalView({
else pending += data;
});
// Publish the input API now that the keystroke relay above is live, so an
// injected key is handled exactly like a typed one. Guarded on `disposed`:
// the caller may still hold this object after the cell unmounts, and driving
// a disposed xterm throws.
onReadyRef.current?.({
send: (data) => {
if (!disposed) term.input(data);
},
focus: () => {
if (!disposed) term.focus();
},
});
const onData = (bytes: Uint8Array) => {
if (!disposed) term.write(bytes);
};