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,76 @@
/**
* #69 lot 3 — the mobile key toolbar.
*
* Guards the two properties that make the terminal drivable from a phone:
* the chips emit the exact byte sequences a physical key would, and tapping one
* never steals focus from xterm (which would collapse the virtual keyboard).
*/
import { describe, it, expect, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import type { TerminalInputApi } from "@/features/terminals";
import { TerminalKeyBar } from "./TerminalKeyBar";
function mockApi(): TerminalInputApi & { sent: string[]; focused: () => number } {
const sent: string[] = [];
let focusCount = 0;
return {
sent,
focused: () => focusCount,
send: (d) => void sent.push(d),
focus: () => void focusCount++,
};
}
describe("TerminalKeyBar", () => {
it("sends the byte sequence a physical key would emit", () => {
const api = mockApi();
render(<TerminalKeyBar api={api} />);
fireEvent.click(screen.getByRole("button", { name: "Ctrl-C (interrompre)" }));
fireEvent.click(screen.getByRole("button", { name: "Esc" }));
fireEvent.click(screen.getByRole("button", { name: "Tab" }));
fireEvent.click(screen.getByRole("button", { name: "Flèche haut" }));
expect(api.sent).toEqual(["\x03", "\x1b", "\t", "\x1b[A"]);
});
it("emits the CSI sequences for every arrow key", () => {
const api = mockApi();
render(<TerminalKeyBar api={api} />);
for (const name of ["Flèche haut", "Flèche bas", "Flèche gauche", "Flèche droite"]) {
fireEvent.click(screen.getByRole("button", { name }));
}
expect(api.sent).toEqual(["\x1b[A", "\x1b[B", "\x1b[D", "\x1b[C"]);
});
it("keeps the virtual keyboard up: cancels the tap's focus shift, refocuses xterm", () => {
const api = mockApi();
render(<TerminalKeyBar api={api} />);
const ctrlC = screen.getByRole("button", { name: "Ctrl-C (interrompre)" });
// `fireEvent` returns false when the handler called preventDefault — that is
// what stops the browser blurring xterm's textarea and closing the keyboard.
expect(fireEvent.pointerDown(ctrlC)).toBe(false);
fireEvent.click(ctrlC);
expect(api.focused()).toBe(1);
});
it("renders disabled until the terminal reports its input API", () => {
render(<TerminalKeyBar api={null} />);
for (const button of screen.getAllByRole("button")) {
expect((button as HTMLButtonElement).disabled).toBe(true);
}
});
it("exposes the keys as an accessible toolbar", () => {
const api = mockApi();
render(<TerminalKeyBar api={api} />);
expect(screen.getByRole("toolbar", { name: "Touches du terminal" })).toBeTruthy();
});
});