Skip to content

Commit 24fea69

Browse files
committed
fix(mobile): terminal portrait first-prompt visible via lazy-create + resize debounce
Frontend lazy-create (P1): the Terminal component mounts on a local _pending entry, calls fit.fit() to measure the container, then invokes pty.create with the exact cols/rows. The shell spawns at its final grid size so no SIGWINCH is ever needed and mksh's sigwinch_redisplay() pad never fires — which was overwriting the first prompt in portrait. Backend resize debounce (P3): applyResize() centralizes the resize path and (a) no-ops identical-to-spawn dims, (b) holds the first resize for up to 800ms if no shell output has been seen yet, flushing on first onData. Belt-and-suspenders against late viewport settling. Schema: CreateInput accepts an optional client-provided id (pty_*), so the frontend can keep the id stable across the mount → create → WS flow. SDK v2 regenerated to surface id/cols/rows in PtyCreateData.body. Diagnostic flags kept for this checkpoint — REVERT BEFORE SHIPPING: - MainActivity: WebView.setWebContentsDebuggingEnabled(true) - build.gradle.kts release: isDebuggable = true Validated on Mi 10 Pro (b7163823) in portrait. Bugs #3 (virtual keyboard single vs double tap) and anomalyco#5 (touch scroll) remain open.
1 parent 7b618d3 commit 24fea69

9 files changed

Lines changed: 3974 additions & 893 deletions

File tree

packages/app/src/components/terminal.tsx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { usePlatform } from "@/context/platform"
1313
import { useSDK } from "@/context/sdk"
1414
import { useServer } from "@/context/server"
1515
import { monoFontFamily, useSettings } from "@/context/settings"
16-
import type { LocalPTY } from "@/context/terminal"
16+
import { useTerminal, type LocalPTY } from "@/context/terminal"
1717
import { terminalAttr, terminalProbe } from "@/testing/terminal"
1818
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
1919
import { terminalWriter } from "@/utils/terminal-writer"
@@ -232,6 +232,7 @@ const persistTerminal = (input: {
232232
export const Terminal = (props: TerminalProps) => {
233233
const platform = usePlatform()
234234
const sdk = useSDK()
235+
const terminalCtx = useTerminal()
235236
const settings = useSettings()
236237
const theme = useTheme()
237238
const language = useLanguage()
@@ -642,6 +643,30 @@ export const Terminal = (props: TerminalProps) => {
642643
startResize()
643644
} else {
644645
fit.fit()
646+
if (local.pty._pending) {
647+
// Lazy-create: backend has no session for this id yet. Call
648+
// pty.create with the *exact* grid dims measured above. The shell
649+
// spawns at final size so no SIGWINCH is ever emitted and mksh's
650+
// readline pad-erase redisplay never fires — fixes the portrait
651+
// first-prompt bug at its root.
652+
try {
653+
await client.pty.create({
654+
id,
655+
title: local.pty.title,
656+
cols: t.cols,
657+
rows: t.rows,
658+
})
659+
// Pre-seed lastSize so the immediate scheduleSize below (and the
660+
// ones from WS open / ResizeObserver if dims are still identical)
661+
// are no-ops and never trigger a PUT /pty/:id.
662+
lastSize = { cols: t.cols, rows: t.rows }
663+
terminalCtx.finalizePending(id)
664+
} catch (err) {
665+
addDebug(`pty.create failed: ${err instanceof Error ? err.message : String(err)}`)
666+
terminalCtx.failPending(id)
667+
throw err
668+
}
669+
}
645670
scheduleSize(t.cols, t.rows)
646671
if (restore) {
647672
await write(restore)

packages/app/src/context/terminal.tsx

Lines changed: 84 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,26 @@ export type LocalPTY = {
1717
buffer?: string
1818
scrollY?: number
1919
cursor?: number
20+
// Lazy-create flag. True between the moment the user opens a terminal tab
21+
// and the moment the Terminal component has mounted, measured its
22+
// container, and called sdk.client.pty.create(). During this window no
23+
// backend session exists yet — the shell is spawned at the exact final
24+
// grid dimensions so no SIGWINCH/readline-pad is ever needed. The sweep
25+
// and persist/migrate paths must skip pending entries.
26+
_pending?: boolean
27+
}
28+
29+
// Client-side PTY id generator. Must produce a string matching the server's
30+
// `pty_...` prefix validation (see Identifier.schema in opencode/src/id/id.ts).
31+
// Format mimics ascending ids (timestamp prefix + random suffix) so the
32+
// server's ordering assumptions still hold when the id reaches the backend.
33+
function generateClientPtyId(): string {
34+
const ts = Date.now().toString(36)
35+
const rand =
36+
typeof crypto !== "undefined" && "randomUUID" in crypto
37+
? (crypto as { randomUUID: () => string }).randomUUID().replace(/-/g, "")
38+
: Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2)
39+
return `pty_${ts}${rand}`.slice(0, 30)
2040
}
2141

2242
const WORKSPACE_KEY = "__workspace__"
@@ -74,6 +94,10 @@ function pty(value: unknown): LocalPTY | undefined {
7494
const id = text(value.id)
7595
if (!id) return
7696

97+
// Drop entries that were still pending at persist time — they have no
98+
// backend session to reconnect to, so keeping them only confuses sweep.
99+
if (value._pending === true) return
100+
77101
const title = text(value.title) ?? ""
78102
const number = num(value.titleNumber)
79103
const rows = num(value.rows)
@@ -232,6 +256,26 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
232256
setSweepDone(true)
233257
return
234258
}
259+
// Drop any persisted pending entries — they had no backend session when
260+
// persistence ran (the Terminal component never completed the lazy
261+
// pty.create call), so nothing to reconnect to. Also saves us from
262+
// querying the server with ids it has never seen.
263+
const pendingIds = new Set(store.all.filter((pty) => pty._pending).map((pty) => pty.id))
264+
if (pendingIds.size > 0) {
265+
batch(() => {
266+
setStore(
267+
"all",
268+
produce((draft) => {
269+
for (let i = draft.length - 1; i >= 0; i--) {
270+
if (pendingIds.has(draft[i].id)) draft.splice(i, 1)
271+
}
272+
}),
273+
)
274+
if (store.active && pendingIds.has(store.active)) {
275+
setStore("active", store.all[0]?.id)
276+
}
277+
})
278+
}
235279
const ids = store.all.map((pty) => pty.id)
236280
if (ids.length === 0) {
237281
setSweepDone(true)
@@ -346,29 +390,44 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
346390
},
347391
new() {
348392
const nextNumber = pickNextTerminalNumber()
349-
const estimated = estimateTerminalSize()
350-
351-
sdk.client.pty
352-
.create({ title: defaultTitle(nextNumber), cols: estimated.cols, rows: estimated.rows })
353-
.then((pty: { data?: { id?: string; title?: string } }) => {
354-
const id = pty.data?.id
355-
if (!id) return
356-
const newTerminal = {
357-
id,
358-
title: pty.data?.title ?? defaultTitle(nextNumber),
359-
titleNumber: nextNumber,
360-
}
361-
setStore("all", store.all.length, newTerminal)
362-
setStore("active", id)
363-
})
364-
.catch((error: unknown) => {
365-
console.error("Failed to create terminal", error)
366-
showToast({
367-
variant: "error",
368-
title: "Terminal",
369-
description: error instanceof Error ? error.message : "Failed to create terminal session",
370-
})
371-
})
393+
// Lazy-create: no backend call here. The Terminal component mounts on
394+
// this _pending entry, measures its container with fit.fit(), and calls
395+
// sdk.client.pty.create() with the *exact* grid dims. The shell spawns
396+
// at its final size so no initial resize/SIGWINCH is needed.
397+
const id = generateClientPtyId()
398+
const pending: LocalPTY = {
399+
id,
400+
title: defaultTitle(nextNumber),
401+
titleNumber: nextNumber,
402+
_pending: true,
403+
}
404+
batch(() => {
405+
setStore("all", store.all.length, pending)
406+
setStore("active", id)
407+
})
408+
},
409+
finalizePending(id: string) {
410+
const index = store.all.findIndex((x) => x.id === id)
411+
if (index === -1) return
412+
if (!store.all[index]?._pending) return
413+
setStore("all", index, (pty) => ({ ...pty, _pending: undefined }))
414+
},
415+
failPending(id: string) {
416+
const index = store.all.findIndex((x) => x.id === id)
417+
if (index === -1) return
418+
if (!store.all[index]?._pending) return
419+
batch(() => {
420+
if (store.active === id) {
421+
const fallback = index > 0 ? store.all[index - 1]?.id : store.all[1]?.id
422+
setStore("active", fallback)
423+
}
424+
setStore(
425+
"all",
426+
produce((draft) => {
427+
draft.splice(index, 1)
428+
}),
429+
)
430+
})
372431
},
373432
update(pty: Partial<LocalPTY> & { id: string }) {
374433
update(sdk.client, pty)
@@ -524,6 +583,8 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
524583
all: () => workspace().all(),
525584
active: () => workspace().active(),
526585
new: () => workspace().new(),
586+
finalizePending: (id: string) => workspace().finalizePending(id),
587+
failPending: (id: string) => workspace().failPending(id),
527588
update: (pty: Partial<LocalPTY> & { id: string }) => workspace().update(pty),
528589
trim: (id: string) => workspace().trim(id),
529590
trimAll: () => workspace().trimAll(),

packages/mobile/src-tauri/assets/runtime/opencode-cli.js

Lines changed: 71 additions & 5 deletions
Large diffs are not rendered by default.

packages/mobile/src-tauri/gen/android/app/build.gradle.kts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,12 @@ android {
5353
}
5454
}
5555
getByName("release") {
56-
isDebuggable = false
56+
// Diagnostic-only: isDebuggable=true exposes the WebView to
57+
// chrome://inspect (see MainActivity.setWebContentsDebuggingEnabled).
58+
// Revert to false before shipping. Required to investigate the
59+
// portrait first-prompt bug — the artefact reproduces only on
60+
// release builds (minified/shrunk asset paths differ).
61+
isDebuggable = true
5762
isJniDebuggable = false
5863
isMinifyEnabled = true
5964
isShrinkResources = true

packages/mobile/src-tauri/gen/android/app/src/main/assets/runtime/opencode-cli.js

Lines changed: 3383 additions & 860 deletions
Large diffs are not rendered by default.

packages/mobile/src-tauri/gen/android/app/src/main/java/ai/opencode/mobile/MainActivity.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import android.os.Bundle
99
import android.os.Environment
1010
import android.provider.Settings
1111
import android.view.WindowManager
12+
import android.webkit.WebView
1213
import androidx.activity.enableEdgeToEdge
1314
import androidx.core.app.ActivityCompat
1415
import androidx.core.content.ContextCompat
@@ -19,6 +20,13 @@ class MainActivity : TauriActivity() {
1920
enableEdgeToEdge()
2021
super.onCreate(savedInstanceState)
2122

23+
// Diagnostic-only: expose the WebView to `chrome://inspect` on the host
24+
// PC so we can live-inspect DOM / console / WebSocket frames. Required to
25+
// investigate the portrait first-prompt bug (see plan doc). Remove before
26+
// shipping to end users — this is not a debug-only build guard because
27+
// release builds are where the bug reproduces.
28+
WebView.setWebContentsDebuggingEnabled(true)
29+
2230
// Start LlamaService (Foreground Service) FIRST so it's alive before any
2331
// llama-server spawn. LlamaService keeps the whole process tree at adj=0
2432
// (foreground), exempt from Android PhantomProcessKiller and MIUI

packages/opencode/src/pty/index.ts

Lines changed: 107 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,27 @@ export namespace Pty {
3333
bufferCursor: number
3434
cursor: number
3535
subscribers: Map<unknown, Socket>
36+
// Mobile portrait first-prompt fix: track the dims the shell was spawned
37+
// with so we can no-op identical resize requests (the frontend's initial
38+
// fit.fit() produces the same numbers as estimateTerminalSize() on matching
39+
// viewports), and we record the spawn timestamp so we can delay the first
40+
// SIGWINCH until after mksh has emitted its PS1 and entered its readline
41+
// loop. Sending SIGWINCH during that window triggers sigwinch_redisplay()
42+
// which pads the line with spaces and overwrites the prompt.
43+
spawnedAt: number
44+
spawnCols: number
45+
spawnRows: number
46+
firstOutputAt: number | undefined
47+
pendingResize: { cols: number; rows: number; timer: ReturnType<typeof setTimeout> } | undefined
3648
}
3749

50+
// Hold the first post-spawn resize for this long if it arrives before any
51+
// shell output — gives mksh time to source rc files, emit PS1 and initialize
52+
// readline. Measured locally: PS1 arrives within ~120ms on cold boot; 800ms
53+
// covers worst-case .profile sourcing. If output is seen earlier, the queued
54+
// resize is flushed immediately.
55+
const SPAWN_SIGWINCH_HOLD_MS = 800
56+
3857
type State = {
3958
dir: string
4059
sessions: Map<PtyID, Active>
@@ -85,6 +104,14 @@ export namespace Pty {
85104
cwd: z.string().optional(),
86105
title: z.string().optional(),
87106
env: z.record(z.string(), z.string()).optional(),
107+
// Optional client-provided session id. Used by the mobile frontend's
108+
// lazy-create flow: the Terminal component mounts first, measures its
109+
// container, calls fit() to get the *exact* grid size, and only then
110+
// calls pty.create with that id + the measured dims. The shell is then
111+
// born at the final size so no SIGWINCH is ever needed and readline's
112+
// pad-erase redisplay never fires — fixing the portrait first-prompt
113+
// bug at its root. When omitted, the server generates one as before.
114+
id: z.string().startsWith("pty_").optional(),
88115
// Initial PTY dimensions. When omitted the platform default (80x24) is
89116
// used, which on mobile webviews causes the first shell prompt to be
90117
// dropped: the shell writes its prompt at 80x24, the frontend fit()s
@@ -140,6 +167,10 @@ export namespace Pty {
140167
const bus = yield* Bus.Service
141168
const plugin = yield* Plugin.Service
142169
function teardown(session: Active) {
170+
if (session.pendingResize) {
171+
clearTimeout(session.pendingResize.timer)
172+
session.pendingResize = undefined
173+
}
143174
try {
144175
session.process.kill()
145176
} catch {}
@@ -193,7 +224,12 @@ export namespace Pty {
193224

194225
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
195226
const s = yield* InstanceState.get(state)
196-
const id = PtyID.ascending()
227+
// Use the client-provided id if present (lazy-create flow), else mint
228+
// a fresh ascending id. PtyID.ascending(given) validates the prefix.
229+
if (input.id && s.sessions.has(input.id as PtyID)) {
230+
return s.sessions.get(input.id as PtyID)!.info
231+
}
232+
const id = input.id ? PtyID.ascending(input.id) : PtyID.ascending()
197233
const command = input.command || Shell.preferred()
198234
const args = input.args || []
199235
if (Shell.login(command)) {
@@ -260,11 +296,35 @@ export namespace Pty {
260296
bufferCursor: 0,
261297
cursor: 0,
262298
subscribers: new Map(),
299+
spawnedAt: Date.now(),
300+
spawnCols: input.cols ?? 80,
301+
spawnRows: input.rows ?? 24,
302+
firstOutputAt: undefined,
303+
pendingResize: undefined,
263304
}
264305
s.sessions.set(id, session)
265306
proc.onData(
266307
Instance.bind((chunk) => {
267308
session.cursor += chunk.length
309+
if (session.firstOutputAt === undefined) {
310+
session.firstOutputAt = Date.now()
311+
// Flush any resize that was held while we waited for the shell
312+
// to emit its prompt. Deferring until after output reaches
313+
// readline means SIGWINCH lands in a state where mksh only
314+
// updates its internal COLUMNS without repainting (no pad).
315+
const pending = session.pendingResize
316+
if (pending) {
317+
clearTimeout(pending.timer)
318+
session.pendingResize = undefined
319+
try {
320+
session.process.resize(pending.cols, pending.rows)
321+
session.spawnCols = pending.cols
322+
session.spawnRows = pending.rows
323+
} catch (err) {
324+
log.info("deferred resize failed", { id, err: String(err) })
325+
}
326+
}
327+
}
268328

269329
for (const [key, ws] of session.subscribers.entries()) {
270330
if (ws.readyState !== 1) {
@@ -310,17 +370,61 @@ export namespace Pty {
310370
session.info.title = input.title
311371
}
312372
if (input.size) {
313-
session.process.resize(input.size.cols, input.size.rows)
373+
applyResize(session, input.size.cols, input.size.rows)
314374
}
315375
yield* bus.publish(Event.Updated, { info: session.info })
316376
return session.info
317377
})
318378

379+
// Mobile portrait first-prompt guard. Three cases:
380+
// 1. Identical to spawn dims — skip ioctl+SIGWINCH entirely. No kernel
381+
// state change needed, no signal to trigger readline pad.
382+
// 2. Shell hasn't emitted output yet and we're inside the hold window —
383+
// queue the resize; it is flushed either by the first onData callback
384+
// (see create handler) or by a fallback timer at hold-deadline.
385+
// 3. Anything else — resize immediately (genuine mid-session resize).
386+
function applyResize(session: Active, cols: number, rows: number) {
387+
if (cols === session.spawnCols && rows === session.spawnRows) {
388+
if (session.pendingResize) {
389+
clearTimeout(session.pendingResize.timer)
390+
session.pendingResize = undefined
391+
}
392+
return
393+
}
394+
const sinceSpawn = Date.now() - session.spawnedAt
395+
if (session.firstOutputAt === undefined && sinceSpawn < SPAWN_SIGWINCH_HOLD_MS) {
396+
if (session.pendingResize) {
397+
clearTimeout(session.pendingResize.timer)
398+
}
399+
const timer = setTimeout(() => {
400+
const pending = session.pendingResize
401+
if (!pending) return
402+
session.pendingResize = undefined
403+
try {
404+
session.process.resize(pending.cols, pending.rows)
405+
session.spawnCols = pending.cols
406+
session.spawnRows = pending.rows
407+
} catch (err) {
408+
log.info("held resize fallback failed", { err: String(err) })
409+
}
410+
}, Math.max(0, SPAWN_SIGWINCH_HOLD_MS - sinceSpawn))
411+
session.pendingResize = { cols, rows, timer }
412+
return
413+
}
414+
try {
415+
session.process.resize(cols, rows)
416+
session.spawnCols = cols
417+
session.spawnRows = rows
418+
} catch (err) {
419+
log.info("resize failed", { err: String(err) })
420+
}
421+
}
422+
319423
const resize = Effect.fn("Pty.resize")(function* (id: PtyID, cols: number, rows: number) {
320424
const s = yield* InstanceState.get(state)
321425
const session = s.sessions.get(id)
322426
if (session && session.info.status === "running") {
323-
session.process.resize(cols, rows)
427+
applyResize(session, cols, rows)
324428
}
325429
})
326430

0 commit comments

Comments
 (0)