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:
90
frontend/src/features/web/TerminalKeyBar.tsx
Normal file
90
frontend/src/features/web/TerminalKeyBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user