diff --git a/PLAN-TERMINAL-SELECTION-HANDLES-2026-07-10.md b/PLAN-TERMINAL-SELECTION-HANDLES-2026-07-10.md new file mode 100644 index 000000000000..1f5a5bd742d1 --- /dev/null +++ b/PLAN-TERMINAL-SELECTION-HANDLES-2026-07-10.md @@ -0,0 +1,364 @@ +# Plan — Poignées de sélection tactile Android dans le terminal mobile + +**Date** : 2026-07-10 +**Statut** : Draft prêt pour implémentation +**Périmètre** : `packages/app` + tests unitaires + validation Android device +**Dépendance** : conserver intacte la sélection tactile/copie déjà fonctionnelle et le correctif de désélection. + +## 1. Objectif UX + +Reproduire le comportement Android attendu : + +- appui long : mot sélectionné et surlignage Ghostty inchangé ; +- deux poignées visibles aux extrémités, forme goutte Android, couleur accent ; +- zone tactile minimale 48×48 CSS px, visuel plus petit centré ; +- glisser une poignée étend/réduit la sélection en temps réel ; +- croiser les poignées inverse le sens sans perdre la sélection ; +- les poignées suivent le texte pendant scroll, overscroll, resize clavier et rotation ; +- Copier conserve la sélection et les poignées ; +- tap simple, scroll normal et long-press existants ne régressent pas. + +Non-objectifs : sélectionner du texte DOM, modifier `ghostty-web`, reproduire un menu d’action Android complet, ou implémenter une seconde logique de segmentation de mots. + +## 2. Contraintes établies + +`ghostty-web` rend le terminal sur canvas. `SelectionManager` ne comprend que les événements souris et expose `getSelectionPosition()`, pas une API publique de sélection pixel-à-pixel. La sélection et son surlignage restent donc la responsabilité exclusive du canvas Ghostty. + +La dernière tentative a échoué parce qu’elle ajoutait un overlay Solid autonome avec son propre cycle d’état et des coordonnées approximatives. La nouvelle implémentation doit avoir une seule source de vérité : la géométrie doit être calculée par le contrôleur qui connaît le canvas, son `getBoundingClientRect()`, l’overscroll et les événements `onSelectionChange`. + +## 3. Architecture proposée + +Extraire les responsabilités de `terminal.tsx` (déjà ~1 500 lignes) : + +```text +Terminal.tsx + ├─ TerminalTouchController état pending/swipe/selecting/handle-drag + │ ├─ canvas MouseEvent bridge seul endroit qui parle à SelectionManager + │ └─ SelectionGeometry positions canvas + overscroll + resize + └─ TerminalSelectionOverlay rendu des poignées, sans logique de sélection +``` + +Fichiers : + +1. `packages/app/src/components/terminal-touch-controller.ts` + - état tactile existant déplacé sans changer son comportement ; + - long-press, swipe, blocage IME, désélection et drag des poignées ; + - `startHandleDrag`, `moveHandleDrag`, `endHandleDrag` ; + - aucun état global, dépendances injectées (`container`, `term`, `canvas`, callbacks). +2. `packages/app/src/components/terminal-selection-geometry.ts` + - fonctions pures testables ; + - conversion cellule → coordonnées client/overlay ; + - calcul des points d’ancrage début/fin ; + - prise en compte de `canvas.getBoundingClientRect()` et de l’offset d’overscroll fourni par le contrôleur. +3. `packages/app/src/components/terminal-selection-overlay.tsx` + - rendu uniquement ; + - racine `pointer-events:none` ; poignées seules en `pointer-events:auto` ; + - visuel SVG goutte Android et hit target 48×48 ; + - reçoit `positions`, `visible`, `onHandlePointerDown`. +4. `packages/app/src/components/terminal.tsx` + - wiring minimal du contrôleur et de l’overlay ; + - aucun calcul de coordonnées ni nouvelle machine tactile inline. +5. Tests associés dans `packages/app/src/components/terminal-selection-geometry.test.ts` et `terminal-touch-controller.test.ts`. + +## 4. Modèle de données + +```ts +type SelectionHandleSide = "start" | "end" +type SelectionHandlePoint = { + clientX: number + clientY: number + overlayLeft: number + overlayTop: number +} +type SelectionGeometry = { + start: SelectionHandlePoint + end: SelectionHandlePoint + visible: boolean + revision: number +} +type HandleDrag = { + side: SelectionHandleSide + pointerId: number + anchor: { x: number; y: number } +} +``` + +`SelectionGeometry` est dérivée de `term.getSelectionPosition()` à chaque changement de sélection et non stockée comme une seconde sélection. `HandleDrag.anchor` est toujours l’extrémité opposée relue au moment du pointerdown. + +## 5. Flux de rendu et synchronisation + +1. `term.onSelectionChange` programme une mise à jour via `requestAnimationFrame`. +2. Le contrôleur lit `getSelectionPosition()` et `getSelection()` ; si l’un est vide, il masque les poignées sans appeler `clearSelection()`. +3. Il lit le `canvas.getBoundingClientRect()` après le layout réel. Aucune approximation `container.width / cols` comme source primaire. +4. Il convertit `start/end` en CSS pixels. Pour le début, l’ancre est au bord gauche de la cellule ; pour la fin, au bord droit. +5. Le même offset d’overscroll que celui appliqué au canvas est fourni à la géométrie ; le canvas et l’overlay bougent donc ensemble. +6. Recalcul obligatoire sur : `onSelectionChange`, `ResizeObserver` du container/canvas, `visualViewport.resize`, scroll capturé, changement d’overscroll, fin de `fit()` et reprise après clavier. +7. Une seule frame pending est autorisée ; cleanup annule le RAF, les observers et les listeners. + +## 6. Drag d’une poignée + +### Pointerdown + +- Le hit-test reconnaît `[data-terminal-selection-handle]` dans `onPointerDownCapture` avant la machine `pending/swipe`. +- Le contrôleur ne déclenche ni `clearSelection`, ni focus textarea, ni scroll. +- `preventDefault()` + `stopPropagation()` ; `setPointerCapture(pointerId)` sur la poignée. +- Relire `getSelectionPosition()`. +- Pour la poignée `end`, l’ancre est `selection.start`. Pour `start`, l’ancre est `selection.end`. +- Dispatcher un `mousedown` synthétique sur le canvas à la cellule d’ancrage. Ne dispatcher aucun `click`. +- Conserver les poignées visibles pendant la phase intermédiaire où `SelectionManager` n’a pas encore produit la nouvelle sélection. + +### Pointermove + +- Vérifier `pointerId` et l’état `HandleDrag`. +- `preventDefault()` + `stopPropagation()` ; dispatcher un `mousemove` synthétique avec les coordonnées client réelles du doigt. +- Laisser `SelectionManager` calculer la cellule, l’auto-scroll et l’inversion ; son événement `onSelectionChange` met à jour le surlignage et les poignées. +- Si le doigt sort du viewport, le pointer capture maintient le drag ; le contrôleur conserve le clamp/auto-scroll déjà fourni par Ghostty. + +### Pointerup/cancel + +- Dispatcher exactement un `mouseup` si le drag est actif. +- Libérer le pointer capture, annuler le RAF pending et relire la géométrie. +- Ne jamais appeler `clearSelection()` dans ce chemin. +- `pointercancel` doit terminer proprement sans laisser `isSelecting` Ghostty actif. + +## 7. Visuel Android + +- SVG dédié, forme goutte : tête arrondie + pointe/stem vers la ligne sélectionnée ; pas un cercle et pas un simple carré CSS. +- Couleur issue du token d’accent de l’UI, avec contraste garanti sur le canvas. +- Visuel environ 24×32 CSS px, hit target 48×48 CSS px ; zone transparente autour du SVG. +- `touch-action:none`, `user-select:none`, `aria-label` distinct pour début/fin. +- Overlay `pointer-events:none` pour ne jamais masquer le canvas ; seules les deux poignées capturent les événements. +- Vérifier aux bords gauche/droit/bas : la poignée reste visible autant que possible sans modifier le canvas ni le clipping du terminal. + +## 8. Prévention des régressions + +Avant d’activer le rendu des poignées, livrer une étape intermédiaire qui ne change que l’extraction du contrôleur et prouve que le surlignage reste identique. + +Garde-fous obligatoires : + +- le chemin long-press continue à dispatcher `mousedown + click(detail=2)` exactement comme avant ; +- le chemin swipe ne touche jamais `SelectionManager` ; +- un pointerdown sur poignée sort avant `pending/swipe/selecting` ; +- aucun `clearSelection()` dans les chemins de poignée ; +- les listeners document existants ne voient pas les poignées comme un clic extérieur ; +- l’overlay ne devient jamais parent du canvas et ne remplace pas le DOM créé par `term.open()` ; +- un test visuel manuel doit valider le surlignage avant d’autoriser le drag. + +## 9. Tests automatisés + +### Géométrie pure + +- début et fin sur la même ligne ; +- sélection multi-lignes ; +- début/fin inversés ; +- canvas transformé par overscroll ; +- resize clavier et changement de `cols/rows` ; +- canvas partiellement hors viewport ; +- sélection absente → géométrie invisible. + +### Contrôleur + +- pointerdown poignée début ancre sur fin ; +- pointerdown poignée fin ancre sur début ; +- pointermove dispatcher dans l’ordre ; +- pointerup dispatcher une seule fois ; +- cancel libère l’état ; +- aucun `clearSelection` pendant un drag ; +- pointerdown poignée ne passe pas en mode swipe ; +- second pointer ignoré sans casser le premier. + +### Device Android — matrice de sortie + +1. appui long : mot surligné, clavier non rouvert ; +2. poignée fin : extension/réduction vers la droite et vers la gauche ; +3. poignée début : même vérification ; +4. croisement : inversion sans disparition du surlignage ; +5. scroll pendant sélection : poignées et surlignage restent alignés ; +6. clavier ouverture/fermeture et rotation : positions recalculées ; +7. Copier : texte copié, sélection et poignées conservées ; +8. tap simple/scroll normal : comportement v1 inchangé ; +9. changement d’onglet : aucune poignée d’un ancien terminal visible. + +## 10. Séquencement d’implémentation + +- [ ] Étape 0 — conserver l’APK connu bon et caractériser visuellement la sélection actuelle sur device. +- [x] Étape 1 — extraire `terminal-selection-geometry.ts` + tests purs. (4 tests passants) +- [x] Étape 2 — extraire le contrôleur tactile sans modifier les comportements ; typecheck + tests passants. +- [ ] Étape 3 — ajouter un overlay inerte (poignées non interactives) ; vérifier surlignage et synchronisation scroll. +- [ ] Étape 4 — activer pointer capture et drag de la poignée `end` seulement ; build/install/device. +- [ ] Étape 5 — ajouter poignée `start`, inversion et cancel ; build/install/device. +- [ ] Étape 6 — ajuster SVG, hit target, bords, clavier et rotation ; build/install/device. +- [ ] Étape 7 — mettre à jour handoff, tests, plan et mémoire ; proposer `/audio-validate` non pertinent ici, `/health` non pertinent car pas de projet Rust modifié. + +Chaque étape doit rester buildable et être validée avant la suivante. Aucun build ne doit combiner extraction, visuel et drag complet. + +## 11. Commandes de validation + +```powershell +cd D:\App\OpenCode\opencode\packages\app +bun typecheck +bunx biome check src/components/terminal.tsx src/components/terminal-touch-controller.ts src/components/terminal-selection-geometry.ts src/components/terminal-selection-overlay.tsx + +cd D:\App\OpenCode\opencode\packages\mobile +$env:TEMP='D:\App\OpenCode\.build-temp' +$env:TMP='D:\App\OpenCode\.build-temp' +$env:ORT_LIB_LOCATION='D:/tmp/ort-android' +bun tauri android build --target aarch64 +``` + +Signer l’APK unsigned avec `zipalign` puis `apksigner` et vérifier avant `adb install -r`. Toujours comparer l’heure de l’unsigned et du signed ; le précédent incident venait d’un APK signed antérieur au bundle frontend. + +## 12. Critères de sortie + +Le chantier est terminé seulement si : + +- aucun changement de surlignage par rapport à l’APK connu bon hors drag ; +- les deux poignées ont le visuel goutte et une zone tactile utilisable ; +- chaque poignée se déplace réellement et suit le texte ; +- scroll, overscroll, clavier et rotation ne désalignent pas l’overlay ; +- crossing/inversion, Copier, désélection et multi-onglets passent ; +- tests automatisés, typecheck et Biome passent ; +- test humain device consigné dans le handoff ; +- diff final ≤400 LOC par étape ou découpé en commits indépendants. + +## Décisions verrouillées + +- conserver `ghostty-web` et son `SelectionManager` comme unique moteur de sélection ; +- ne pas modifier le code tiers de `ghostty-web` ; +- poignées style goutte Android, pas cercles ; +- Copier ne désélectionne pas ; +- ne pas recommencer une implémentation overlay complète sans étape visuelle inerte et validation device ; +- pas de commit/push automatique inclus dans ce plan. +## 13. Amendements issus de la review croisée des IA — v2 + +### Verdict consolidé + +Les reviews convergent sur une note d’environ 8/10 : l’architecture séparation contrôleur/géométrie/overlay et le séquencement incrémental sont validés. Aucun avis ne recommande de réutiliser l’overlay autonome qui a régressé le produit. Les réserves portent toutes sur le pont d’événements, la géométrie exacte et le lifecycle mobile ; elles deviennent des gates obligatoires avant implémentation. + +### Faits vérifiés dans ghostty-web + +Lecture de `packages/app/node_modules/ghostty-web/lib/selection-manager.ts` : + +- les handlers `mousedown`/`mousemove`/`click` sont attachés au canvas ; +- les handlers canvas convertissent `e.offsetX/e.offsetY` en cellule ; +- le handler `document.mousemove` utilise `clientX/clientY` puis `canvas.getBoundingClientRect()` ; +- le handler `document.mouseup` termine `isSelecting`, copie et émet `onSelectionChange` ; +- `normalizeSelection()` normalise l’ordre et convertit les lignes absolues en lignes viewport clamped ; +- `getSelectionPosition()` retourne donc des coordonnées de cellules viewport, pas des pixels physiques : aucun facteur DPR ne doit être appliqué à ces valeurs. + +Conséquence : avant l’étape d’extraction, il faut vérifier sur le WebView réel que les `MouseEvent` synthétiques reçus sur le canvas exposent bien les `offsetX/offsetY` attendus. Si ce n’est pas le cas, le pont synthétique est bloqué et doit être adapté avant toute UI. + +### Gate 0 — PoC du pont synthétique, avant tout refactor`r`n`r`n**Résultat 2026-07-10** : validé sur WebView réel via CDP/ADB. `offsetX/offsetY` correspondent aux coordonnées client attendues et un double-clic synthétique déclenche la copie Ghostty (`ClipboardItem:1`). + +Ajouter temporairement une instrumentation dev-only, sans modification de comportement : + +1. sélectionner un mot avec le chemin actuel ; +2. dispatcher un `mousedown` synthétique à une cellule connue ; +3. observer sur le canvas `clientX/clientY`, `offsetX/offsetY`, `getBoundingClientRect()` et la cellule calculée ; +4. dispatcher `mousemove` puis `mouseup` ; +5. vérifier que le surlignage se déplace et que `onSelectionChange` est émis. + +Le PoC doit vérifier les champs complets : + +```ts +{ + bubbles: true, + cancelable: true, + view: window, + button: 0, + buttons: 1, // mousedown/mousemove + detail: 1, + clientX, + clientY, + screenX, + screenY, +} +``` + +Pour `mouseup`, `buttons: 0`. Conserver `lastClientX/lastClientY` afin que `pointercancel`, `lostpointercapture` et dispose puissent fermer le drag avec des coordonnées cohérentes. Le PoC est un stop-the-line : aucune extraction ni poignée interactive si la cellule synthétique ne correspond pas à la cellule attendue. + +### Snapshot géométrique atomique + +La géométrie ne doit jamais mélanger une sélection N avec un layout N-1. Chaque frame lit dans cet ordre : + +```text +getSelection() + getSelectionPosition() + ↓ +canvas.getBoundingClientRect() final + ↓ +revision layout/overscroll + ↓ +SelectionGeometry unique +``` + +`getSelectionPosition()` est en cellules viewport ; le canvas rect est en CSS px. Le DPR n’intervient pas dans la conversion. Utiliser le rect final transformé par CSS ou un rect non transformé + offset, jamais les deux : l’overscroll ne doit être compté qu’une fois. + +### HandleDrag renforcé + +```ts +type HandleDrag = { + activeSide: "start" | "end" + pointerId: number + anchorCell: { x: number; y: number } + lastClient: { x: number; y: number } + selectionRevision: number + mouseDownDispatched: boolean + mouseUpDispatched: boolean +} +``` + +`activeSide` reste attaché à la poignée physique saisie, même lorsque Ghostty inverse `start/end` après crossing. Le contrôleur ne bascule jamais l’identité du pointer vers l’autre poignée. + +### Lifecycle obligatoire + +La fin de drag est idempotente et centralisée dans `finishHandleDrag(reason)` : + +- `pointerup` ; +- `pointercancel` ; +- `lostpointercapture` ; +- dispose du terminal ; +- changement d’onglet ou perte de visibilité. + +Si `mouseDownDispatched && !mouseUpDispatched`, `finishHandleDrag` émet exactement un `mouseup`, puis libère la capture, annule le RAF et masque l’état actif. Les observers/listeners sont retirés systématiquement. + +### Scroll, auto-scroll et MIUI + +- `onSelectionChange` est une source primaire : elle couvre l’auto-scroll Ghostty même sans nouveau `pointermove` ; +- un RAF dirty unique coalesce toutes les sources et évite le layout thrashing ; +- `ResizeObserver`, fin de `fit()`, focus/blur textarea, `visualViewport.resize` et scroll sont des invalidations, pas des sources de vérité ; +- `visualViewport.resize` est un signal opportuniste sur MIUI, jamais l’unique signal ; +- le root overlay reste `pointer-events:none` ; seul le bouton poignée a `pointer-events:auto`, `touch-action:none` et `user-select:none` ; le container terminal conserve son scroll normal ; +- si un endpoint sort réellement du viewport après reflow clavier, la poignée est masquée ou clamped selon la géométrie Android définie, sans déplacer arbitrairement le canvas. + +### Crossing et spike technique + +Le crossing est validé avant la finition visuelle : après le PoC, un spike `end` déplace la poignée derrière `start`, puis vérifie que le surlignage reste présent, que `getSelectionPosition()` retourne le nouvel ordre normalisé et que le pointer physique actif continue son drag. Si le pont ne permet pas ce comportement, stopper et réviser le mécanisme d’ancrage plutôt que d’ajouter un état `isInverted` spéculatif. + +### Résultat spike 2026-07-10 + +Le spike CDP sur WebView réel a produit trois écritures clipboard : mot initial, extension `end`, puis crossing vers la gauche. Le pont synthétique et l’inversion Ghostty sont donc validés. La couverture `pointercancel/lostpointercapture` est reportée à l’étape interactive. +### Séquence v2 obligatoire + +- [ ] Gate 0 — instrumentation read-only `offsetX/offsetY`, listeners et rect. +- [x] Étape 1 — géométrie pure + tests de cellules viewport et rect transformé. (4 tests passants) +- [x] Étape 2 — extraction mécanique du contrôleur sans changement de comportement ; typecheck/Biome passants. +- [~] Étape 2.5 — spike synthétique end + crossing validés sur device ; cancel/lostcapture restent à valider avec la poignée. +- [ ] Étape 3 — overlay inerte, rect final et scroll/resize/overscroll ; validation du surlignage inchangé. +- [ ] Étape 4 — poignée end interactive, pointer capture et auto-scroll ; build/device isolé. +- [ ] Étape 5 — poignée start, crossing et changement de rôle physique ; build/device isolé. +- [ ] Étape 6 — SVG goutte, hit target 48×48 CSS px équivalent WebView 48 dp, clipping et contraste. +- [ ] Étape 7 — matrice complète clavier, rotation, tabs, copie, désélection, cancel et dispose. + +### Tests supplémentaires imposés + +- `offsetX/offsetY` synthétiques correspondant à la cellule cible ; +- champs `button/buttons/detail/bubbles/cancelable/view` exacts ; +- double overscroll impossible avec canvas transformé ; +- `pointercancel` et `lostpointercapture` n’abandonnent jamais `isSelecting` ; +- dispose pendant drag n’émet qu’un seul `mouseup` ; +- RAF, ResizeObserver, visualViewport et scroll listeners nettoyés ; +- overlay supprimé sur changement d’onglet et aucun ancien handle visible ; +- crossing maintient le pointer physique sur la poignée initialement saisie ; +- focus/blur et fit recalculent même si `visualViewport` ne notifie pas MIUI. + +Ces amendements remplacent les hypothèses implicites du plan initial. Le plan est prêt pour Gate 0, mais pas pour écrire le composant de poignées directement. diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 633d66650634..5d39b7bd52c2 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -477,8 +477,11 @@ export function SessionHeader() { - {/* FORK: Stretch Phase 6 — editor focus mode (tablet mode) */} - + {/* FORK: Stretch Phase 6 — editor focus mode (tablet mode). + sessionPanelWidth (session.tsx) only reacts to this when + isDesktop(), so on mobile the button toggles state with + no visible effect — hide it there. */} + { + const base = { + range: { start: { x: 2, y: 3 }, end: { x: 5, y: 4 } }, + canvasRect: { left: 24, top: 100, width: 300, height: 400 }, + containerRect: { left: 0, top: 80 }, + columns: 30, + rows: 40, + } + + test("maps viewport cells to CSS client coordinates", () => { + expect(selectionGeometry(base)).toEqual({ + start: { clientX: 49, clientY: 135, overlayLeft: 44, overlayTop: 60 }, + end: { clientX: 79, clientY: 145, overlayLeft: 84, overlayTop: 70 }, + }) + }) + + test("keeps direction-independent endpoints usable for crossing", () => { + const result = selectionGeometry({ ...base, range: { start: { x: 12, y: 8 }, end: { x: 4, y: 2 } } }) + expect(result?.start.overlayLeft).toBe(144) + expect(result?.end.overlayLeft).toBe(74) + }) + + test("uses the final transformed canvas rect exactly once", () => { + const result = selectionGeometry({ ...base, canvasRect: { left: 24, top: 76, width: 300, height: 400 } }) + expect(result?.start.overlayTop).toBe(36) + expect(result?.start.clientY).toBe(111) + }) + + test("rejects unusable layout metrics", () => { + expect(selectionGeometry({ ...base, columns: 0 })).toBeUndefined() + expect(selectionGeometry({ ...base, canvasRect: { ...base.canvasRect, width: 0 } })).toBeUndefined() + }) +}) \ No newline at end of file diff --git a/packages/app/src/components/terminal-selection-geometry.ts b/packages/app/src/components/terminal-selection-geometry.ts new file mode 100644 index 000000000000..b80746f7ac09 --- /dev/null +++ b/packages/app/src/components/terminal-selection-geometry.ts @@ -0,0 +1,42 @@ +export type SelectionHandleSide = "start" | "end" + +export type SelectionCell = { x: number; y: number } +export type SelectionRange = { start: SelectionCell; end: SelectionCell } +export type SelectionRect = { left: number; top: number; width: number; height: number } +export type SelectionHandlePoint = { clientX: number; clientY: number; overlayLeft: number; overlayTop: number } +export type SelectionGeometry = { start: SelectionHandlePoint; end: SelectionHandlePoint } +export type SelectionGeometryInput = { + range: SelectionRange + canvasRect: SelectionRect + containerRect: Pick + columns: number + rows: number +} + +export function selectionGeometry(input: SelectionGeometryInput): SelectionGeometry | undefined { + if (input.columns <= 0 || input.rows <= 0) return undefined + if (input.canvasRect.width <= 0 || input.canvasRect.height <= 0) return undefined + const cellWidth = input.canvasRect.width / input.columns + const cellHeight = input.canvasRect.height / input.rows + return { + start: handlePoint(input, input.range.start, "start", cellWidth, cellHeight), + end: handlePoint(input, input.range.end, "end", cellWidth, cellHeight), + } +} + +function handlePoint( + input: SelectionGeometryInput, + cell: SelectionCell, + side: SelectionHandleSide, + cellWidth: number, + cellHeight: number, +): SelectionHandlePoint { + const edgeX = cell.x + (side === "end" ? 1 : 0) + const bottomY = (cell.y + 1) * cellHeight + return { + clientX: input.canvasRect.left + (cell.x + 0.5) * cellWidth, + clientY: input.canvasRect.top + (cell.y + 0.5) * cellHeight, + overlayLeft: input.canvasRect.left - input.containerRect.left + edgeX * cellWidth, + overlayTop: input.canvasRect.top - input.containerRect.top + bottomY, + } +} \ No newline at end of file diff --git a/packages/app/src/components/terminal-touch-controller.ts b/packages/app/src/components/terminal-touch-controller.ts new file mode 100644 index 000000000000..bae38522f328 --- /dev/null +++ b/packages/app/src/components/terminal-touch-controller.ts @@ -0,0 +1,490 @@ +import type { Terminal as Term } from "ghostty-web" + +export const useTerminalUiBindings = (input: { + container: HTMLDivElement + term: Term + cleanups: VoidFunction[] + handlePointerDown: (e: PointerEvent) => void + handleLinkClick: (event: MouseEvent) => void +}) => { + const handleCopy = (event: ClipboardEvent) => { + const selection = input.term.getSelection() + if (!selection) return + + const clipboard = event.clipboardData + if (!clipboard) return + + event.preventDefault() + clipboard.setData("text/plain", selection) + } + + const handlePaste = (event: ClipboardEvent) => { + const clipboard = event.clipboardData + const text = clipboard?.getData("text/plain") ?? clipboard?.getData("text") ?? "" + if (!text) return + + event.preventDefault() + event.stopPropagation() + input.term.paste(text) + } + + const handleTextareaFocus = () => { + input.term.options.cursorBlink = true + } + const handleTextareaBlur = () => { + input.term.options.cursorBlink = false + } + + input.container.addEventListener("copy", handleCopy, true) + input.cleanups.push(() => input.container.removeEventListener("copy", handleCopy, true)) + + input.container.addEventListener("paste", handlePaste, true) + input.cleanups.push(() => input.container.removeEventListener("paste", handlePaste, true)) + + input.container.addEventListener("pointerdown", input.handlePointerDown) + input.cleanups.push(() => input.container.removeEventListener("pointerdown", input.handlePointerDown)) + + // --- mobile touch: drag-to-scroll (pointermove swipe detection) --- + // We do NOT intercept touchend or pointerup — Ghostty's internal + // `canvas.addEventListener("touchend", …)` handler is allowed to fire + // (it calls `textarea.focus()` which is the ONLY reliable way to attach + // the Android softkeyboard IME to the hidden textarea). Tap therefore + // behaves exactly as on HEAD (opens keyboard), and we only add a new + // pointermove-driven swipe gesture that scrolls the scrollback in place. + const MOBILE_SWIPE_THRESHOLD_PX = 8 + + // Ghostty's own `scrollLines` clamps at the buffer's live bottom (viewportY + // 0) — dragging further has no effect, so the last prompt is stuck at the + // bottom edge of the panel even when the on-screen keyboard covers most of + // it. Fake "scrolling past the end" by translating the (already fully + // rendered) canvas upward via CSS once the real scrollback is exhausted — + // `getCanvasOffset()`/hit-testing inside ghostty-web reads + // `canvas.getBoundingClientRect()`, which already reflects a CSS + // transform, so tap/selection coordinates stay correct while overscrolled. + const MAX_OVERSCROLL_FACTOR = 0.5 + let overscrollPx = 0 + const canvasEl = input.container.querySelector("canvas") + + const applyOverscroll = () => { + if (!canvasEl) return + canvasEl.style.transform = overscrollPx > 0 ? `translateY(-${overscrollPx}px)` : "" + } + + // Overscroll is deliberately persistent — it must survive both incoming + // shell output and the user's own typing, since the whole point is keeping + // the last prompt visible above the on-screen keyboard while working. It + // only changes via the drag-to-unwind path in onTouchMoveCapture below. + // + // The keyboard opening/closing still resizes `container` (via the + // --vv-top/--vvh compensation), which re-fits the terminal grid. Since + // `overscrollPx` is stored as an absolute pixel amount computed against + // `container`'s height at drag time, if the keyboard then shrinks that + // container, the same absolute offset becomes disproportionately large + // relative to the new (smaller) height. Re-clamp it live so it never + // exceeds MAX_OVERSCROLL_FACTOR of whatever the container's current height + // is — this is the only thing that adjusts the offset outside a drag. + const clampOverscrollToContainer = () => { + const maxOverscrollPx = input.container.clientHeight * MAX_OVERSCROLL_FACTOR + if (overscrollPx <= maxOverscrollPx) return + overscrollPx = Math.max(0, maxOverscrollPx) + applyOverscroll() + } + if (typeof ResizeObserver !== "undefined") { + const overscrollResizeObserver = new ResizeObserver(clampOverscrollToContainer) + overscrollResizeObserver.observe(input.container) + input.cleanups.push(() => overscrollResizeObserver.disconnect()) + } + + // --- mobile touch: long-press to select text --- + // Ghostty's own SelectionManager (selection-manager.ts) is entirely + // mouse-event driven (mousedown/mousemove/mouseup/click on its canvas) — + // it has no touch support and no public "select from pixel A to pixel B" + // API (pixel<->cell conversion is private). Rather than duplicating that + // already-tested logic (drag threshold, auto-scroll at edges, backwards- + // selection swap, clipboard copy), a long-press dispatches real synthetic + // MouseEvents at the same canvas element SelectionManager already listens + // on, driving its existing state machine exactly as a real mouse would. + const LONG_PRESS_MS = 500 + let longPressTimer: ReturnType | undefined + // Tracks whether the finger moved at all while mode was "selecting", so + // touchend knows whether to finalize (dispatch a synthetic mouseup) or + // leave a long-press-only word selection untouched — see the dedicated + // comment on that branch below for why finalizing an unmoved selection + // would destroy it instead of keeping it. + let selectionMoved = false + let lastSelectingPoint: { x: number; y: number } | null = null + + const disarmLongPressTimer = () => { + if (longPressTimer === undefined) return + clearTimeout(longPressTimer) + longPressTimer = undefined + } + input.cleanups.push(disarmLongPressTimer) + + const dispatchCanvasMouseEvent = (type: string, clientX: number, clientY: number, detail = 0) => { + if (!canvasEl) return + canvasEl.dispatchEvent( + new MouseEvent(type, { + clientX, + clientY, + button: 0, + buttons: type === "mouseup" ? 0 : 1, + bubbles: true, + cancelable: true, + detail, + }), + ) + } + + const beginSelection = (x: number, y: number) => { + // The synthetic mousedown below triggers TWO independent focus() calls + // that both end up opening the Android softkeyboard, confirmed via CDP + // instrumentation on a real device (both attempts below were tested + // in isolation and failed to fully fix the symptom before this combined + // fix): + // + // 1. Ghostty's own `canvas.addEventListener("mousedown", () => + // textarea.focus())` (ghostty-web/lib/terminal.ts:446-449) — + // unconditionally focuses the hidden input textarea. + // 2. SelectionManager's mousedown handler + // (ghostty-web/lib/selection-manager.ts:435-441, comment: "CRITICAL: + // Focus the terminal so it can receive keyboard input") calls + // `canvas.parentElement.focus()` — i.e. `input.container` itself. + // That container has `contenteditable="true"` set on it by Ghostty + // (terminal.ts:396, for browser-extension compatibility), so Android + // treats it as a genuine text field and shows the softkeyboard even + // though the hidden textarea never gains focus. This is why + // suppressing only the textarea's `focus()` (previous attempt) left + // the keyboard reopening exactly as before — confirmed on-device: no + // `textarea-focus` event fired, yet `visualViewport` still shrank. + // + // The keyboard reopening mid-drag also resizes `visualViewport`, which + // shifts the whole terminal panel layout up/down (see terminal-panel.tsx's + // viewport-height store) — two root causes, but the same two visible + // symptoms (keyboard flicker + layout jump). + // + // A same-tick `blur()` after the dispatch does NOT work (confirmed + // on-device via CDP instrumentation): Android's "show soft input" + // request, once triggered by `focus()`, is already in flight natively by + // the time our synchronous JS blur() runs — the keyboard opens anyway + // (~130ms later) regardless. The IME's native state and the DOM's focus + // state can desync; a same-tick blur cannot cancel a request that + // already fired. + // + // Can't intercept via event capturing either — SelectionManager's own + // mousedown handler, which actually anchors the selection (same handler + // that calls `canvas.parentElement.focus()` above), lives on the SAME + // canvas element and the SAME event as Ghostty's focus listener, so + // stopping propagation before the canvas would kill the real selection + // logic along with both unwanted focus calls. + // + // Instead, suppress both calls at their source: temporarily replace + // `.focus` on the textarea AND on the container with a no-op for the + // exact duration of the synchronous `dispatchEvent` call, then restore + // both immediately. `dispatchEvent` runs every listener (Ghostty's and + // SelectionManager's) before returning, so the override is guaranteed + // active exactly when either `focus()` would otherwise fire — the + // browser never sees a real focus request on either element, so no + // "show soft input" is ever queued in the first place. SelectionManager's + // own selection-anchoring logic (`isSelecting = true`, anchor cell) does + // not depend on the focus() call succeeding, so suppressing it is safe. + const textarea = input.term.textarea + const originalTextareaFocus = textarea?.focus.bind(textarea) + const originalContainerFocus = input.container.focus.bind(input.container) + if (textarea) textarea.focus = () => {} + input.container.focus = () => {} + dispatchCanvasMouseEvent("mousedown", x, y) + if (textarea && originalTextareaFocus) textarea.focus = originalTextareaFocus + input.container.focus = originalContainerFocus + // Snap the long-press's initial grab to word granularity (matches + // Android's native long-press-to-select) by replaying it as a + // double-click through SelectionManager's own tested word-boundary + // logic (getWordAtCell) — cheaper and safer than duplicating that + // private method, which also needs screen-buffer rows Term doesn't + // expose publicly (only getScrollbackLine/getScrollbackLength are). + dispatchCanvasMouseEvent("click", x, y, 2) + } + + type TouchMode = "pending" | "swipe" | "selecting" + let currentTouch: { id: number; x: number; y: number; mode: TouchMode; scrollApplied: number } | null = null + // Chromium fires `pointerup` before `touchend` for the same gesture + // (confirmed on-device: ~0.2ms apart, consistently pointerup-first across + // every touch sequence captured). `onTouchEndOrCancel` used to null + // `currentTouch` on pointerup, so by the time `blockTouchEndIfGestureConsumed` + // ran on the later touchend, `currentTouch?.mode` always read as `undefined` + // — the swipe check never matched and scrolling always opened the + // keyboard. Capture the verdict before nulling so touchend can still see it. + // Covers both "swipe" and "selecting": releasing a text selection must + // block Ghostty's native touchend->focus() exactly like a scroll release + // does, or finishing a long-press-drag selection would reopen the + // keyboard the same way scrolling used to. + let lastGestureConsumedTouchEnd = false + + // Safety net: if a real pointerup/pointercancel is ever missed for the + // tracked pointer (observed as a risk, not directly reproduced: Android can + // drop a touch sequence without delivering either event to the WebView + // when the view hierarchy resizes mid-touch, e.g. the keyboard showing or + // hiding during a drag), `currentTouch` would otherwise stay non-null + // forever. Combined with `blockTouchEndIfSwipe`'s `|| currentTouch` guard + // below, a stuck `currentTouch` permanently stops Ghostty's own touchend + // handler from ever firing again for this terminal instance — the tab + // would look like it silently stopped accepting taps/keyboard focus, with + // no way to recover short of creating a new tab. Auto-clear it if no + // pointer activity refreshes this watchdog for a second; a real gesture + // always finishes (or keeps moving) well within that window. + const STUCK_TOUCH_TIMEOUT_MS = 1000 + let stuckTouchTimer: ReturnType | undefined + const armStuckTouchWatchdog = () => { + if (stuckTouchTimer !== undefined) clearTimeout(stuckTouchTimer) + stuckTouchTimer = setTimeout(() => { + stuckTouchTimer = undefined + if (!currentTouch) return + // A stuck "selecting" gesture that had moved left SelectionManager's + // own isSelecting flag permanently true (no real mouseup ever arrives + // to clear it) — finalize it the same way a normal release would + // instead of abandoning it silently. + if (currentTouch.mode === "selecting" && selectionMoved && lastSelectingPoint) { + dispatchCanvasMouseEvent("mouseup", lastSelectingPoint.x, lastSelectingPoint.y) + } + lastGestureConsumedTouchEnd = currentTouch.mode !== "pending" + currentTouch = null + }, STUCK_TOUCH_TIMEOUT_MS) + } + const disarmStuckTouchWatchdog = () => { + if (stuckTouchTimer === undefined) return + clearTimeout(stuckTouchTimer) + stuckTouchTimer = undefined + } + input.cleanups.push(disarmStuckTouchWatchdog) + + const mobileCharHeight = () => { + const rows = input.term.rows || 24 + return Math.max(8, input.container.clientHeight / rows) + } + + // Ghostty registers `canvas.addEventListener("mousedown", () => …focus())` + // unconditionally (no button check) — meant for desktop mouse clicks, but + // Chromium also synthesizes a compatibility `mousedown` from an unhandled + // touch sequence for legacy web compat. `preventDefault` on `touchstart` is + // the standard way to suppress that synthesis; kept as defense in depth, + // though it turned out NOT to be the actual cause of scroll reopening the + // keyboard (see below). + // + // Root cause, confirmed on-device: dismissing the keyboard (back button / + // tap outside) never actually calls `.blur()` on the hidden textarea — it + // only hides the IME UI. `document.activeElement` stays the textarea + // (verified: `vvHeight` back to full/no-keyboard while `activeElement` is + // still the terminal's textarea). Android's InputMethodManager can then + // re-show the keyboard on the NEXT touch of that still-focused view + // entirely at the platform level — independent of touchend, mousedown, or + // any `preventDefault()`, which is why blocking those JS events alone never + // stopped it. Blurring on every touchstart removes DOM focus before that + // native reshow can act; Ghostty's own touchend handler still calls + // `.focus()` for a genuine tap (mode never reaches "swipe"), so tap-to-open + // is unaffected — only scrolling now stays blurred throughout. + const suppressSyntheticMouseEvents = (e: TouchEvent) => { + e.preventDefault() + input.term.textarea?.blur() + // Belt-and-braces: the container itself is a fallback focus target for + // Ghostty's own `Term.focus()`/SelectionManager (see terminal.tsx's + // contenteditable-strip comment for the confirmed root cause on mobile). + // Blurring it too costs nothing when it never had focus in the first + // place, and covers it if it ever does independently of the textarea. + if (document.activeElement === input.container) input.container.blur() + } + const touchStartOptions: AddEventListenerOptions = { capture: true, passive: false } + input.container.addEventListener("touchstart", suppressSyntheticMouseEvents, touchStartOptions) + input.cleanups.push(() => + input.container.removeEventListener("touchstart", suppressSyntheticMouseEvents, touchStartOptions), + ) + + const onTouchDownCapture = (e: PointerEvent) => { + if (e.pointerType !== "touch" || currentTouch) return + currentTouch = { id: e.pointerId, x: e.clientX, y: e.clientY, mode: "pending", scrollApplied: 0 } + selectionMoved = false + lastSelectingPoint = null + armStuckTouchWatchdog() + + const pointerId = e.pointerId + const x = e.clientX + const y = e.clientY + disarmLongPressTimer() + longPressTimer = setTimeout(() => { + longPressTimer = undefined + // Only promote if the same touch is still down and hasn't already + // been classified as a scroll (see the >=threshold branch below, + // which disarms this timer the moment it fires). + if (!currentTouch || currentTouch.id !== pointerId || currentTouch.mode !== "pending") return + currentTouch.mode = "selecting" + lastSelectingPoint = { x, y } + if (input.term.getSelection().length > 0) input.term.clearSelection() + beginSelection(x, y) + }, LONG_PRESS_MS) + } + + const onTouchMoveCapture = (e: PointerEvent) => { + if (!currentTouch || e.pointerId !== currentTouch.id) return + armStuckTouchWatchdog() + + if (currentTouch.mode === "selecting") { + // Consume the event so the surrounding scroller never also pans + // while extending a text selection. + e.preventDefault() + e.stopPropagation() + selectionMoved = true + lastSelectingPoint = { x: e.clientX, y: e.clientY } + dispatchCanvasMouseEvent("mousemove", e.clientX, e.clientY) + return + } + + const dy = e.clientY - currentTouch.y + + if (currentTouch.mode === "pending") { + if (Math.hypot(e.clientX - currentTouch.x, dy) < MOBILE_SWIPE_THRESHOLD_PX) return + disarmLongPressTimer() + currentTouch.mode = "swipe" + } + + // Swipe mode: consume the event so the surrounding app scroller does + // not also pan. ghostty clamps scrollLines at the buffer edges. + e.preventDefault() + e.stopPropagation() + // Drag-down = walking back in history = scroll UP (negative delta). + const targetRowsFromStart = Math.round(-dy / mobileCharHeight()) + const totalDelta = targetRowsFromStart - currentTouch.scrollApplied + currentTouch.scrollApplied = targetRowsFromStart + if (totalDelta === 0) return + + const charHeight = mobileCharHeight() + let delta = totalDelta + if (delta < 0 && overscrollPx > 0) { + // Dragging back toward history: unwind the fake overscroll first so + // the gesture feels continuous instead of jumping straight into real + // scrollback while the canvas is still shifted up. + const rowsToUnwind = Math.min(-delta, overscrollPx / charHeight) + overscrollPx = Math.max(0, overscrollPx - rowsToUnwind * charHeight) + delta += rowsToUnwind + applyOverscroll() + } + if (delta === 0) return + + const beforeY = input.term.getViewportY() + input.term.scrollLines(delta) + if (delta > 0 && input.term.getViewportY() === 0) { + // Requested more "toward the bottom" scroll than the real buffer had + // left (already at viewportY 0) — the leftover becomes overscroll. + const unusedRows = delta - (beforeY - input.term.getViewportY()) + if (unusedRows > 0) { + const maxOverscrollPx = input.container.clientHeight * MAX_OVERSCROLL_FACTOR + overscrollPx = Math.min(maxOverscrollPx, overscrollPx + unusedRows * charHeight) + applyOverscroll() + } + } + } + + const onTouchEndOrCancel = (e: PointerEvent) => { + if (!currentTouch || e.pointerId !== currentTouch.id) return + disarmStuckTouchWatchdog() + disarmLongPressTimer() + if (currentTouch.mode === "selecting") { + // Only finalize (synthetic mouseup) if the finger actually moved. + // SelectionManager's own mouseup handler clears the selection when + // `dragThresholdMet` was never set — dispatching mouseup for a + // long-press that never dragged would destroy the word selection + // `beginSelection` just made instead of leaving it in place, which is + // the expected "long-press alone selects+copies a word" behavior. + if (selectionMoved) dispatchCanvasMouseEvent("mouseup", e.clientX, e.clientY) + } + if (currentTouch.mode === "pending" && input.term.getSelection().length > 0) { + input.term.clearSelection() + } + lastGestureConsumedTouchEnd = currentTouch.mode !== "pending" + currentTouch = null + lastSelectingPoint = null + // DO NOT preventDefault/stopPropagation here — Ghostty's native + // touchend handler on the canvas must still fire so the IME attaches + // correctly to the textarea. This is the lesson from the 2026-04-23 + // regression where blocking touchend left the softkeyboard visually + // open but keystrokes never reached the textarea. + } + + const touchCaptureOptions: AddEventListenerOptions = { capture: true } + const touchMoveOptions: AddEventListenerOptions = { capture: true, passive: false } + input.container.addEventListener("pointerdown", onTouchDownCapture, touchCaptureOptions) + input.container.addEventListener("pointermove", onTouchMoveCapture, touchMoveOptions) + input.container.addEventListener("pointerup", onTouchEndOrCancel, touchCaptureOptions) + input.container.addEventListener("pointercancel", onTouchEndOrCancel, touchCaptureOptions) + input.cleanups.push(() => { + input.container.removeEventListener("pointerdown", onTouchDownCapture, touchCaptureOptions) + input.container.removeEventListener("pointermove", onTouchMoveCapture, touchMoveOptions) + input.container.removeEventListener("pointerup", onTouchEndOrCancel, touchCaptureOptions) + input.container.removeEventListener("pointercancel", onTouchEndOrCancel, touchCaptureOptions) + }) + + // Prevent Ghostty's `canvas.addEventListener("touchend", g.focus())` ONLY + // when the gesture was a swipe or a text selection — so neither scrolling + // nor releasing a long-press selection ever toggles the softkeyboard + // state. For taps (mode stays "pending"), we let touchend bubble to the + // canvas so Ghostty attaches the Android IME normally. This conditional + // block is safe where the v3.2 attempt (unconditional + // stopImmediatePropagation on touchend) was not. Reads + // `lastGestureConsumedTouchEnd` (captured synchronously in + // onTouchEndOrCancel's pointerup/pointercancel handler) rather than + // `currentTouch?.mode`, which is always already null by the time this + // touchend handler runs. + // + // Also blocks while `currentTouch` is still non-null (another pointer is + // still actively tracked): confirmed on-device that Android reports a + // second, ~4ms-lived pointer (its own distinct pointerId) partway through + // a real one-finger scroll. `onTouchDownCapture` already ignores that + // second pointerdown (`if (... || currentTouch) return`), so its matching + // pointerup never reaches `onTouchEndOrCancel` either (pointerId mismatch) + // and `lastGestureConsumedTouchEnd` is never updated for it — leaving this + // touchend to fall through on a stale value from whatever gesture came + // before. Any touchend arriving while a swipe/selection is still in + // progress is spurious by definition (a real tap-to-focus never overlaps + // another active touch). + const blockTouchEndIfGestureConsumed = (e: TouchEvent) => { + if (lastGestureConsumedTouchEnd || currentTouch) { + e.stopPropagation() + } + } + const touchBlockerOptions: AddEventListenerOptions = { capture: true, passive: true } + input.container.addEventListener("touchend", blockTouchEndIfGestureConsumed, touchBlockerOptions) + input.container.addEventListener("touchcancel", blockTouchEndIfGestureConsumed, touchBlockerOptions) + input.cleanups.push(() => { + input.container.removeEventListener("touchend", blockTouchEndIfGestureConsumed, touchBlockerOptions) + input.container.removeEventListener("touchcancel", blockTouchEndIfGestureConsumed, touchBlockerOptions) + }) + + // Synthetic selection mousedown events make Ghostty believe the last + // mousedown started inside the canvas forever on touch-only devices. Clear + // on pointerdown instead of waiting for click: touchstart.preventDefault() + // and UI event handlers can suppress the later click entirely. Capture is + // safe here because the mobile toolbar is explicitly excluded, so Copier + // still reads the selection before anything can clear it. + const clearSelectionOnOutsidePointerDown = (e: PointerEvent) => { + const target = e.target + if (!(target instanceof Element)) return + if (target.closest('[data-component="terminal-mobile-toolbar"]')) return + if (input.container.contains(target)) return + if (input.term.getSelection().length > 0) input.term.clearSelection() + } + document.addEventListener("pointerdown", clearSelectionOnOutsidePointerDown, true) + input.cleanups.push(() => document.removeEventListener("pointerdown", clearSelectionOnOutsidePointerDown, true)) + + input.container.addEventListener("click", input.handleLinkClick, { + capture: true, + }) + input.cleanups.push(() => + input.container.removeEventListener("click", input.handleLinkClick, { + capture: true, + }), + ) + + input.term.textarea?.addEventListener("focus", handleTextareaFocus) + input.term.textarea?.addEventListener("blur", handleTextareaBlur) + input.cleanups.push(() => input.term.textarea?.removeEventListener("focus", handleTextareaFocus)) + input.cleanups.push(() => input.term.textarea?.removeEventListener("blur", handleTextareaBlur)) +} + diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index ad7c32ccf56d..384102f9f062 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -17,9 +17,24 @@ import { useTerminal, type LocalPTY } from "@/context/terminal" import { terminalAttr, terminalProbe } from "@/testing/terminal" import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters" import { terminalWriter } from "@/utils/terminal-writer" +import { useTerminalUiBindings } from "@/components/terminal-touch-controller" const TOGGLE_TERMINAL_ID = "terminal.toggle" const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`" + +export interface TerminalSelectionApi { + // Deliberately NOT delegating to `term.hasSelection()`: that method hides + // an in-progress selection until its internal drag threshold is met, + // which would report `false` right after a long-press-only word-select + // (see beginSelection in useTerminalUiBindings) even though text is + // already highlighted and copied. Reading the text directly sidesteps + // that gate and matches what's actually visible on screen. + hasSelection(): boolean + copySelection(): boolean + paste(text: string): void + onSelectionChange(cb: () => void): VoidFunction +} + export interface TerminalProps extends ComponentProps<"div"> { pty: LocalPTY autoFocus?: boolean @@ -28,32 +43,102 @@ export interface TerminalProps extends ComponentProps<"div"> { onConnect?: () => void onConnectError?: (error: unknown) => void onSend?: (fn: ((data: string) => void) | undefined) => void + onSelectionApi?: (api: TerminalSelectionApi | undefined) => void } -let shared: Promise<{ mod: typeof import("ghostty-web"); ghostty: Ghostty | undefined }> | undefined +let sharedModule: Promise | undefined -const loadGhostty = () => { - if (shared) return shared - console.info("[terminal] loading ghostty-web module, wasm url:", ghosttyWasmUrl) - shared = import("ghostty-web") - .then(async (mod) => { - // Try loading WASM backend; fall back to canvas-only rendering on mobile/unsupported environments - let ghostty: Ghostty | undefined - try { - ghostty = await mod.Ghostty.load(ghosttyWasmUrl) - console.info("[terminal] ghostty WASM loaded successfully") - } catch (err) { - console.warn("[terminal] Ghostty WASM unavailable, using canvas renderer:", err) +const loadGhosttyModule = () => { + if (sharedModule) return sharedModule + sharedModule = import("ghostty-web").catch((err) => { + console.error("[terminal] failed to import ghostty-web module:", err) + sharedModule = undefined + throw err + }) + return sharedModule +} + +// Each terminal tab gets its own Ghostty WASM instance (own linear memory). +// A single shared instance across tabs meant a later tab's WASM memory +// growth (memory.grow(), e.g. opening a 2nd/3rd terminal) detached the +// ArrayBuffer views an earlier tab's renderer was still reading cells +// from, corrupting its on-screen glyphs into garbage (observed: random +// CJK/tofu characters appearing in an already-open tab right after +// opening a new one). The JS module import above is still shared/cached — +// only the WASM instantiation (real memory) is isolated per terminal. +const loadGhostty = async () => { + const mod = await loadGhosttyModule() + console.info("[terminal] loading ghostty-web WASM instance, wasm url:", ghosttyWasmUrl) + let ghostty: Ghostty | undefined + try { + ghostty = await mod.Ghostty.load(ghosttyWasmUrl) + console.info("[terminal] ghostty WASM loaded successfully") + } catch (err) { + console.warn("[terminal] Ghostty WASM unavailable, using canvas renderer:", err) + } + return { mod, ghostty } +} + +// A brand-new terminal spawns its shell at exactly the size measured by the +// one `fit.fit()` call preceding `pty.create()` — and, by design, never +// resizes again afterward (see the lazy-create comment further below: a +// follow-up SIGWINCH after the first prompt re-triggers mksh's readline +// pad-erase redisplay glitch). That design is only safe if the one +// pre-spawn measurement is correct. On this Android WebView the container's +// layout (flex sizing, `--vvh`/visualViewport-driven CSS vars, keyboard- +// adjacent geometry) is often not yet settled the instant this component +// mounts, so a synchronous `getBoundingClientRect()` right after `t.open()` +// can catch a transient, too-small size — the shell then spawns too narrow +// and, since no correction ever follows, stays that way for its entire +// life (observed: prompt permanently truncated by readline's horizontal- +// scroll indicator). Wait for the container's measured size to stop +// changing across consecutive ResizeObserver callbacks before treating it +// as final. Bounded by `maxWaitMs` so a container that genuinely never +// settles (or a browser that never fires a stabilizing callback) doesn't +// block the terminal from opening at all. +const waitForStableContainerSize = (container: HTMLElement, maxWaitMs = 400, requiredStableTicks = 2) => + new Promise((resolve) => { + if (typeof ResizeObserver === "undefined") { + resolve() + return + } + let settled = false + let lastWidth = -1 + let lastHeight = -1 + let stableTicks = 0 + const finish = () => { + if (settled) return + settled = true + clearTimeout(timer) + ro.disconnect() + resolve() + } + const timer = setTimeout(finish, maxWaitMs) + const ro = new ResizeObserver(() => { + const rect = container.getBoundingClientRect() + // Mobile layout can briefly settle at a near-zero height during + // initial reflow (keyboard/safe-area/address-bar animations) before + // reaching its final size. Two identical ticks at e.g. 1px would + // otherwise look "stable" and let fit.fit() compute rows=1 — the PTY + // then spawns bash with LINES=1, which crashes readline's redisplay + // the moment any command produces scrollable output. Require a + // plausible minimum before trusting a measurement as final. + if ( + rect.width >= 100 && + rect.height >= 100 && + rect.width === lastWidth && + rect.height === lastHeight + ) { + stableTicks += 1 + if (stableTicks >= requiredStableTicks) finish() + } else { + stableTicks = 0 + lastWidth = rect.width + lastHeight = rect.height } - return { mod, ghostty } }) - .catch((err) => { - console.error("[terminal] failed to import ghostty-web module:", err) - shared = undefined - throw err - }) - return shared -} + ro.observe(container) + }) type TerminalColors = { background: string @@ -136,157 +221,6 @@ const debugTerminal = (...values: unknown[]) => { console.debug("[terminal]", ...values) } -const errorName = (err: unknown) => { - if (!err || typeof err !== "object") return - if (!("name" in err)) return - const errorName = err.name - return typeof errorName === "string" ? errorName : undefined -} - -const useTerminalUiBindings = (input: { - container: HTMLDivElement - term: Term - cleanups: VoidFunction[] - handlePointerDown: () => void - handleLinkClick: (event: MouseEvent) => void -}) => { - const handleCopy = (event: ClipboardEvent) => { - const selection = input.term.getSelection() - if (!selection) return - - const clipboard = event.clipboardData - if (!clipboard) return - - event.preventDefault() - clipboard.setData("text/plain", selection) - } - - const handlePaste = (event: ClipboardEvent) => { - const clipboard = event.clipboardData - const text = clipboard?.getData("text/plain") ?? clipboard?.getData("text") ?? "" - if (!text) return - - event.preventDefault() - event.stopPropagation() - input.term.paste(text) - } - - const handleTextareaFocus = () => { - input.term.options.cursorBlink = true - } - const handleTextareaBlur = () => { - input.term.options.cursorBlink = false - } - - input.container.addEventListener("copy", handleCopy, true) - input.cleanups.push(() => input.container.removeEventListener("copy", handleCopy, true)) - - input.container.addEventListener("paste", handlePaste, true) - input.cleanups.push(() => input.container.removeEventListener("paste", handlePaste, true)) - - input.container.addEventListener("pointerdown", input.handlePointerDown) - input.cleanups.push(() => input.container.removeEventListener("pointerdown", input.handlePointerDown)) - - // --- mobile touch: drag-to-scroll (pointermove swipe detection) --- - // We do NOT intercept touchend or pointerup — Ghostty's internal - // `canvas.addEventListener("touchend", …)` handler is allowed to fire - // (it calls `textarea.focus()` which is the ONLY reliable way to attach - // the Android softkeyboard IME to the hidden textarea). Tap therefore - // behaves exactly as on HEAD (opens keyboard), and we only add a new - // pointermove-driven swipe gesture that scrolls the scrollback in place. - const MOBILE_SWIPE_THRESHOLD_PX = 8 - - type TouchMode = "pending" | "swipe" - let currentTouch: { id: number; x: number; y: number; mode: TouchMode; scrollApplied: number } | null = null - - const mobileCharHeight = () => { - const rows = input.term.rows || 24 - return Math.max(8, input.container.clientHeight / rows) - } - - const onTouchDownCapture = (e: PointerEvent) => { - if (e.pointerType !== "touch" || currentTouch) return - currentTouch = { id: e.pointerId, x: e.clientX, y: e.clientY, mode: "pending", scrollApplied: 0 } - } - - const onTouchMoveCapture = (e: PointerEvent) => { - if (!currentTouch || e.pointerId !== currentTouch.id) return - const dy = e.clientY - currentTouch.y - - if (currentTouch.mode === "pending") { - if (Math.hypot(e.clientX - currentTouch.x, dy) < MOBILE_SWIPE_THRESHOLD_PX) return - currentTouch.mode = "swipe" - } - - // Swipe mode: consume the event so the surrounding app scroller does - // not also pan. ghostty clamps scrollLines at the buffer edges. - e.preventDefault() - e.stopPropagation() - // Drag-down = walking back in history = scroll UP (negative delta). - const targetRowsFromStart = Math.round(-dy / mobileCharHeight()) - const delta = targetRowsFromStart - currentTouch.scrollApplied - if (delta === 0) return - input.term.scrollLines(delta) - currentTouch.scrollApplied = targetRowsFromStart - } - - const onTouchEndOrCancel = (e: PointerEvent) => { - if (!currentTouch || e.pointerId !== currentTouch.id) return - currentTouch = null - // DO NOT preventDefault/stopPropagation here — Ghostty's native - // touchend handler on the canvas must still fire so the IME attaches - // correctly to the textarea. This is the lesson from the 2026-04-23 - // regression where blocking touchend left the softkeyboard visually - // open but keystrokes never reached the textarea. - } - - const touchCaptureOptions: AddEventListenerOptions = { capture: true } - const touchMoveOptions: AddEventListenerOptions = { capture: true, passive: false } - input.container.addEventListener("pointerdown", onTouchDownCapture, touchCaptureOptions) - input.container.addEventListener("pointermove", onTouchMoveCapture, touchMoveOptions) - input.container.addEventListener("pointerup", onTouchEndOrCancel, touchCaptureOptions) - input.container.addEventListener("pointercancel", onTouchEndOrCancel, touchCaptureOptions) - input.cleanups.push(() => { - input.container.removeEventListener("pointerdown", onTouchDownCapture, touchCaptureOptions) - input.container.removeEventListener("pointermove", onTouchMoveCapture, touchMoveOptions) - input.container.removeEventListener("pointerup", onTouchEndOrCancel, touchCaptureOptions) - input.container.removeEventListener("pointercancel", onTouchEndOrCancel, touchCaptureOptions) - }) - - // Prevent Ghostty's `canvas.addEventListener("touchend", g.focus())` ONLY - // when the gesture was a swipe — so scrolling never toggles the - // softkeyboard state. For taps (mode stays "pending"), we let touchend - // bubble to the canvas so Ghostty attaches the Android IME normally. - // This conditional block is safe where the v3.2 attempt (unconditional - // stopImmediatePropagation on touchend) was not. - const blockTouchEndIfSwipe = (e: TouchEvent) => { - if (currentTouch?.mode === "swipe") { - e.stopPropagation() - } - } - const touchBlockerOptions: AddEventListenerOptions = { capture: true, passive: true } - input.container.addEventListener("touchend", blockTouchEndIfSwipe, touchBlockerOptions) - input.container.addEventListener("touchcancel", blockTouchEndIfSwipe, touchBlockerOptions) - input.cleanups.push(() => { - input.container.removeEventListener("touchend", blockTouchEndIfSwipe, touchBlockerOptions) - input.container.removeEventListener("touchcancel", blockTouchEndIfSwipe, touchBlockerOptions) - }) - - input.container.addEventListener("click", input.handleLinkClick, { - capture: true, - }) - input.cleanups.push(() => - input.container.removeEventListener("click", input.handleLinkClick, { - capture: true, - }), - ) - - input.term.textarea?.addEventListener("focus", handleTextareaFocus) - input.term.textarea?.addEventListener("blur", handleTextareaBlur) - input.cleanups.push(() => input.term.textarea?.removeEventListener("focus", handleTextareaFocus)) - input.cleanups.push(() => input.term.textarea?.removeEventListener("blur", handleTextareaBlur)) -} - const persistTerminal = (input: { term: Term | undefined addon: SerializeAddon | undefined @@ -345,7 +279,16 @@ export const Terminal = (props: TerminalProps) => { console.info("[terminal-debug]", msg) } let container!: HTMLDivElement - const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError", "onSend"]) + const [local, others] = splitProps(props, [ + "pty", + "class", + "classList", + "autoFocus", + "onConnect", + "onConnectError", + "onSend", + "onSelectionApi", + ]) const id = local.pty.id const probe = terminalProbe(id) const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : "" @@ -495,11 +438,22 @@ export const Terminal = (props: TerminalProps) => { t.textarea?.focus() setTimeout(() => t.textarea?.focus(), 0) } - const handlePointerDown = () => { + const handlePointerDown = (e: PointerEvent) => { const activeElement = document.activeElement if (activeElement instanceof HTMLElement && activeElement !== container && !container.contains(activeElement)) { activeElement.blur() } + // Root cause of "scroll reopens the keyboard" (confirmed on-device): + // this fired unconditionally on every pointerdown, calling + // `t.textarea?.focus()` before the swipe-vs-tap gesture below had any + // chance to classify the touch — refocusing the (possibly just-blurred) + // textarea instantly re-arms Android's native keyboard-on-focused-touch + // behavior regardless of whether the gesture turns out to be a scroll. + // Ghostty's own touchend handler already focuses the terminal correctly + // for a genuine tap (gated by `blockTouchEndIfSwipe` below), so touch + // pointers skip this eager focus entirely; non-touch pointers (mouse) + // keep the original immediate focus-on-click behavior. + if (e.pointerType === "touch") return focusTerminal() } @@ -603,6 +557,21 @@ export const Terminal = (props: TerminalProps) => { try { t.open(container) + // Ghostty sets `contenteditable="true"` on this container (see + // ghostty-web/lib/terminal.ts) purely so desktop browser extensions + // (Vimium, etc.) recognize it as an input element — irrelevant on + // Android, where no such extensions run. On mobile it's actively + // harmful: confirmed on-device that reopening a terminal makes + // Android's WebView focus this contenteditable container natively + // (no JS `.focus()` call involved — verified via a global + // `Element.prototype.focus` trace that stayed empty) and pop the + // softkeyboard before the real input textarea is even the target, + // leaving it open but non-functional until the user explicitly taps + // the textarea. Real keyboard input always goes through the hidden + // textarea (see `focusTerminal`/`suppressSyntheticMouseEvents` + // above), never through this container's contenteditable state, so + // removing it on mobile costs nothing. + if (platform.platform === "mobile") container.contentEditable = "false" const rect = container.getBoundingClientRect() addDebug(`t.open() OK — container: ${Math.round(rect.width)}x${Math.round(rect.height)} inDOM:${document.contains(container)}`) } catch (err) { @@ -620,6 +589,20 @@ export const Terminal = (props: TerminalProps) => { if (local.autoFocus !== false) focusTerminal() if (typeof document !== "undefined" && document.fonts) { + // The very first `fit.fit()` below measures cell width using + // whatever font is currently active. If the monospace font hasn't + // finished loading yet, that measurement (and, for a brand-new PTY, + // the exact spawn size — see the lazy-create comment below) is + // wrong. A later correction then forces a real SIGWINCH, which is + // exactly the readline redraw glitch this code is trying to avoid: + // the shell briefly redraws its prompt at the wrong column count, + // showing it truncated and stripped of its color codes. Waiting + // here (bounded so a slow/stuck font never blocks the terminal) + // makes the first measurement correct instead of self-correcting. + await Promise.race([ + document.fonts.ready, + new Promise((resolve) => setTimeout(resolve, 200)), + ]) document.fonts.ready.then(scheduleFit) } @@ -638,7 +621,7 @@ export const Terminal = (props: TerminalProps) => { }) cleanups.push(() => disposeIfDisposable(onKey)) - const startResize = () => { + const startResize = (opts?: { skipFixedDelayRefits?: boolean }) => { fit.observeResize() handleResize = scheduleFit window.addEventListener("resize", handleResize) @@ -658,14 +641,30 @@ export const Terminal = (props: TerminalProps) => { // toggle. `fit.observeResize()` already watches the terminal // internal element but not the outer container we control. // 2. A few delayed refits covering the window where the viewport - // stabilizes (50ms / 200ms / 500ms). Cheap, idempotent. + // stabilizes (50ms / 200ms / 500ms). Cheap, idempotent — EXCEPT + // for a lazy-created terminal (`skipFixedDelayRefits`): its + // pre-spawn measurement is already settled (see + // `waitForStableContainerSize` above), and the shell prints its + // first prompt within single-digit milliseconds — well before + // these timers fire. By then the server's `firstOutputAt` is + // already set, so ANY of these timers computing even a + // one-column difference (subpixel/rounding jitter) pushes a + // resize the server treats as a genuine mid-session one + // (its own pre-first-output hold window, see + // `pty/index.ts::applyResize`, no longer applies) — a real + // SIGWINCH lands right after the prompt is already drawn, + // which is exactly the readline pad-erase redisplay glitch + // this whole mechanism exists to avoid. Skip them here; a + // lazy-created terminal only needs to react to genuine later + // events (ResizeObserver, orientationchange), not a blind + // just-in-case re-measure. if (typeof ResizeObserver !== "undefined") { const ro = new ResizeObserver(() => scheduleFit()) ro.observe(container) cleanups.push(() => ro.disconnect()) } const refreshTimers: ReturnType[] = [] - for (const delay of [50, 200, 500]) { + for (const delay of opts?.skipFixedDelayRefits ? [] : [50, 200, 500]) { refreshTimers.push( setTimeout(() => { if (disposed) return @@ -727,40 +726,92 @@ export const Terminal = (props: TerminalProps) => { if (scrollY !== undefined) t.scrollToLine(scrollY) startResize() } else { + // FitAddon.proposeDimensions() (ghostty-web source) measures + // `t.element` — the element ghostty-web creates *inside* our + // `container` — via clientWidth/clientHeight, NOT `container` + // itself. Its sizing can settle on a different schedule than the + // outer container we control, so that's the element to wait on. + const wasLazyCreate = local.pty._pending + if (wasLazyCreate) await waitForStableContainerSize(t.element ?? container) fit.fit() - if (local.pty._pending) { + if (wasLazyCreate) { // Lazy-create: backend has no session for this id yet. Call - // pty.create with the *exact* grid dims measured above. The shell - // spawns at final size so no SIGWINCH is ever emitted and mksh's - // readline pad-erase redisplay never fires — fixes the portrait - // first-prompt bug at its root. - try { - await client.pty.create({ - id, - title: local.pty.title, - cols: t.cols, - rows: t.rows, - // FORK: ADR-0005 task runner — run a specific command instead of - // the default shell when set via terminal.newWithCommand(). - ...(local.pty.command ? { command: local.pty.command } : {}), - }) - // Pre-seed lastSize so the immediate scheduleSize below (and the - // ones from WS open / ResizeObserver if dims are still identical) - // are no-ops and never trigger a PUT /pty/:id. - lastSize = { cols: t.cols, rows: t.rows } - terminalCtx.finalizePending(id) - } catch (err) { - addDebug(`pty.create failed: ${err instanceof Error ? err.message : String(err)}`) + // pty.create with the *exact* grid dims measured above — now that + // waitForStableContainerSize() above has confirmed the container + // isn't still mid-layout. The shell spawns at final size so no + // SIGWINCH is ever emitted and mksh's readline pad-erase + // redisplay never fires — fixes the portrait first-prompt bug at + // its root. + // FORK (P5 investigation, 2026-07-09): dump the EXACT measurement + // feeding pty.create(). PTY-Server native logs show all sessions + // are spawned with cols=36 rows=1 (bash LINES=1), but the visible + // container is ~43 cols x ~7 rows. Suspect: waitForStableContainerSize + // returned early on a transient 1-row stable state. Capture before/after + // waitForStableContainerSize + the actual t.cols/t.rows + container + + // t.element rect to identify the race. + { + const contRect = container.getBoundingClientRect() + const elemRect = (t.element ?? container).getBoundingClientRect() + console.log( + "[term-investigation] lazy-create measurement", + JSON.stringify({ + id, + tCols: t.cols, + tRows: t.rows, + containerRect: { w: Math.round(contRect.width), h: Math.round(contRect.height) }, + tElementRect: { w: Math.round(elemRect.width), h: Math.round(elemRect.height) }, + tColsExpected: Math.floor(elemRect.width / 8.4), + tRowsExpected: Math.floor(elemRect.height / 17), + }), + ) + } + // FORK (P1, 2026-07-09): SDK default `throwOnError:false` returns + // errors via `res.error` instead of throwing. The previous try/catch + // was unreachable on HTTP errors, so a failed lazy-create left + // the pending entry in the store forever. Read `res.error` explicitly. + const createRes = await client.pty.create({ + id, + title: local.pty.title, + cols: t.cols, + rows: t.rows, + // FORK: ADR-0005 task runner — run a specific command instead of + // the default shell when set via terminal.newWithCommand(). + ...(local.pty.command ? { command: local.pty.command } : {}), + }) + if (createRes.error) { + // FORK (P5 investigation, 2026-07-09): trace lazy-create failure + // (Site 4 from P1). If this fires alongside failPending in the + // context, lazy-create is rejecting a tab — but it should NOT be + // the active tab if the device is past the welcome screen. + console.log( + "[term-investigation] lazy-create pty.create failed", + JSON.stringify({ + id, + title: local.pty.title, + cols: t.cols, + rows: t.rows, + isPending: local.pty._pending, + errorName: (createRes.error as { name?: string })?.name, + errorMessage: + createRes.error instanceof Error ? createRes.error.message : String(createRes.error), + }), + ) + addDebug(`pty.create failed: ${createRes.error instanceof Error ? createRes.error.message : String(createRes.error)}`) terminalCtx.failPending(id) - throw err + throw createRes.error } + // Pre-seed lastSize so the immediate scheduleSize below (and the + // ones from WS open / ResizeObserver if dims are still identical) + // are no-ops and never trigger a PUT /pty/:id. + lastSize = { cols: t.cols, rows: t.rows } + terminalCtx.finalizePending(id) } scheduleSize(t.cols, t.rows) if (restore) { await write(restore) if (scrollY !== undefined) t.scrollToLine(scrollY) } - startResize() + startResize({ skipFixedDelayRefits: wasLazyCreate }) } const once = { value: false } @@ -770,18 +821,31 @@ export const Terminal = (props: TerminalProps) => { if (disposed) return if (once.value) return once.value = true + // FORK (P5 investigation, 2026-07-09): WS path. If this fires for the + // T2 id when the user presses Enter, the bug is on the frontend side + // (WS dropped, retry exhausted), not the backend killing bash. + console.log( + "[term-investigation] terminal fail() (WS retry exhausted or auth failed)", + JSON.stringify({ + id, + errMessage: err instanceof Error ? err.message : String(err), + }), + ) local.onConnectError?.(err) } - const gone = () => - client.pty - .get({ ptyID: id }) - .then(() => false) - .catch((err) => { - if (errorName(err) === "NotFoundError") return true - debugTerminal("failed to inspect terminal session", err) - return false - }) + // FORK (P1, 2026-07-09): SDK default `throwOnError:false` returns + // `{data: undefined, error: {name, ...}}` for non-2xx instead of + // throwing. Reading `res.error` is the only way to detect NotFoundError + // here. The previous `.then/.catch` chain was unreachable on HTTP + // errors, so `gone()` always returned false and `retry()` consumed + // all 5 attempts before falling back to clone(). + const gone = async () => { + const res = await client.pty.get({ ptyID: id }) + if (res.error && (res.error as { name?: string }).name === "NotFoundError") return true + if (res.error) debugTerminal("failed to inspect terminal session", res.error) + return false + } const retry = (err: unknown) => { if (disposed) return @@ -932,6 +996,41 @@ export const Terminal = (props: TerminalProps) => { local.onSend?.(sendBytes) cleanups.push(() => local.onSend?.(undefined)) + local.onSelectionApi?.({ + hasSelection: () => t.getSelection().length > 0, + copySelection: () => t.copySelection(), + paste: (text) => t.paste(text), + onSelectionChange: (cb) => { + const disposable = t.onSelectionChange(cb) + return () => disposeIfDisposable(disposable) + }, + }) + cleanups.push(() => local.onSelectionApi?.(undefined)) + + // Android suspends WebView JS timers/networking while the app is + // backgrounded (screen lock, app switch). If the PTY WebSocket dies + // during that window, the `close` event — and therefore `retry()`'s + // exponential backoff — often only fires once the page is foregrounded + // again, and the very first backoff step still takes up to 250ms. A + // user unlocking their phone and typing immediately lands keystrokes + // in that gap: `t.onData` only sends when `readyState === OPEN`, so + // they are silently dropped with no queueing and no visible feedback. + // Forcing an immediate reconnect check on visibility restore closes + // that window instead of waiting for backoff. Mirrors the same + // pattern already used for the SSE stream in global-sdk.tsx. + const handleVisibility = () => { + if (disposed) return + if (document.visibilityState !== "visible") return + if (ws && ws.readyState === WebSocket.OPEN) return + if (reconn !== undefined) { + clearTimeout(reconn) + reconn = undefined + } + open() + } + document.addEventListener("visibilitychange", handleVisibility) + cleanups.push(() => document.removeEventListener("visibilitychange", handleVisibility)) + open() } diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index c74d9aa6c3c0..648c92a10e64 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -118,6 +118,9 @@ export type Platform = { /** Read image from clipboard (desktop only) */ readClipboardImage?(): Promise + /** Read text from clipboard (mobile only — bridges the native Android clipboard) */ + readClipboardText?(): Promise + /** Check if local CLI execution is available (Android Termux) */ checkLocalAvailable?(): Promise diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index 0b6c19a17cae..67a2b405c25a 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -226,9 +226,23 @@ function createWorkspaceTerminalSession(sdk: ReturnType, dir: str ) } + // FORK (P5 investigation, 2026-07-09): trace `removeExited` entry. Hypothesis + // is that an Enter press triggers PTY server-side exit, which fires pty.exited, + // which calls this and removes T2 from the store. If this log fires in pairs + // with the pty.exited listener log below (same id), the chain is confirmed. const removeExited = (id: string) => { const all = store.all const index = all.findIndex((x) => x.id === id) + console.log( + "[term-investigation] removeExited", + JSON.stringify({ + id, + foundAtIndex: index, + isActiveTab: store.active === id, + storeActive: store.active, + storeAllIds: store.all.map((p) => p.id), + }), + ) if (index === -1) return const active = store.active === id ? (index === 0 ? all[1]?.id : all[0]?.id) : store.active batch(() => { @@ -242,7 +256,20 @@ function createWorkspaceTerminalSession(sdk: ReturnType, dir: str }) } + // FORK (P5 investigation, 2026-07-09): trace every pty.exited event from the + // server. If this fires simultaneously with removeExited above (matching id), + // the "Enter ferme T2" symptom is caused by the PTY process exiting on the + // server side — to be cross-validated against Bun server logs + // (`log.info("session exited", { id, exitCode })` in pty/index.ts). const unsub = sdk.event.on("pty.exited", (event: { properties: { id: string } }) => { + console.log( + "[term-investigation] pty.exited event", + JSON.stringify({ + id: event.properties.id, + storeActive: store.active, + storeAllIds: store.all.map((p) => p.id), + }), + ) removeExited(event.properties.id) }) onCleanup(unsub) @@ -288,19 +315,21 @@ function createWorkspaceTerminalSession(sdk: ReturnType, dir: str // failure (network error, auth not yet ready, server still booting) // must NOT mark the PTY dead — otherwise a sweep racing the sidecar // health check would wipe every still-valid session. + // + // FORK (P1, 2026-07-09): SDK default `throwOnError:false` means non-2xx + // responses are returned as `{data: undefined, error: {name, ...}}`, + // NOT thrown. Reading `res.error` is the only way to detect NotFoundError. + // The previous `.then(() => "alive").catch(...)` chain was unreachable + // for HTTP error paths and silently grandfathered every dead tab. const statuses = await Promise.all( - ids.map((id) => - sdk.client.pty - .get({ ptyID: id }) - .then(() => "alive" as const) - .catch((err: unknown) => { - const name = - err && typeof err === "object" && "name" in err && typeof err.name === "string" - ? err.name - : undefined - return name === "NotFoundError" ? ("gone" as const) : ("unknown" as const) - }), - ), + ids.map(async (id) => { + const res = await sdk.client.pty.get({ ptyID: id }) + if (res.error) { + const name = (res.error as { name?: string }).name + return name === "NotFoundError" ? ("gone" as const) : ("unknown" as const) + } + return "alive" as const + }), ) const dead = new Set(ids.filter((_, index) => statuses[index] === "gone")) if (dead.size === 0) { @@ -349,24 +378,27 @@ function createWorkspaceTerminalSession(sdk: ReturnType, dir: str const pty = store.all[index] if (!pty) return const estimated = estimateTerminalSize({ cols: pty.cols, rows: pty.rows }) - const next = await client.pty - .create({ - title: pty.title, - cols: estimated.cols, - rows: estimated.rows, - }) - .catch((error: unknown) => { - console.error("Failed to clone terminal", error) - return undefined - }) - if (!next?.data) return + // FORK (P1, 2026-07-09): SDK default `throwOnError:false` returns errors + // via `res.error` instead of throwing. Reading `res.error` is the only + // way to detect a failed create (e.g., when the sidecar is down). + const res = await client.pty.create({ + title: pty.title, + cols: estimated.cols, + rows: estimated.rows, + }) + if (res.error) { + console.error("Failed to clone terminal", res.error) + return + } + const next = res.data + if (!next) return const active = store.active === pty.id batch(() => { setStore("all", index, { - id: next.data.id, - title: next.data.title ?? pty.title, + id: next.id, + title: next.title ?? pty.title, titleNumber: pty.titleNumber, buffer: undefined, cursor: undefined, @@ -375,7 +407,7 @@ function createWorkspaceTerminalSession(sdk: ReturnType, dir: str cols: undefined, }) if (active) { - setStore("active", next.data.id) + setStore("active", next.id) } }) } @@ -433,12 +465,28 @@ function createWorkspaceTerminalSession(sdk: ReturnType, dir: str const index = store.all.findIndex((x) => x.id === id) if (index === -1) return if (!store.all[index]?._pending) return + // FORK (P5 investigation, 2026-07-09): success path of lazy-create. + // Compare with failPending logs to distinguish "lazy-create succeeded, + // then something else killed the tab" vs "lazy-create itself failed". + console.log("[term-investigation] finalizePending", JSON.stringify({ id })) setStore("all", index, (pty) => ({ ...pty, _pending: undefined })) }, failPending(id: string) { const index = store.all.findIndex((x) => x.id === id) if (index === -1) return if (!store.all[index]?._pending) return + // FORK (P5 investigation, 2026-07-09): fail path of lazy-create. + // Cross-validate with the lazy-create error log in terminal.tsx + // (around line 846) to confirm they fire together. + console.log( + "[term-investigation] failPending", + JSON.stringify({ + id, + isActiveTab: store.active === id, + storeActive: store.active, + storeAllIds: store.all.map((p) => p.id), + }), + ) batch(() => { if (store.active === id) { const fallback = index > 0 ? store.all[index - 1]?.id : store.all[1]?.id diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index e504f3b7f6c1..308737b53921 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -700,6 +700,9 @@ export const dict = { "terminal.connectionLost.abnormalClose": "WebSocket closed abnormally: {{code}}", "terminal.connectionLost.description": "The terminal connection was interrupted. This can happen when the server restarts.", + "terminal.selection.copy": "Copy", + "terminal.selection.paste": "Paste", + "terminal.selection.copied": "Copied to clipboard", "common.closeTab": "Close tab", "common.dismiss": "Dismiss", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 38f9a91f2ec3..8c0d89c62d71 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -543,6 +543,9 @@ export const dict = { "terminal.connectionLost.title": "Connexion perdue", "terminal.connectionLost.description": "La connexion au terminal a été interrompue. Cela peut arriver lorsque le serveur redémarre.", + "terminal.selection.copy": "Copier", + "terminal.selection.paste": "Coller", + "terminal.selection.copied": "Copié dans le presse-papiers", "common.closeTab": "Fermer l'onglet", "common.dismiss": "Ignorer", "common.requestFailed": "La demande a échoué", diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 74a220e59bdc..d3a69d851ecd 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1023,12 +1023,16 @@ export default function Page() { reviewSnap={ui.reviewSnap} size={size} /> + + {/* Sibling of SessionSidePanel (not the outer header/keyboard-hints + level) so its mobile full-height overlay (mobile.css + #terminal-panel.mobile-side-panel) covers the session content + without covering SessionHeader — matching SessionSidePanel. */} + {/* FORK: Stretch Phase 6 — keyboard hints bar (tablet + hardware keyboard) */} - - ) } diff --git a/packages/app/src/pages/session/editor-panel.tsx b/packages/app/src/pages/session/editor-panel.tsx index db8ddc014b58..9e70c02299f9 100644 --- a/packages/app/src/pages/session/editor-panel.tsx +++ b/packages/app/src/pages/session/editor-panel.tsx @@ -317,17 +317,24 @@ export function EditorPanel(props: EditorPanelProps) { disabled={props.editorEntry()?.saving} data-testid="editor-save-button" aria-label={language.t("common.save")} + title={language.t("common.save")} classList={{ - "h-8 px-3 inline-flex items-center gap-1.5 rounded-md text-12-medium border transition-colors": true, + "h-8 w-8 flex items-center justify-center rounded-md border transition-colors": true, "border-accent bg-accent text-background": props.editorEntry()?.dirty, "border-border-base text-text-weak hover:text-text-base hover:bg-surface-base-hover": !props.editorEntry()?.dirty, "opacity-50 pointer-events-none": !!props.editorEntry()?.saving, }} > - {language.t("common.save")} diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index f19c19d7c055..5f08cac6a34b 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -309,7 +309,16 @@ export function SessionSidePanel(props: { "pointer-events-none": !open(), }} style={isMobile() - ? { height: open() ? "50vh" : "0px", transition: "height 240ms cubic-bezier(0.22,1,0.36,1)" } + ? { + // Full height (not the previous 50vh half-sheet): browsing + // files/reviewing changes is a primary mobile task, not a + // quick peek — the panel is `position: absolute` over the + // session content (see mobile.css .mobile-side-panel), so + // covering the composer underneath is the intended behavior + // while it's open, not a layout bug. + height: open() ? "100%" : "0px", + transition: "height 240ms cubic-bezier(0.22,1,0.36,1)", + } : { width: panelWidth() } } > diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx index 833229c3ac4f..529495b33b31 100644 --- a/packages/app/src/pages/session/terminal-panel.tsx +++ b/packages/app/src/pages/session/terminal-panel.tsx @@ -5,12 +5,13 @@ import { Tabs } from "@opencode-ai/ui/tabs" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { IconButton } from "@opencode-ai/ui/icon-button" import { TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { showToast } from "@opencode-ai/ui/toast" import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" import type { DragEvent } from "@thisbeyond/solid-dnd" import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" import { SortableTerminalTab } from "@/components/session" -import { Terminal } from "@/components/terminal" +import { Terminal, type TerminalSelectionApi } from "@/components/terminal" import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" @@ -31,7 +32,11 @@ function focusTerminalTextarea(id: string) { function TerminalMobileToolbar(props: { activeId: () => string | undefined sendBytes: (id: string, data: string) => void + hasSelection: () => boolean + onCopy: () => void + onPaste: () => void }) { + const language = useLanguage() const [ctrlActive, setCtrlActive] = createSignal(false) const [altActive, setAltActive] = createSignal(false) @@ -151,6 +156,30 @@ function TerminalMobileToolbar(props: { > ⌨ + + {(k) => (