From 73cd4c3f46576b191a7625373bdc628120650dcf Mon Sep 17 00:00:00 2001
From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com>
Date: Wed, 5 Aug 2026 13:52:14 -0700
Subject: [PATCH] fix(mobile): bound live terminal input latency (#12763)
---
.../app/h/[hostId]/session/[worktreeId].tsx | 1 +
.../session/MobileTerminalLiveInputStatus.tsx | 8 +-
.../terminal-live-input-affordance.test.ts | 3 +
.../terminal-live-pending-flush-state.test.ts | 77 ++++++++++--
.../terminal-live-pending-flush-state.ts | 111 +++++++++++++++---
.../use-terminal-live-pending-input-flush.ts | 24 +++-
6 files changed, 184 insertions(+), 40 deletions(-)
diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx
index c5e4175ac..755d66055 100644
--- a/mobile/app/h/[hostId]/session/[worktreeId].tsx
+++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx
@@ -4986,6 +4986,7 @@ export default function SessionScreen() {
{title}
-
+
{detail}
diff --git a/mobile/src/terminal/terminal-live-input-affordance.test.ts b/mobile/src/terminal/terminal-live-input-affordance.test.ts
index 0125cb053..ce360091c 100644
--- a/mobile/src/terminal/terminal-live-input-affordance.test.ts
+++ b/mobile/src/terminal/terminal-live-input-affordance.test.ts
@@ -39,6 +39,7 @@ describe('terminal live input affordance', () => {
expect(block).toContain('pressed && styles.liveInputFocusTargetPressed')
expect(block).toContain('!canSend && styles.liveInputFocusTargetDisabled')
expect(block).toContain('showSoftInputOnFocus')
+ expect(block).toContain('liveInputText={liveInputCapture}')
expect(sessionRouteSource).toContain('useTerminalLiveInputFocus({')
expect(sessionRouteSource).toContain('return resetLiveInputFocus')
expect(liveInputFocusSource).toContain('focusTerminalLiveInputTarget(inputRef.current')
@@ -52,6 +53,8 @@ describe('terminal live input affordance', () => {
it('makes the live keyboard target visible instead of status-only chrome', () => {
expect(liveInputStatusSource).toContain("'Tap to show keyboard'")
+ expect(liveInputStatusSource).toContain("liveInputText || 'Tap to show keyboard'")
+ expect(liveInputStatusSource).toContain('ellipsizeMode="head"')
expect(commandInputStylesSource).toContain('backgroundColor: colors.bgRaised')
expect(commandInputStylesSource).toContain('borderWidth: 1')
expect(commandInputStylesSource).toContain('liveInputFocusTargetPressed')
diff --git a/mobile/src/terminal/terminal-live-pending-flush-state.test.ts b/mobile/src/terminal/terminal-live-pending-flush-state.test.ts
index 88918c006..a77d4315c 100644
--- a/mobile/src/terminal/terminal-live-pending-flush-state.test.ts
+++ b/mobile/src/terminal/terminal-live-pending-flush-state.test.ts
@@ -1,15 +1,16 @@
import { describe, expect, it } from 'vitest'
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
import {
+ cancelTerminalLivePendingFlush,
+ createTerminalLivePendingFlushState,
queueTerminalLiveMirrorSend,
- waitForTerminalLivePendingFlush,
- type TerminalLivePendingFlushState
+ waitForTerminalLivePendingFlush
} from './terminal-live-pending-flush-state'
describe('terminal live pending flush state', () => {
it('Given no in-flight flush When waiting for the barrier Then allows control input', async () => {
// Given
- const state: TerminalLivePendingFlushState = { current: null }
+ const state = createTerminalLivePendingFlushState()
// When / Then
await expect(waitForTerminalLivePendingFlush(state)).resolves.toBe(true)
@@ -22,7 +23,8 @@ describe('terminal live pending flush state', () => {
const flushPromise = new Promise((resolve) => {
resolveFlush = resolve
})
- const state: TerminalLivePendingFlushState = { current: flushPromise }
+ const state = createTerminalLivePendingFlushState()
+ state.current = flushPromise
// When
const controlSend = sendTerminalLiveControlAfterPendingFlush(
@@ -48,7 +50,8 @@ describe('terminal live pending flush state', () => {
const flushPromise = new Promise((resolve) => {
resolveFlush = resolve
})
- const state: TerminalLivePendingFlushState = { current: flushPromise }
+ const state = createTerminalLivePendingFlushState()
+ state.current = flushPromise
// When
const controlSend = sendTerminalLiveControlAfterPendingFlush(
@@ -67,17 +70,45 @@ describe('terminal live pending flush state', () => {
})
describe('terminal live mirror send queue', () => {
+ it('Given high RTT When more input queues Then pending bytes share one follow-up send', async () => {
+ // Given
+ const state = createTerminalLivePendingFlushState()
+ const payloads: string[] = []
+ let resolveFirstSend: (value: boolean) => void = () => {}
+ const sender = async (_handle: string, payload: string): Promise => {
+ payloads.push(payload)
+ if (payloads.length === 1) {
+ return new Promise((resolve) => {
+ resolveFirstSend = resolve
+ })
+ }
+ return true
+ }
+
+ // When
+ const first = queueTerminalLiveMirrorSend(state, 'terminal-1', 'a', sender)
+ const second = queueTerminalLiveMirrorSend(state, 'terminal-1', 'b', sender)
+ const third = queueTerminalLiveMirrorSend(state, 'terminal-1', 'c', sender)
+ await Promise.resolve()
+
+ // Then
+ expect(payloads).toEqual(['a'])
+ resolveFirstSend(true)
+ await expect(Promise.all([first, second, third])).resolves.toEqual([true, true, true])
+ expect(payloads).toEqual(['a', 'bc'])
+ })
+
it('Given a failed previous send When a mirror send queues Then it still runs in order', async () => {
// Given
- const state: TerminalLivePendingFlushState = { current: null }
+ const state = createTerminalLivePendingFlushState()
const order: string[] = []
- const first = queueTerminalLiveMirrorSend(state, async () => {
+ const first = queueTerminalLiveMirrorSend(state, 'terminal-1', 'first', async () => {
order.push('first')
return false
})
// When
- const second = queueTerminalLiveMirrorSend(state, async () => {
+ const second = queueTerminalLiveMirrorSend(state, 'terminal-1', 'second', async () => {
order.push('second')
return true
})
@@ -90,13 +121,13 @@ describe('terminal live mirror send queue', () => {
it('Given a throwing send When a mirror send queues Then the promise resolves false and the chain continues', async () => {
// Given
- const state: TerminalLivePendingFlushState = { current: null }
- const first = queueTerminalLiveMirrorSend(state, async () => {
+ const state = createTerminalLivePendingFlushState()
+ const first = queueTerminalLiveMirrorSend(state, 'terminal-1', 'first', async () => {
throw new Error('boom')
})
// When
- const second = queueTerminalLiveMirrorSend(state, async () => true)
+ const second = queueTerminalLiveMirrorSend(state, 'terminal-1', 'second', async () => true)
// Then
await expect(first).resolves.toBe(false)
@@ -105,13 +136,33 @@ describe('terminal live mirror send queue', () => {
it('Given a settled mirror send When it was the newest Then the state resets to null', async () => {
// Given
- const state: TerminalLivePendingFlushState = { current: null }
+ const state = createTerminalLivePendingFlushState()
// When
- await queueTerminalLiveMirrorSend(state, async () => true)
+ await queueTerminalLiveMirrorSend(state, 'terminal-1', 'payload', async () => true)
await Promise.resolve()
// Then
expect(state.current).toBeNull()
})
+
+ it('Given queued input When the queue is cancelled Then unsent input is dropped', async () => {
+ // Given
+ const state = createTerminalLivePendingFlushState()
+ let resolveSend: (value: boolean) => void = () => {}
+ const sender = async (): Promise =>
+ new Promise((resolve) => {
+ resolveSend = resolve
+ })
+ const active = queueTerminalLiveMirrorSend(state, 'terminal-1', 'a', sender)
+ const pending = queueTerminalLiveMirrorSend(state, 'terminal-1', 'b', sender)
+
+ // When
+ cancelTerminalLivePendingFlush(state)
+
+ // Then
+ await expect(Promise.all([active, pending])).resolves.toEqual([false, false])
+ expect(state.current).toBeNull()
+ resolveSend(true)
+ })
})
diff --git a/mobile/src/terminal/terminal-live-pending-flush-state.ts b/mobile/src/terminal/terminal-live-pending-flush-state.ts
index 405198964..fe0b4927f 100644
--- a/mobile/src/terminal/terminal-live-pending-flush-state.ts
+++ b/mobile/src/terminal/terminal-live-pending-flush-state.ts
@@ -1,5 +1,30 @@
+type TerminalLiveMirrorSender = (handle: string, payload: string) => Promise
+
+type TerminalLivePendingRequest = {
+ readonly resolve: (sent: boolean) => void
+}
+
+type TerminalLivePendingBatch = {
+ readonly handle: string
+ payload: string
+ readonly requests: TerminalLivePendingRequest[]
+ readonly sender: TerminalLiveMirrorSender
+}
+
export type TerminalLivePendingFlushState = {
current: Promise | null
+ activeRequests: TerminalLivePendingRequest[]
+ generation: number
+ pendingBatches: TerminalLivePendingBatch[]
+}
+
+export function createTerminalLivePendingFlushState(): TerminalLivePendingFlushState {
+ return {
+ current: null,
+ activeRequests: [],
+ generation: 0,
+ pendingBatches: []
+ }
}
export function waitForTerminalLivePendingFlush(
@@ -8,26 +33,76 @@ export function waitForTerminalLivePendingFlush(
return state.current ?? Promise.resolve(true)
}
-// Why: mirror payloads are erase/append deltas against the PTY echo. A skipped
-// delta desyncs every later diff, so this chain runs each send even when the
-// previous one failed. state.current should never reject; the catch keeps a
-// future raw assignment from skipping a delta.
+export function cancelTerminalLivePendingFlush(state: TerminalLivePendingFlushState): void {
+ state.generation += 1
+ const requests = [
+ ...state.activeRequests,
+ ...state.pendingBatches.flatMap((batch) => batch.requests)
+ ]
+ state.activeRequests = []
+ state.pendingBatches = []
+ state.current = null
+ requests.forEach(({ resolve }) => resolve(false))
+}
+
+async function drainTerminalLiveMirrorSends(
+ state: TerminalLivePendingFlushState,
+ generation: number
+): Promise {
+ let allSent = true
+ while (state.generation === generation) {
+ const batch = state.pendingBatches.shift()
+ if (!batch) {
+ state.current = null
+ return allSent
+ }
+
+ state.activeRequests = batch.requests
+ const sent = await batch.sender(batch.handle, batch.payload).catch(() => false)
+ if (state.generation !== generation) {
+ return false
+ }
+
+ state.activeRequests = []
+ batch.requests.forEach(({ resolve }) => resolve(sent))
+ allSent &&= sent
+ }
+ return false
+}
+
+// Mirror deltas are ordered PTY bytes; batching pending bytes avoids one RTT per keystroke.
export function queueTerminalLiveMirrorSend(
state: TerminalLivePendingFlushState,
- sendMirrorPayload: () => Promise
+ handle: string,
+ payload: string,
+ sender: TerminalLiveMirrorSender
): Promise {
- const previousSend = state.current
- const sendPromise = (async () => {
- if (previousSend) {
- await previousSend.catch(() => false)
- }
- return sendMirrorPayload()
- })().catch(() => false)
- state.current = sendPromise
- void sendPromise.then(() => {
- if (state.current === sendPromise) {
- state.current = null
- }
+ let resolveRequest: (sent: boolean) => void = () => {}
+ const request = new Promise((resolve) => {
+ resolveRequest = resolve
})
- return sendPromise
+ const pendingTail = state.pendingBatches.at(-1)
+ if (pendingTail?.handle === handle && pendingTail.sender === sender) {
+ pendingTail.payload += payload
+ pendingTail.requests.push({ resolve: resolveRequest })
+ } else {
+ state.pendingBatches.push({
+ handle,
+ payload,
+ requests: [{ resolve: resolveRequest }],
+ sender
+ })
+ }
+
+ if (!state.current) {
+ const generation = state.generation
+ const drain = drainTerminalLiveMirrorSends(state, generation).catch(() => false)
+ state.current = drain
+ void drain.then(() => {
+ if (state.current === drain) {
+ state.current = null
+ }
+ })
+ }
+ return request
}
diff --git a/mobile/src/terminal/use-terminal-live-pending-input-flush.ts b/mobile/src/terminal/use-terminal-live-pending-input-flush.ts
index f62393129..056174421 100644
--- a/mobile/src/terminal/use-terminal-live-pending-input-flush.ts
+++ b/mobile/src/terminal/use-terminal-live-pending-input-flush.ts
@@ -7,6 +7,8 @@ import {
TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS
} from './terminal-live-hangul-mirror'
import {
+ cancelTerminalLivePendingFlush,
+ createTerminalLivePendingFlushState,
queueTerminalLiveMirrorSend,
waitForTerminalLivePendingFlush
} from './terminal-live-pending-flush-state'
@@ -39,7 +41,7 @@ export function useTerminalLivePendingInputFlush({
setLiveInputCapture
}: TerminalLivePendingInputFlushOptions): TerminalLivePendingInputFlush {
const heldCommitTimerRef = useRef | null>(null)
- const pendingLiveInputFlushRef = useRef | null>(null)
+ const pendingLiveInputFlushRef = useRef(createTerminalLivePendingFlushState())
const heldLiveInputTextRef = useRef('')
const sentLiveInputTextRef = useRef('')
const pendingLiveInputHandleRef = useRef(null)
@@ -56,6 +58,7 @@ export function useTerminalLivePendingInputFlush({
const resetMirrorState = useCallback(() => {
clearHeldCommitTimer()
+ cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current)
heldLiveInputTextRef.current = ''
sentLiveInputTextRef.current = ''
pendingLiveInputHandleRef.current = null
@@ -68,9 +71,15 @@ export function useTerminalLivePendingInputFlush({
}, [liveInputRef, resetMirrorState, setLiveInputCapture])
const waitForPendingLiveInputFlush = useCallback(async (): Promise => {
- return waitForTerminalLivePendingFlush(pendingLiveInputFlushRef)
+ return waitForTerminalLivePendingFlush(pendingLiveInputFlushRef.current)
}, [])
+ const sendQueuedMirrorPayload = useCallback(
+ (handle: string, payload: string): Promise =>
+ sendLiveTerminalInputRef.current(handle, payload),
+ [sendLiveTerminalInputRef]
+ )
+
const runMirrorStep = useCallback(
async (handle: string, fieldText: string, commitHeld: boolean): Promise => {
if (
@@ -107,8 +116,11 @@ export function useTerminalLivePendingInputFlush({
if (payload.length === 0) {
return waitForPendingLiveInputFlush()
}
- return queueTerminalLiveMirrorSend(pendingLiveInputFlushRef, () =>
- sendLiveTerminalInputRef.current(handle, payload)
+ return queueTerminalLiveMirrorSend(
+ pendingLiveInputFlushRef.current,
+ handle,
+ payload,
+ sendQueuedMirrorPayload
)
},
[
@@ -117,7 +129,7 @@ export function useTerminalLivePendingInputFlush({
clearHeldCommitTimer,
liveInputTerminalHandlesRef,
resetMirrorState,
- sendLiveTerminalInputRef,
+ sendQueuedMirrorPayload,
waitForPendingLiveInputFlush
]
)
@@ -164,7 +176,7 @@ export function useTerminalLivePendingInputFlush({
heldLiveInputTextRef.current = ''
sentLiveInputTextRef.current = ''
pendingLiveInputHandleRef.current = null
- pendingLiveInputFlushRef.current = null
+ cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current)
}
}, [])