Skip to content

Commit 7b618d3

Browse files
committed
feat(mobile): terminal toolbar + native vi + auto-install Alpine tools
- feat(terminal): mobile toolbar sends raw bytes via new onSend prop on <Terminal>. Toolbar exposes Esc, Tab, arrows, Home/End, PgUp/PgDn, Del, F1-F12, `:`, `|`, `/`, `~`, and sticky Ctrl/Alt modifier toggles. Fixes Vim/less/htop/... which failed because the previous synthetic KeyboardEvent dispatch sent code:"c" where ghostty-web's mapKeyCode expects USB-HID "KeyC". - fix(mobile-runtime): route vi/vim/less/more/top/sed and ~80 other interactive applets to /system/bin/toybox (dynamic-bionic, seccomp- safe) instead of libbusybox_exec.so. Busybox is static-linked and uses modern syscalls blocked by Android zygote seccomp, so vi hit SIGSYS ("bad system call"). Also symlink runtime/bin/ to system /system/bin/curl|strace|wget|awk when present. - feat(mobile-runtime): install_extended_env now auto-installs ~30 developer tools (nano, git, tmux, screen, openssh-client, rsync, python3, nodejs, npm, jq, tree, htop, fzf, fd, bat, exa, ripgrep, make, patch, vim, ...) via apk add after the Alpine rootfs is extracted, then creates proot-run wrappers in runtime/bin/ so each tool is callable by name without any user-visible setup step. - feat(mobile-boot): ExtractionProgress (first-launch screen) now chains extractRuntime then installExtendedEnv, so new users get the full toolchain automatically — no hidden menu, no manual command. Progress bar and "~3 min, ~90MB download" hint updated accordingly. - ship diagnostic shim libsigsys_trace.so (dormant; LD_PRELOAD-able for future bionic-target syscall tracing — no effect on static busybox where it was first tested). Remaining: first-prompt-invisible-in-portrait bug is still open. Four hypotheses tested and reverted (no effect observed). Synthesis with device-captured observations and five pistes is in MOBILE_TERMINAL_STATE.md for the human taking over.
1 parent 86e4a99 commit 7b618d3

8 files changed

Lines changed: 655 additions & 66 deletions

File tree

MOBILE_TERMINAL_STATE.md

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# État des bugs terminal mobile OpenCode — handoff humain
2+
3+
## Bug #2 ✅ RÉSOLU — Touches avancées (Vim/Esc/Ctrl/flèches)
4+
5+
**Fix appliqué** : Réécriture de `TerminalMobileToolbar` pour envoyer des octets bruts via un handle `onSend` exposé par le composant `<Terminal>`.
6+
7+
**Fichiers modifiés** :
8+
- `packages/app/src/components/terminal.tsx` — prop `onSend` (lignes 30, 262, 816-820)
9+
- `packages/app/src/pages/session/terminal-panel.tsx` — toolbar rewrite (lignes 27-164) + consumer (lignes 426-430, 451)
10+
11+
**Résultat confirmé par l'utilisateur** : toolbar avec Esc, Tab, flèches, Home, End, PgUp, PgDn, Del, F1-F12, `:`, `|`, `/`, `~`, Ctrl/Alt toggles fonctionne.
12+
13+
---
14+
15+
## Bug #3 ✅ FIX EN PLACE — `vi` "bad system call"
16+
17+
**Root cause identifiée** : `libbusybox_exec.so` est un binaire **statique** (confirmé par `file`). Lancé, il fait des syscalls directs ; certains (probablement `rseq`, `statx`, `clone3` — non identifié avec précision) sont bloqués par le seccomp-bpf filter du zygote Android. Résultat : SIGSYS → "bad system call".
18+
19+
**LD_PRELOAD ne fonctionne pas** sur un binaire statique → impossible d'instrumenter busybox via shim.
20+
21+
**Fix appliqué** : router `vi`, `vim`, `less`, `more`, `top`, `sed` vers `/system/bin/toybox` (dynamic-bionic, compatible seccomp zygote Android). Les applets toybox Android 0.8.6+ incluent `vi`.
22+
23+
**Fichier modifié** :
24+
- `packages/mobile/src-tauri/src/runtime.rs` (lignes 318-357, 363-368)
25+
26+
**Validation effectuée** :
27+
- `file libbusybox_exec.so` → "ELF executable, static"
28+
- `toybox vi --help` fonctionne (applet présent sur ce device Xiaomi)
29+
- Test manuel : symlink `/data/data/.../tmp/vi → /system/bin/toybox` exécuté via pty_server → vi ouvre sans SIGSYS, accepte `:wq`
30+
31+
**À tester par l'humain** : ouvrir terminal dans l'app → `vi /tmp/test.txt` → doit ouvrir vi natif Android (toybox). Si SELinux bloque l'exec de `/system/bin/*` depuis le sandbox app (commentaire ligne 270-271 du runtime.rs mentionne ce risque), le fix pourra échouer et il faudra shipper un vim NDK bionic dans `assets/runtime/bin/` via `prepare-android-runtime.sh`.
32+
33+
---
34+
35+
## Bug #1 ❌ NON RÉSOLU — Prompt invisible en portrait (flash puis disparaît)
36+
37+
**Hypothèses testées et RÉFUTÉES par les observations device** :
38+
1. Timing de render ghostty-web → Ctrl-L kick via `requestAnimationFrame` après 1er byte : aucun effet
39+
2. Android WebView viewport metadata → `interactive-widget=resizes-content` : aucun effet
40+
3. Line-height 150% hérité de `base.css` sur `.xterm-*``line-height: normal !important` : aucun effet
41+
4. Padding excessif `px-6 py-3` sur container → custom property `--terminal-px/py` réduit sur mobile : aucun effet
42+
43+
**Toutes ces tentatives ont été revert** pour laisser le code dans un état propre.
44+
45+
**Observations factuelles depuis le device (capture via `chrome://inspect`)** :
46+
```
47+
t.open OK container=393x196 inDOM=true t.cols=80 t.rows=24 vp=392x851 vvh=851
48+
SIZE[t.onResize] ?x?→36x12
49+
TIMER[50ms] container=393x196 t=36x12 vvh=851
50+
TIMER[200ms] container=393x196 t=36x12 vvh=851
51+
WS first msg: text(36ch)
52+
firstByte bytes=36 userTyped=false t=36x12 vvh=851
53+
TIMER[500ms] container=393x196 t=36x12 vvh=851
54+
vvp.resize vvh=851→803 ← chute spontanée de 48px
55+
vvp.resize vvh=803→548 ← clavier virtuel monte (spontanément, user n'a rien tapé)
56+
```
57+
58+
**Comportement reporté par l'utilisateur** : "le prompt apparaît en flash puis disparaît". En **paysage** il est visible, en **portrait** il n'est visible que brièvement.
59+
60+
**Pistes pour l'humain** :
61+
62+
1. **Le clavier virtuel monte spontanément sans interaction** — pourquoi ? (focus automatique sur le textarea caché de ghostty-web ?) La séquence `vvp.resize 851→803→548` se produit ~1100ms après l'ouverture, sans frappe utilisateur. Identifier ce qui déclenche le focus.
63+
64+
2. **Le container reste à `393x196`px** même quand `visualViewport.height` passe à 548. Le `#terminal-panel` ne se contracte pas avec le clavier. À investiguer : pourquoi le layout ne suit pas visualViewport ?
65+
66+
3. **Position du `#terminal-panel` dans le layout mobile** : à inspecter via `chrome://inspect` pendant le flash. Hypothèse : le panel est positionné en bas de l'écran (document-relative) ; quand le clavier monte, il passe sous le clavier.
67+
68+
4. **Test alternatif suggéré** : utiliser `position: fixed; bottom: 0` sur `#terminal-panel` avec une hauteur calculée dynamiquement depuis `visualViewport.height - composerHeight - tabsHeight`. Via JS, écouter `visualViewport.resize` et mettre à jour la hauteur.
69+
70+
5. **ghostty-web est canvas-based** — la "disparition" pourrait être un canvas clear/reflow interne déclenché par un resize ou un event. Instrumenter via `chrome://inspect` pendant le flash.
71+
72+
**Outils à disposition** :
73+
- `chrome://inspect/#devices` fonctionne (WebView debuggable)
74+
- Les logs `[terminal-debug]` basiques existent toujours dans `terminal.tsx` (addDebug)
75+
- Sur device : `/data/data/ai.opencode.mobile/tmp/` est writable pour logs custom si besoin
76+
77+
---
78+
79+
## État du code (changements actifs dans le repo)
80+
81+
### Bug #2 (gardé, fonctionne) :
82+
- `packages/app/src/components/terminal.tsx` — prop `onSend`
83+
- `packages/app/src/pages/session/terminal-panel.tsx` — toolbar rewrite
84+
85+
### Bug #3 (gardé, devrait fonctionner) :
86+
- `packages/mobile/src-tauri/src/runtime.rs` — symlinks vi/vim → /system/bin/toybox
87+
88+
### Diagnostic dormant (peut être retiré si non utile) :
89+
- `packages/mobile/src-tauri/gen/android/app/src/main/jni/sigsys_trace.c` (nouveau, LD_PRELOAD shim)
90+
- `packages/mobile/src-tauri/gen/android/app/src/main/jni/CMakeLists.txt` — entrée sigsys_trace ajoutée
91+
92+
### Reverted (aucun effet, supprimés) :
93+
- `packages/ui/src/styles/base.css` — reset line-height (retiré)
94+
- `packages/mobile/src/mobile.css` — --terminal-px/py override (retiré)
95+
- `packages/mobile/index.html` — interactive-widget viewport meta (retiré)
96+
- `packages/app/src/components/terminal.tsx` — Ctrl-L kick, diagnostic logs verbeux (retirés)
97+
98+
---
99+
100+
## Build + install
101+
102+
```bash
103+
cd packages/mobile && \
104+
ORT_LIB_LOCATION="$PWD/src-tauri/gen/android/app/src/main/jniLibs/arm64-v8a" \
105+
bunx tauri android build --debug --target aarch64
106+
107+
adb uninstall ai.opencode.mobile
108+
adb install src-tauri/gen/android/app/build/outputs/apk/universal/debug/app-universal-debug.apk
109+
```
110+
111+
**Dernière APK installée** : état 2026-04-20 ~18:55, bug #2 OK, bug #3 fix en place, bug #1 non résolu.
112+
113+
## Vérifications rapides
114+
115+
```bash
116+
# Bug #3 : après avoir ouvert le terminal dans l'app une fois
117+
adb shell "run-as ai.opencode.mobile readlink /data/user/0/ai.opencode.mobile/runtime/bin/vi"
118+
# Attendu : /system/bin/toybox
119+
```

packages/app/src/components/terminal.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export interface TerminalProps extends ComponentProps<"div"> {
2727
onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void
2828
onConnect?: () => void
2929
onConnectError?: (error: unknown) => void
30+
onSend?: (fn: ((data: string) => void) | undefined) => void
3031
}
3132

3233
let shared: Promise<{ mod: typeof import("ghostty-web"); ghostty: Ghostty | undefined }> | undefined
@@ -258,7 +259,7 @@ export const Terminal = (props: TerminalProps) => {
258259
console.info("[terminal-debug]", msg)
259260
}
260261
let container!: HTMLDivElement
261-
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
262+
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError", "onSend"])
262263
const id = local.pty.id
263264
const probe = terminalProbe(id)
264265
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
@@ -812,6 +813,12 @@ export const Terminal = (props: TerminalProps) => {
812813
},
813814
})
814815

816+
const sendBytes = (data: string) => {
817+
if (ws?.readyState === WebSocket.OPEN) ws.send(data)
818+
}
819+
local.onSend?.(sendBytes)
820+
cleanups.push(() => local.onSend?.(undefined))
821+
815822
open()
816823
}
817824

packages/app/src/pages/session/terminal-panel.tsx

Lines changed: 95 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -23,43 +23,86 @@ import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
2323
import { useSessionLayout } from "@/pages/session/session-layout"
2424
import { terminalProbe } from "@/testing/terminal"
2525

26-
/** Send a key sequence to the active terminal by dispatching a KeyboardEvent to its textarea. */
27-
function sendTerminalKey(id: string, key: string, opts?: { ctrlKey?: boolean; code?: string }) {
26+
function focusTerminalTextarea(id: string) {
2827
const wrapper = document.getElementById(`terminal-wrapper-${id}`)
29-
if (!wrapper) return
30-
const textarea = wrapper.querySelector("textarea")
31-
if (!textarea) return
32-
textarea.focus()
33-
const event = new KeyboardEvent("keydown", {
34-
key,
35-
code: opts?.code ?? key,
36-
ctrlKey: opts?.ctrlKey ?? false,
37-
bubbles: true,
38-
cancelable: true,
39-
})
40-
textarea.dispatchEvent(event)
28+
const textarea = wrapper?.querySelector("textarea")
29+
if (textarea && document.activeElement !== textarea) textarea.focus()
4130
}
4231

43-
function TerminalMobileToolbar(props: { activeId: () => string | undefined }) {
32+
function TerminalMobileToolbar(props: {
33+
activeId: () => string | undefined
34+
sendBytes: (id: string, data: string) => void
35+
}) {
4436
const [ctrlActive, setCtrlActive] = createSignal(false)
37+
const [altActive, setAltActive] = createSignal(false)
4538

46-
const send = (key: string, code?: string) => {
39+
function emit(data: string) {
4740
const id = props.activeId()
4841
if (!id) return
49-
sendTerminalKey(id, key, { ctrlKey: ctrlActive(), code })
50-
if (ctrlActive()) setCtrlActive(false)
42+
props.sendBytes(id, data)
43+
focusTerminalTextarea(id)
44+
}
45+
46+
function sendKey(bytes: string) {
47+
let out = bytes
48+
if (altActive()) {
49+
out = "\x1b" + out
50+
setAltActive(false)
51+
}
52+
emit(out)
5153
}
5254

53-
const keys = [
54-
{ label: "Esc", action: () => send("Escape", "Escape") },
55-
{ label: "Tab", action: () => send("Tab", "Tab") },
56-
{ label: "↑", action: () => send("ArrowUp", "ArrowUp") },
57-
{ label: "↓", action: () => send("ArrowDown", "ArrowDown") },
58-
{ label: "←", action: () => send("ArrowLeft", "ArrowLeft") },
59-
{ label: "→", action: () => send("ArrowRight", "ArrowRight") },
60-
{ label: "|", action: () => send("|") },
61-
{ label: "/", action: () => send("/") },
62-
{ label: "~", action: () => send("~") },
55+
function sendChar(ch: string) {
56+
if (ctrlActive()) {
57+
const upper = ch.toUpperCase()
58+
const code = upper.charCodeAt(0)
59+
if (code >= 0x40 && code <= 0x5f) {
60+
let byte = String.fromCharCode(code - 0x40)
61+
if (altActive()) {
62+
byte = "\x1b" + byte
63+
setAltActive(false)
64+
}
65+
emit(byte)
66+
setCtrlActive(false)
67+
return
68+
}
69+
setCtrlActive(false)
70+
}
71+
sendKey(ch)
72+
}
73+
74+
const btnBase = "shrink-0 px-3 h-8 rounded-md text-13-medium border"
75+
const btnNormal = "bg-surface-base text-text-base border-border-base active:bg-surface-base-active"
76+
const btnActive = "bg-text-strong text-background-base border-text-strong"
77+
78+
const keys: { label: string; action: () => void }[] = [
79+
{ label: "Esc", action: () => sendKey("\x1b") },
80+
{ label: "Tab", action: () => sendKey("\t") },
81+
{ label: "↑", action: () => sendKey("\x1b[A") },
82+
{ label: "↓", action: () => sendKey("\x1b[B") },
83+
{ label: "→", action: () => sendKey("\x1b[C") },
84+
{ label: "←", action: () => sendKey("\x1b[D") },
85+
{ label: "Home", action: () => sendKey("\x1b[H") },
86+
{ label: "End", action: () => sendKey("\x1b[F") },
87+
{ label: "PgUp", action: () => sendKey("\x1b[5~") },
88+
{ label: "PgDn", action: () => sendKey("\x1b[6~") },
89+
{ label: "Del", action: () => sendKey("\x1b[3~") },
90+
{ label: "F1", action: () => sendKey("\x1bOP") },
91+
{ label: "F2", action: () => sendKey("\x1bOQ") },
92+
{ label: "F3", action: () => sendKey("\x1bOR") },
93+
{ label: "F4", action: () => sendKey("\x1bOS") },
94+
{ label: "F5", action: () => sendKey("\x1b[15~") },
95+
{ label: "F6", action: () => sendKey("\x1b[17~") },
96+
{ label: "F7", action: () => sendKey("\x1b[18~") },
97+
{ label: "F8", action: () => sendKey("\x1b[19~") },
98+
{ label: "F9", action: () => sendKey("\x1b[20~") },
99+
{ label: "F10", action: () => sendKey("\x1b[21~") },
100+
{ label: "F11", action: () => sendKey("\x1b[23~") },
101+
{ label: "F12", action: () => sendKey("\x1b[24~") },
102+
{ label: ":", action: () => sendChar(":") },
103+
{ label: "|", action: () => sendChar("|") },
104+
{ label: "/", action: () => sendChar("/") },
105+
{ label: "~", action: () => sendChar("~") },
63106
]
64107

65108
return (
@@ -70,10 +113,10 @@ function TerminalMobileToolbar(props: { activeId: () => string | undefined }) {
70113
>
71114
<button
72115
type="button"
73-
class="shrink-0 px-3 h-8 rounded-md text-13-medium border"
116+
class={btnBase}
74117
classList={{
75-
"bg-text-strong text-background-base border-text-strong": ctrlActive(),
76-
"bg-surface-base text-text-base border-border-base": !ctrlActive(),
118+
[btnActive]: ctrlActive(),
119+
[btnNormal]: !ctrlActive(),
77120
}}
78121
onPointerDown={(e) => {
79122
e.preventDefault()
@@ -82,11 +125,25 @@ function TerminalMobileToolbar(props: { activeId: () => string | undefined }) {
82125
>
83126
Ctrl
84127
</button>
128+
<button
129+
type="button"
130+
class={btnBase}
131+
classList={{
132+
[btnActive]: altActive(),
133+
[btnNormal]: !altActive(),
134+
}}
135+
onPointerDown={(e) => {
136+
e.preventDefault()
137+
setAltActive(!altActive())
138+
}}
139+
>
140+
Alt
141+
</button>
85142
<For each={keys}>
86143
{(k) => (
87144
<button
88145
type="button"
89-
class="shrink-0 px-3 h-8 rounded-md text-13-medium bg-surface-base text-text-base border border-border-base active:bg-surface-base-active"
146+
class={`${btnBase} ${btnNormal}`}
90147
onPointerDown={(e) => {
91148
e.preventDefault()
92149
k.action()
@@ -115,6 +172,7 @@ export function TerminalPanel() {
115172
const height = createMemo(() => layout.terminal.height())
116173
const close = () => view().terminal.close()
117174
let root: HTMLDivElement | undefined
175+
const sendHandles = new Map<string, (data: string) => void>()
118176

119177
const [store, setStore] = createStore({
120178
autoCreated: false,
@@ -365,7 +423,10 @@ export function TerminalPanel() {
365423
</Tabs.List>
366424
</Tabs>
367425
<Show when={isMobile()}>
368-
<TerminalMobileToolbar activeId={() => terminal.active()} />
426+
<TerminalMobileToolbar
427+
activeId={() => terminal.active()}
428+
sendBytes={(id, data) => sendHandles.get(id)?.(data)}
429+
/>
369430
</Show>
370431
<div class="flex-1 min-h-0 relative">
371432
{(() => {
@@ -387,6 +448,7 @@ export function TerminalPanel() {
387448
onConnect={() => ops.trim(pty.id)}
388449
onCleanup={ops.update}
389450
onConnectError={() => ops.clone(pty.id)}
451+
onSend={(fn) => { if (fn) sendHandles.set(pty.id, fn); else sendHandles.delete(pty.id) }}
390452
/>
391453
</div>
392454
)}

packages/mobile/src-tauri/gen/android/app/src/main/jni/CMakeLists.txt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,19 @@ target_link_libraries(llama_jni
5555
add_library(rust_pty SHARED rust_pty.c)
5656
target_link_libraries(rust_pty android log)
5757

58+
# ─── SIGSYS trace shim ───────────────────────────────────────────────
59+
# LD_PRELOAD into child processes spawned by pty_server to capture the
60+
# exact seccomp-blocked syscall number. Diagnostic-only; remove once
61+
# the blocking syscall is identified and worked around.
62+
add_library(sigsys_trace SHARED sigsys_trace.c)
63+
target_link_libraries(sigsys_trace log)
64+
65+
# pty_server.c is NOT built here — CMake add_library produces a .so that
66+
# cannot be executed via ProcessBuilder (needs PIE executable). The pre-
67+
# built binary in jniLibs/arm64-v8a/libpty_server.so (PIE) is used as-is.
68+
# To instrument pty_server's children, set LD_PRELOAD in LlamaService.kt
69+
# before ProcessBuilder.start() — children inherit env via fork+exec.
70+
5871
# NOTE: resolv_override.c is NOT built here. NDK compiles against Bionic,
5972
# but bun uses musl — a Bionic .so cannot be LD_PRELOADed into a musl process.
6073
# DNS is handled via dns.setServers() in mobile-entry.ts.

0 commit comments

Comments
 (0)