Fix mobile terminal Korean IME composition on Android (#7011)

* Fix Korean IME composition by deferring live terminal preedit

The mobile terminal capture field previously sent and cleared every TextInput change, which can break Hangul composition on Android keyboards. Introduce a small commit model and extracted live-input hook so composed text is flushed deliberately while ASCII remains immediate.

Constraint: React Native TextInput has no portable composition event for this path; the fix uses a bounded commit delay for likely IME text.

Rejected: Native-module IME integration | unnecessary for the confirmed JS dispatch/clear failure and higher maintenance risk.

Confidence: high

Scope-risk: moderate

Directive: Keep terminal.send payload shape and buffered command input unchanged; do not claim physical Samsung Keyboard QA without device evidence.

Tested: cd mobile && pnpm exec vitest run src/terminal/terminal-live-text-commit.test.ts src/terminal/terminal-live-input.test.ts src/terminal/terminal-text-input-normalization.test.ts src/terminal/terminal-keyboard-type.test.ts --reporter=verbose

Tested: cd mobile && pnpm exec tsc --noEmit

Tested: cd mobile && pnpm exec oxlint src/terminal/terminal-live-text-commit.ts src/terminal/terminal-live-text-commit.test.ts src/terminal/use-terminal-live-input-commit.ts app/h/[hostId]/session/[worktreeId].tsx

Not-tested: Physical Galaxy Fold7/Samsung Keyboard and Android emulator/Gboard QA were unavailable; device probes recorded no attached Android device.

* Preserve pending Korean IME text before mobile accessory controls

Accessory keys share the same pending live-input commit gate as TextInput keypress and submit paths, so control bytes cannot race ahead of composed Hangul.

Constraint: React Native mobile input does not expose portable composition events for Samsung/Gboard IME paths.

Rejected: Let accessory buttons keep sending directly | Direct sends can drop pending Hangul before Tab/Esc/Enter/Backspace reaches the PTY.

Confidence: high

Scope-risk: narrow

Directive: Keep all terminal control-byte paths behind the pending live-input flush/local-edit decision before sending to the PTY.

Tested: pnpm --dir mobile test; pnpm --dir mobile lint; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile exec oxfmt --check src/terminal/terminal-live-text-commit.ts src/terminal/terminal-live-text-commit.test.ts src/terminal/use-terminal-live-input-commit.ts src/terminal/use-terminal-live-accessory-input-commit.ts app/h/[hostId]/session/[worktreeId].tsx; git diff --cached --check

Not-tested: Physical Galaxy Fold7 Samsung keyboard manual QA is still external-device only.

* Prevent stale IME timer flushes after mobile terminal teardown

Pending live-input timers now clear on hook unmount, and accessory Delete documents why it stays local without trimming pending IME text.

Constraint: React Native TextInput lacks a portable composition lifecycle, so pending IME text is guarded by a bounded timer that must not survive screen teardown.

Rejected: Use clearPendingLiveInputCommit during unmount | it would also touch React state/native props during teardown when only timer/ref cleanup is required.

Confidence: high

Scope-risk: narrow

Directive: Any delayed terminal input commit must have an owner-lifecycle cleanup path before sending to the PTY.

Tested: pnpm --dir mobile test; pnpm --dir mobile lint; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile exec vitest run src/terminal/terminal-live-text-commit.test.ts --reporter=verbose; pnpm --dir mobile exec oxfmt --check src/terminal/terminal-live-text-commit.ts src/terminal/use-terminal-live-input-commit.ts; git diff --check

Not-tested: Physical Galaxy Fold7 Samsung keyboard manual QA remains unavailable in this environment.

* Use semantic accessory edits for mobile IME commits

Accessory Backspace/Delete now carry semantic local-edit intent from built-in keys instead of inferring intent from raw bytes, and submit handling is reconnected to the pure submit-sequence model.

Constraint: Custom terminal accessory keys may produce the same bytes as built-ins but should still flush pending IME text before sending rather than being silently treated as hidden-input edits.

Rejected: Classify local accessory edits by raw bytes | That couples future custom controls to current built-in byte encodings.

Confidence: high

Scope-risk: narrow

Directive: Keep semantic input intent separate from terminal byte payloads when pending IME text is present.

Tested: pnpm --dir mobile test; pnpm --dir mobile lint; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile exec oxfmt --check src/terminal/terminal-live-text-commit.ts src/terminal/terminal-live-text-commit.test.ts src/terminal/use-terminal-live-input-commit.ts src/terminal/use-terminal-live-accessory-input-commit.ts app/h/[hostId]/session/[worktreeId].tsx; git diff --check

Not-tested: Physical Galaxy Fold7 Samsung keyboard manual QA remains unavailable in this environment.

* Respect IME flush failures before control input

Propagate terminal.send success from pending Korean IME text before sending Enter, Tab, or accessory bytes, while keeping custom no-pending accessory bytes on the original direct path.

Constraint: PR #7011 review required follow-up control bytes only after the pending composed text send actually succeeds.

Rejected: Treating send invocation as success | It can still reject or no-op when RPC state changed.

Confidence: high

Scope-risk: narrow

Directive: Keep pending IME flush paths async-success-aware before adding new terminal control inputs.

Tested: pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; pnpm --dir mobile exec oxfmt --check changed files; targeted no-excuse clean for mobile/src/terminal changed files.

Not-tested: Physical Galaxy Fold7 Samsung keyboard; full session file no-excuse audit still reports pre-existing unrelated violations.

* Serialize mobile IME flushes before live controls

Treat terminal.send as successful only when the RPC response is ok and the runtime send result is accepted, then route all live-input control sends through a shared in-flight pending-flush barrier.

Constraint: PR #7011 review found that resolved RPC promises and per-call sequencing were not enough to prove pending Hangul text reached the PTY before follow-up controls.

Rejected: Only awaiting each flush-then-send call | Repeatable accessory keys and no-pending sends can arrive while the first flush is still in flight.

Confidence: high

Scope-risk: moderate

Directive: Keep future mobile terminal control paths behind the pending-flush barrier whenever IME text may be in flight.

Tested: pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; pnpm --dir mobile exec oxfmt --check changed files; no-excuse clean for terminal changed files.

Not-tested: Physical Galaxy Fold7 Samsung keyboard; full session file no-excuse audit still reports pre-existing unrelated violations.

* Queue current IME snapshots behind active flushes

Drain the pending snapshot captured by a control action after any already-active terminal send, and make accessory commit handling explicit so raw fallback is not encoded as an inverted boolean.

Constraint: Architecture review found the previous single-slot barrier could wait for an older flush while skipping newly pending Hangul text.

Rejected: Reusing the prior in-flight promise as the current flush result | It proves only an older snapshot, not the current pending buffer.

Confidence: high

Scope-risk: narrow

Directive: New mobile terminal control paths must distinguish allow-raw, handled, and suppress-raw outcomes explicitly.

Tested: pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; pnpm --dir mobile exec oxfmt --check changed files; no-excuse clean for terminal changed files.

Not-tested: Physical Galaxy Fold7 Samsung keyboard; full session file no-excuse audit still reports pre-existing unrelated violations.

* Preserve accessory raw-send terminal targets

Capture the terminal handle at accessory keypress time and suppress raw fallback if the active live terminal changes while waiting for pending IME flushes.

Constraint: Independent review found raw accessory bytes could retarget to a different terminal after an async IME flush barrier.

Rejected: Re-reading activeHandleRef as the send target after await | It can point at a different terminal than the keypress belonged to.

Confidence: high

Scope-risk: narrow

Directive: Raw accessory fallback must use the keypress-time target and revalidate it after any await.

Tested: pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; pnpm --dir mobile exec oxfmt --check changed files; no-excuse clean for terminal changed files.

Not-tested: Physical Galaxy Fold7 Samsung keyboard; full session file no-excuse audit still reports pre-existing unrelated violations.

* Document accessory flush barrier intent

Make the non-obvious raw accessory wait/suppress behavior explicit so future changes preserve IME-before-control ordering.

Constraint: CodeRabbit requested a why-comment for the send-now accessory branch.

Rejected: Leaving the barrier semantics implicit | The branch can otherwise look like unnecessary async defensive code.

Confidence: high

Scope-risk: narrow

Directive: Keep comments focused on why raw accessory bytes wait behind IME flushes.

Tested: targeted terminal vitest suite; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; oxfmt check for changed file.

Not-tested: Physical Galaxy Fold7 Samsung keyboard.

* Preserve buffered accessory raw sends

Keep the stale-handle guard focused on the captured active terminal instead of live-input opt-in state, so buffered mode keeps existing accessory key behavior while async live-input waits still cannot retarget to another terminal.

Constraint: Buffered command input behavior must remain unchanged while fixing mobile Korean IME live input ordering.
Rejected: Requiring live-input enabled handles for raw accessory fallback | suppresses valid buffered-mode accessory sends.
Confidence: high
Scope-risk: narrow
Directive: Do not use live-input opt-in state as terminal liveness for raw accessory sends; validate captured target, active terminal tab, connection, and client instead.
Tested: pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; oxfmt --check changed mobile terminal/session files; TypeScript no-excuse checker for changed terminal files.
Not-tested: Physical Galaxy Fold7 Samsung Keyboard manual QA and GitHub Actions jobs, blocked by unavailable device and upstream fork workflow approval.

* Keep Hangul IME text pending until explicit flush

Avoid timer-driven PTY writes for Hangul candidates so paused Korean composition cannot leak intermediate jamo, while preserving the bounded settle timer for non-Hangul IME text. Also keep disabled live-input accessory fallback behind any existing pending flush barrier.

Constraint: React Native TextInput does not expose a portable composition lifecycle on this mobile surface.
Rejected: Fixed 150ms auto-flush for Hangul | can emit ㅎ or 하 if the user pauses mid-composition.
Confidence: high
Scope-risk: narrow
Directive: Treat Hangul candidates as pending until submit/control/accessory flush; do not reintroduce idle timer commits for Hangul without device-level composition evidence.
Tested: pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; oxfmt --check changed mobile terminal/session files; TypeScript no-excuse checker for changed terminal files.
Not-tested: Physical Galaxy Fold7 Samsung Keyboard manual QA and GitHub Actions jobs, blocked by unavailable device and upstream fork workflow approval.

* Gate dictation toast on accepted live send

Honor the async live-input sender contract so the mobile UI reports dictation insertion only after terminal.send is accepted.

Constraint: sendLiveTerminalInput now returns false for stale, disconnected, oversized, or rejected terminal sends.
Rejected: Toasting immediately after dispatch | reports success for sends that never reached the PTY.
Confidence: high
Scope-risk: narrow
Directive: Treat live-input UI success as terminal.send acceptance, not request dispatch.
Tested: pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; oxfmt --check app/h/[hostId]/session/[worktreeId].tsx.
Not-tested: Physical Galaxy Fold7 Samsung Keyboard manual QA and GitHub Actions jobs, blocked by unavailable device and upstream fork workflow approval.

* Keep accessory edits on Hangul pending path

Make accessory local edits reuse the Hangul-aware defer policy so built-in Backspace/Delete cannot reintroduce timer-driven Hangul PTY writes.

Constraint: Hangul IME candidates must remain pending until explicit submit/control/accessory flush.
Rejected: Reusing the non-Hangul 150ms settle timer for accessory local edits | can leak pending Hangul after Backspace/Delete.
Confidence: high
Scope-risk: narrow
Directive: Any future pending-text reschedule must use getTerminalLiveDeferredTextDelayMs instead of a hardcoded timer.
Tested: pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; oxfmt --check changed mobile terminal/session files; TypeScript no-excuse checker for changed terminal files.
Not-tested: Physical Galaxy Fold7 Samsung Keyboard manual QA and GitHub Actions jobs, blocked by unavailable device and upstream fork workflow approval.

* Prove Hangul live-input hook ordering

Add a direct hook-level regression so Android Korean IME fixes are covered at the orchestration boundary, not only by lower-level helpers.

Constraint: React Native mobile TextInput lacks portable composition lifecycle events in this path.

Rejected: Relying only on helper tests | misses hook-level pending flush and submit ordering.

Confidence: high

Scope-risk: narrow

Directive: Keep Hangul candidates pending until an explicit terminal action flushes them.

Tested: pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; oxfmt --check changed mobile files; no-excuse on terminal modules

Not-tested: Physical Galaxy Fold Samsung Keyboard manual QA is not available in this environment.

* Keep accessory raw-send tests precise

Remove a duplicate raw-target assertion whose title implied disabled live-input behavior that is covered at the accessory commit boundary instead.

Constraint: Anti-slop cleanup must preserve existing Hangul/accessory behavior and stay within changed terminal tests.

Rejected: Keeping the duplicate disabled-input wording | it tests the same active-terminal predicate as the preceding case.

Confidence: high

Scope-risk: narrow

Directive: Test disabled live-input buffering in the accessory commit layer, not in the raw-target predicate helper.

Tested: pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; pnpm --dir mobile exec oxfmt --check changed mobile files; terminal no-excuse checker

Not-tested: Physical Galaxy Fold Samsung Keyboard manual QA is not available in this environment.

* Explain stale mobile terminal send gates

Document why async IME flush paths re-check terminal/client refs before sending raw bytes or reporting live-send success.

Constraint: CodeRabbit review requested short why comments for non-obvious stale-send safety gates.

Rejected: Leaving the gates undocumented | future edits could remove the stale-target suppression contract.

Confidence: high

Scope-risk: narrow

Directive: Keep async terminal sends guarded by current client, active handle, tab type, and connection state.

Tested: pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; pnpm --dir mobile exec oxfmt --check changed mobile files; terminal no-excuse checker

Not-tested: Physical Galaxy Fold Samsung Keyboard manual QA is not available in this environment.

* Run mobile IME hook tests through effects

Move the Hangul live-input hook regression from server rendering to react-test-renderer so effect cleanup and unmount timer cancellation are exercised.

Constraint: @testing-library/react-native imports React Native's Flow entry under this Vitest setup, so the narrow effect-running renderer is the compatible test surface.

Rejected: Keeping renderToString | it never runs useEffect cleanup and missed the pending timer cleanup path.

Rejected: Adding @testing-library/react-native directly | it failed before tests with React Native Flow syntax under the current Vitest transform.

Confidence: high

Scope-risk: narrow

Directive: Hook-level IME tests must use a renderer that runs effects when asserting pending flush cleanup.

Tested: vitest targeted terminal tests; pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; oxfmt --check changed mobile files; terminal no-excuse checker

Not-tested: Physical Galaxy Fold Samsung Keyboard manual QA is not available in this environment.

* Keep hook lifecycle tests quiet

Suppress only the react-test-renderer deprecation warning around the effect-running hook harness so real console errors still surface.

Constraint: CodeRabbit flagged React 19 renderer warning noise; @testing-library/react-native remains incompatible with the current Vitest/RN Flow transform path.

Rejected: Global console silencing | it would hide unrelated test failures.

Confidence: high

Scope-risk: narrow

Directive: Keep the renderer warning suppression scoped to this hook harness and pass all other console errors through.

Tested: vitest targeted terminal tests; pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; oxfmt --check changed mobile files; terminal no-excuse checker

Not-tested: Physical Galaxy Fold Samsung Keyboard manual QA is not available in this environment.

* fix: flush pending mobile IME input before external sends

* fix: guard terminal command finished event dispatch

---------

Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
This commit is contained in:
WONGIL 2026-07-02 18:31:19 +09:00 committed by GitHub
parent e59c04122d
commit 4f0a141951
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 1945 additions and 223 deletions

View File

@ -1,8 +1,6 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { Animated, AppState, Linking, type AppStateStatus } from 'react-native'
import * as Clipboard from 'expo-clipboard'
import { ImageManipulator, SaveFormat } from 'expo-image-manipulator'
import { File as FsFile, Paths } from 'expo-file-system'
import {
BackHandler,
FlatList,
@ -95,14 +93,18 @@ import {
getVisibleTerminalAccessoryKeys,
loadTerminalAccessoryLayout
} from '../../../../src/terminal/terminal-accessory-layout'
import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/terminal-live-accessory-input'
import { getTerminalLiveAccessoryRawSendTarget } from '../../../../src/terminal/terminal-live-accessory-raw-send-target'
import {
clearTerminalLiveInputFocusTimer,
defaultTerminalLiveInputHandles,
getTerminalLiveSpecialKeyBytes,
isTerminalLiveInputWithinByteLimit,
pruneTerminalLiveInputHandles,
scheduleTerminalLiveInputFocus
} from '../../../../src/terminal/terminal-live-input'
import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-live-input-sender'
import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-send-rpc-response'
import { useTerminalLiveInputCommit } from '../../../../src/terminal/use-terminal-live-input-commit'
import {
getTerminalCommandKeyboardType,
getTerminalLiveInputKeyboardType
@ -162,13 +164,8 @@ import {
type MobileNewTabAgentOption,
type MobileNewTabAgentSettings
} from '../../../../src/session/mobile-new-tab-agent-options'
import {
buildMobileImagePastePayload,
prepareMobileClipboardImageBase64,
saveMobileClipboardImageAsTempFile,
type MobileClipboardImageResizer
} from '../../../../src/session/mobile-clipboard-image'
import { useMobileImageAttachment } from '../../../../src/session/use-mobile-image-attachment'
import { useMobileTerminalPaste } from '../../../../src/session/use-mobile-terminal-paste'
import { MobileTerminalLiveInputStatus } from '../../../../src/session/MobileTerminalLiveInputStatus'
import { MobileTerminalInputActions } from '../../../../src/session/MobileTerminalInputActions'
import { classifyMobileArtifact } from '../../../../src/session/mobile-artifact-kind'
@ -233,52 +230,9 @@ import type {
TerminalGestureInputQueue
} from './mobile-session-route-types'
const CLIPBOARD_IMAGE_DATA_URL_PREFIX_RE = /^data:image\/[a-z0-9.+-]+;base64,/i
const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450
type TerminalLiveAccessoryInput = ReturnType<typeof createTerminalLiveAccessoryInput>
// Why: clipboard images are re-encoded as lossless PNG, so high-res screenshots and
// photos can exceed the upload byte budget; resize the raster down to fit before upload.
// The image is staged to a temp file first because the iOS ImageManipulator loader
// (Data(contentsOf:)) cannot decode large base64 data URIs — it needs a file:// URI.
const resizeMobileClipboardImage: MobileClipboardImageResizer = async (source, target) => {
const base64 = source.replace(CLIPBOARD_IMAGE_DATA_URL_PREFIX_RE, '')
const file = new FsFile(Paths.cache, `orca-clip-resize-${Date.now()}.png`)
let context: ReturnType<typeof ImageManipulator.manipulate> | null = null
let rendered: Awaited<
ReturnType<ReturnType<typeof ImageManipulator.manipulate>['renderAsync']>
> | null = null
let resultUri: string | null = null
try {
file.create({ overwrite: true })
file.write(base64, { encoding: 'base64' })
context = ImageManipulator.manipulate(file.uri)
context.resize({ width: target.width, height: target.height })
rendered = await context.renderAsync()
const result = await rendered.saveAsync({ format: SaveFormat.PNG, base64: true })
resultUri = result.uri
// Why: empty base64 would pass the downstream base64 check and upload a corrupt
// image, so fail loudly here instead of silently sending an invalid payload.
if (!result.base64) {
throw new Error('Failed to encode resized clipboard image')
}
return { data: result.base64, width: result.width, height: result.height }
} finally {
rendered?.release()
context?.release()
if (resultUri) {
try {
new FsFile(resultUri).delete()
} catch {
// Best-effort cleanup; ImageManipulator saves into cache for every retry.
}
}
try {
file.delete()
} catch {
// Best-effort cleanup; the OS reclaims the cache directory regardless.
}
}
}
const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450
function getActiveTabIdForHandle(
tabs: MobileSessionTab[],
@ -1034,6 +988,7 @@ export default function SessionScreen() {
const terminalRefs = useRef<Map<string, TerminalWebViewHandle>>(new Map())
const liveInputRef = useRef<TextInput>(null)
const liveInputFocusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const sendLiveTerminalInputRef = useRef<TerminalLiveInputSender>(async () => false)
const sessionTabActionSheetKeyboardHideSubRef = useRef<ReturnType<
typeof Keyboard.addListener
> | null>(null)
@ -1068,6 +1023,7 @@ export default function SessionScreen() {
// render where client was still null/connecting, silently no-opping the
// in-app-browser open). Route through a ref kept current every render.
const handleCreateBrowserRef = useRef<((rawUrl?: string) => Promise<boolean>) | null>(null)
const initialEmptySessionAutoCreateRef = useRef<string | null>(null)
const markdownSaveSeqRef = useRef<Map<string, number>>(new Map())
const markdownSaveInFlightRef = useRef<Set<string>>(new Set())
@ -1094,6 +1050,24 @@ export default function SessionScreen() {
const [terminalFrameWidth, setTerminalFrameWidth] = useState(0)
const activeSessionTab = sessionTabs.find((tab) => tab.id === activeSessionTabId) ?? null
const {
clearPendingLiveInputCommit,
flushPendingLiveInputBeforeExternalSend,
handleLiveInputAccessoryBytes,
handleLiveInputChange,
handleLiveInputKeyPress,
handleLiveInputSubmit
} = useTerminalLiveInputCommit({
activeHandle,
activeHandleRef,
activeSessionTabType: activeSessionTab?.type,
activeSessionTabTypeRef,
liveInputRef,
liveInputTerminalHandles,
liveInputTerminalHandlesRef,
sendLiveTerminalInputRef,
setLiveInputCapture
})
const canSend =
connState === 'connected' &&
activeHandle != null &&
@ -1244,8 +1218,16 @@ export default function SessionScreen() {
if (!insertHandle) {
return
}
sendLiveTerminalInput(insertHandle, route.text)
showToast('Dictation inserted')
void (async () => {
const flushedPendingInput = await flushPendingLiveInputBeforeExternalSend(insertHandle)
if (!flushedPendingInput) {
return
}
const sent = await sendLiveTerminalInput(insertHandle, route.text)
if (sent) {
showToast('Dictation inserted')
}
})()
return
}
setInput((current) => appendBufferedDictation(current, route.text))
@ -2680,7 +2662,7 @@ export default function SessionScreen() {
terminalsRef.current = []
setSessionTabs([])
setActiveSessionTabId(null)
setLiveInputCapture('')
clearPendingLiveInputCommit()
liveInputTerminalHandlesRef.current = new Set()
defaultedLiveInputTerminalHandlesRef.current = new Set()
setLiveInputTerminalHandles(new Set())
@ -2690,9 +2672,16 @@ export default function SessionScreen() {
return () => {
sessionTabActionSheetRequestSeqRef.current += 1
sessionTabActionSheetKeyboardHideSubRef.current?.remove()
clearPendingLiveInputCommit()
clearDelayedActionTimers()
}
}, [clearDelayedActionTimers, clearTerminalCache, hostId, worktreeId])
}, [
clearDelayedActionTimers,
clearPendingLiveInputCommit,
clearTerminalCache,
hostId,
worktreeId
])
useEffect(() => {
if (connState !== 'connected') {
@ -3097,46 +3086,62 @@ export default function SessionScreen() {
}
}
async function handleAccessoryKey(bytes: string) {
async function handleAccessoryKey(input: TerminalLiveAccessoryInput) {
if (!client || !activeHandle || !canSend) {
return
}
try {
await client.sendRequest('terminal.send', {
terminal: activeHandle,
text: bytes,
const targetHandle = activeHandle
const accessoryCommit = await handleLiveInputAccessoryBytes(input)
if (accessoryCommit.kind !== 'allow-raw') {
return
}
const currentClient = clientRef.current
// Why: async IME flushing can outlive the original terminal selection.
const rawSendTarget = getTerminalLiveAccessoryRawSendTarget({
targetHandle,
activeHandle: activeHandleRef.current,
activeSessionTabType: activeSessionTabTypeRef.current
})
if (!currentClient || !rawSendTarget || connStateRef.current !== 'connected') {
return
}
await currentClient
.sendRequest('terminal.send', {
terminal: rawSendTarget,
text: input.bytes,
enter: false,
...(deviceTokenRef.current
? { client: { id: deviceTokenRef.current, type: 'mobile' as const } }
: {})
})
} catch {
// Transient failure
}
.then(
() => undefined,
() => undefined
)
}
const sendLiveTerminalInput = useCallback(
(handle: string, bytes: string) => {
async (handle: string, bytes: string): Promise<boolean> => {
const text = normalizeTerminalTextInput(bytes)
if (text.length === 0) {
return
return false
}
if (!isTerminalLiveInputWithinByteLimit(text)) {
triggerError()
showToast('Input too large (max 256 KiB)', 1500)
return
return false
}
const rpc = clientRef.current
// Why: callers suppress follow-up controls/toasts when this live send is stale.
if (
!rpc ||
connStateRef.current !== 'connected' ||
handle !== activeHandleRef.current ||
activeSessionTabTypeRef.current !== 'terminal'
) {
return
return false
}
void rpc
return rpc
.sendRequest('terminal.send', {
terminal: handle,
text,
@ -3145,12 +3150,11 @@ export default function SessionScreen() {
? { client: { id: deviceTokenRef.current, type: 'mobile' as const } }
: {})
})
.catch(() => {
// Transient failure
})
.then(isTerminalSendRpcAccepted, () => false)
},
[showToast]
)
sendLiveTerminalInputRef.current = sendLiveTerminalInput
const focusLiveInput = useCallback(() => {
if (!canSend || !liveInputEnabled) {
@ -3387,70 +3391,14 @@ export default function SessionScreen() {
liveInputTerminalHandlesRef.current = next
return next
})
setLiveInputCapture('')
clearPendingLiveInputCommit()
if (nextEnabled) {
scheduleTerminalLiveInputFocus(liveInputFocusTimerRef, () => liveInputRef.current?.focus())
} else {
clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef)
liveInputRef.current?.blur()
}
}, [activeHandle, liveInputTerminalHandles])
const handleLiveInputChange = useCallback(
(text: string) => {
if (!activeHandle) {
setLiveInputCapture('')
liveInputRef.current?.setNativeProps({ text: '' })
return
}
if (!liveInputTerminalHandles.has(activeHandle)) {
setLiveInputCapture('')
liveInputRef.current?.setNativeProps({ text: '' })
return
}
const normalizedText = normalizeTerminalTextInput(text)
if (normalizedText.length > 0) {
sendLiveTerminalInput(activeHandle, normalizedText)
}
setLiveInputCapture('')
// Why: the field is only a keyboard capture surface. Clearing the
// native value prevents subsequent phone-keyboard events from replaying
// already-sent characters when React state remains the empty string.
liveInputRef.current?.setNativeProps({ text: '' })
},
[activeHandle, liveInputTerminalHandles, sendLiveTerminalInput]
)
const handleLiveInputKeyPress = useCallback(
(event: { nativeEvent: { key: string } }) => {
if (!activeHandle) {
return
}
if (!liveInputTerminalHandles.has(activeHandle)) {
return
}
const bytes = getTerminalLiveSpecialKeyBytes(event.nativeEvent.key)
if (!bytes) {
return
}
sendLiveTerminalInput(activeHandle, bytes)
setLiveInputCapture('')
liveInputRef.current?.setNativeProps({ text: '' })
},
[activeHandle, liveInputTerminalHandles, sendLiveTerminalInput]
)
const handleLiveInputSubmit = useCallback(() => {
if (!activeHandle) {
return
}
if (!liveInputTerminalHandles.has(activeHandle)) {
return
}
sendLiveTerminalInput(activeHandle, '\r')
setLiveInputCapture('')
liveInputRef.current?.setNativeProps({ text: '' })
}, [activeHandle, liveInputTerminalHandles, sendLiveTerminalInput])
}, [activeHandle, clearPendingLiveInputCommit, liveInputTerminalHandles])
const allowTerminalGestureInput = useCallback(
(handle: string, sequenceCount: number): boolean => {
@ -3650,11 +3598,11 @@ export default function SessionScreen() {
}
}, [])
const startAccessoryRepeat = useCallback(
(bytes: string) => {
(input: TerminalLiveAccessoryInput) => {
stopAccessoryRepeat()
repeatTimeoutRef.current = setTimeout(() => {
repeatIntervalRef.current = setInterval(() => {
void handleAccessoryKeyRef.current(bytes)
void handleAccessoryKeyRef.current(input)
}, 45)
}, 400)
},
@ -3673,11 +3621,13 @@ export default function SessionScreen() {
clearToastHideTimer()
clearDelayedActionTimers()
clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef)
clearPendingLiveInputCommit()
sessionTabActionSheetRequestSeqRef.current += 1
clearSessionTabActionSheetKeyboardListener()
stopAccessoryRepeat()
},
[
clearPendingLiveInputCommit,
clearDelayedActionTimers,
clearSessionTabActionSheetKeyboardListener,
clearTerminalCache,
@ -3801,84 +3751,38 @@ export default function SessionScreen() {
})
}, [])
const handlePaste = useCallback(async () => {
if (!client || !activeHandle || !canSend) {
return
}
try {
const text = await Clipboard.getStringAsync()
let payload: string | null = null
if (text.length > 0) {
const modes = ptyModesRef.current.get(activeHandle) || {
bracketedPasteMode: false,
altScreen: false,
mouseTrackingMode: 'none',
sgrMouseMode: false,
sgrMousePixelsMode: false
}
const wrap = modes.bracketedPasteMode && !modes.altScreen
// Why: strip embedded bracketed-paste markers from clipboard text so a
// malicious copy containing `\x1b[201~` can't terminate paste mode early
// and have the trailing bytes interpreted as shell commands. Matches
// xterm.js / iTerm2 behavior.
// eslint-disable-next-line no-control-regex -- intentional bracketed-paste marker stripping
const sanitized = wrap ? text.replace(/\x1b\[20[01]~/g, '') : text
payload = wrap ? `\x1b[200~${sanitized}\x1b[201~` : sanitized
} else {
const image = await Clipboard.getImageAsync({ format: 'png' })
if (!image) {
refreshCanPaste()
return
}
const connectionId = await getActiveWorktreeConnectionId()
const base64 = await prepareMobileClipboardImageBase64(image, resizeMobileClipboardImage)
const imagePath = await saveMobileClipboardImageAsTempFile(client, base64, {
connectionId
})
payload = buildMobileImagePastePayload(imagePath)
}
const wrappedBytes = new TextEncoder().encode(payload).byteLength
if (wrappedBytes > 256 * 1024) {
triggerError()
// eslint-disable-next-line no-console
console.warn('[mobile-clip] paste oversized', { wrappedBytes })
showToast('Paste too large (max 256 KiB)', 1500)
return
}
await client.sendRequest('terminal.send', {
terminal: activeHandle,
text: payload,
enter: false,
...(deviceTokenRef.current
? { client: { id: deviceTokenRef.current, type: 'mobile' as const } }
: {})
})
triggerSelection()
refreshCanPaste()
} catch (e) {
triggerError()
const err = e as { name?: string; message?: string }
const isDisconnected = connState !== 'connected'
// eslint-disable-next-line no-console
console.warn('[mobile-clip] paste failed', { name: err.name, message: err.message })
if (isDisconnected) {
showToast('Paste failed (disconnected)', 1500)
} else if (err.message === 'Clipboard image is too large') {
showToast('Image too large to paste', 1500)
} else {
showToast('Paste failed', 1500)
}
}
}, [
const handlePaste = useMobileTerminalPaste({
client,
activeHandle,
activeHandleRef,
activeSessionTabTypeRef,
canSend,
connState,
connStateRef,
clientRef,
deviceTokenRef,
flushPendingLiveInputBeforeExternalSend,
getActiveWorktreeConnectionId,
onError: triggerError,
onSuccess: triggerSelection,
ptyModesRef,
refreshCanPaste,
showToast
])
})
const flushPendingLiveInputBeforeAttachmentSend = useCallback(
async (targetHandle: string): Promise<boolean> => {
const flushedPendingInput = await flushPendingLiveInputBeforeExternalSend(targetHandle)
// Why: image picking/upload and IME flushing can outlive the original tab.
return (
flushedPendingInput &&
connStateRef.current === 'connected' &&
targetHandle === activeHandleRef.current &&
activeSessionTabTypeRef.current === 'terminal'
)
},
[flushPendingLiveInputBeforeExternalSend]
)
const { attachImage, isAttaching } = useMobileImageAttachment({
client,
@ -3886,6 +3790,7 @@ export default function SessionScreen() {
canSend,
connState,
deviceTokenRef,
beforeTerminalSend: flushPendingLiveInputBeforeAttachmentSend,
getActiveWorktreeConnectionId,
showToast,
onSuccess: triggerSelection,
@ -5046,8 +4951,9 @@ export default function SessionScreen() {
if (!key.repeatable) {
return
}
void handleAccessoryKey(key.bytes)
startAccessoryRepeat(key.bytes)
const input = createTerminalLiveAccessoryInput(key)
void handleAccessoryKey(input)
startAccessoryRepeat(input)
}}
onPressOut={() => {
if (key.repeatable) {
@ -5058,7 +4964,7 @@ export default function SessionScreen() {
if (key.repeatable) {
return
}
void handleAccessoryKey(key.bytes)
void handleAccessoryKey(createTerminalLiveAccessoryInput(key))
}}
accessibilityLabel={key.accessibilityLabel ?? `Send ${key.label}`}
>
@ -5082,7 +4988,7 @@ export default function SessionScreen() {
!canSend && styles.accessoryKeyDisabled
]}
disabled={!canSend}
onPress={() => void handleAccessoryKey(key.bytes)}
onPress={() => void handleAccessoryKey({ bytes: key.bytes })}
onLongPress={() => {
triggerMediumImpact()
setDeleteKeyTarget(key)

View File

@ -60,10 +60,12 @@
"devDependencies": {
"@types/react": "^19.2.14",
"@types/react-native": "^0.73.0",
"@types/react-test-renderer": "19.1.0",
"@types/ws": "^8.18.1",
"expo-module-scripts": "^55.0.2",
"oxfmt": "^0.52.0",
"oxlint": "^1.71.0",
"react-test-renderer": "19.2.6",
"tsx": "^4.22.4",
"typescript": "^5.9.3",
"vite": "^8.0.16",

View File

@ -67,7 +67,7 @@ importers:
version: 55.0.22(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-router:
specifier: ^55.0.14
version: 55.0.14(ce047ddbdcb481fe003f0510f5198c7e)
version: 55.0.14(e0a8113e3689d84edb7af23d00a72ffa)
expo-secure-store:
specifier: ^55.0.13
version: 55.0.13(expo@55.0.23)
@ -135,18 +135,24 @@ importers:
'@types/react-native':
specifier: ^0.73.0
version: 0.73.0(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)
'@types/react-test-renderer':
specifier: 19.1.0
version: 19.1.0
'@types/ws':
specifier: ^8.18.1
version: 8.18.1
expo-module-scripts:
specifier: ^55.0.2
version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6)
version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6)
oxfmt:
specifier: ^0.52.0
version: 0.52.0
oxlint:
specifier: ^1.71.0
version: 1.71.0
react-test-renderer:
specifier: 19.2.6
version: 19.2.6(react@19.2.6)
tsx:
specifier: ^4.22.4
version: 4.22.4
@ -1724,48 +1730,56 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-arm64-musl@0.52.0':
resolution: {integrity: sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-ppc64-gnu@0.52.0':
resolution: {integrity: sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-gnu@0.52.0':
resolution: {integrity: sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-musl@0.52.0':
resolution: {integrity: sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-s390x-gnu@0.52.0':
resolution: {integrity: sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-gnu@0.52.0':
resolution: {integrity: sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-musl@0.52.0':
resolution: {integrity: sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxfmt/binding-openharmony-arm64@0.52.0':
resolution: {integrity: sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ==}
@ -1838,48 +1852,56 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-arm64-musl@1.71.0':
resolution: {integrity: sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-ppc64-gnu@1.71.0':
resolution: {integrity: sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-gnu@1.71.0':
resolution: {integrity: sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-musl@1.71.0':
resolution: {integrity: sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-s390x-gnu@1.71.0':
resolution: {integrity: sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-gnu@1.71.0':
resolution: {integrity: sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-musl@1.71.0':
resolution: {integrity: sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxlint/binding-openharmony-arm64@1.71.0':
resolution: {integrity: sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw==}
@ -2341,36 +2363,42 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.1.3':
resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.1.3':
resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.1.3':
resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.1.3':
resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.1.3':
resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.1.3':
resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==}
@ -4632,24 +4660,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@ -5424,6 +5456,11 @@ packages:
peerDependencies:
react: ^19.2.0
react-test-renderer@19.2.6:
resolution: {integrity: sha512-GbS6V23YduFTPiWJ5xICbKEjRcqx1Z90js/V5miqhz7qp/d6xSe9Dd6NjSQODFRdzdsqRMPW82E/sFpPRbY5Mw==}
peerDependencies:
react: ^19.2.6
react@19.2.6:
resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==}
engines: {node: '>=0.10.0'}
@ -7825,7 +7862,7 @@ snapshots:
ws: 8.21.0
zod: 3.25.76
optionalDependencies:
expo-router: 55.0.14(ce047ddbdcb481fe003f0510f5198c7e)
expo-router: 55.0.14(e0a8113e3689d84edb7af23d00a72ffa)
react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)
transitivePeerDependencies:
- '@expo/dom-webview'
@ -8093,7 +8130,7 @@ snapshots:
react: 19.2.6
optionalDependencies:
'@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-dom@19.2.6(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
expo-router: 55.0.14(ce047ddbdcb481fe003f0510f5198c7e)
expo-router: 55.0.14(e0a8113e3689d84edb7af23d00a72ffa)
react-dom: 19.2.6(react@19.2.6)
transitivePeerDependencies:
- supports-color
@ -9055,14 +9092,14 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
'@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6)':
'@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
jest-matcher-utils: 30.3.0
picocolors: 1.1.1
pretty-format: 30.3.0
react: 19.2.6
react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)
react-test-renderer: 19.2.0(react@19.2.6)
react-test-renderer: 19.2.6(react@19.2.6)
redent: 3.0.0
optionalDependencies:
jest: 29.7.0(@types/node@25.9.3)
@ -10651,7 +10688,7 @@ snapshots:
expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-json-utils: 55.0.2
expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6):
expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
'@babel/cli': 7.28.6(@babel/core@7.29.7)
'@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.7)
@ -10659,7 +10696,7 @@ snapshots:
'@babel/preset-typescript': 7.28.5(@babel/core@7.29.7)
'@expo/npm-proofread': 1.0.1
'@expo/spawn-async': 1.7.2
'@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6)
'@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6)
'@tsconfig/node18': 18.2.6
'@types/jest': 29.5.14
babel-plugin-dynamic-import-node: 2.3.3
@ -10734,7 +10771,7 @@ snapshots:
- supports-color
- typescript
expo-router@55.0.14(ce047ddbdcb481fe003f0510f5198c7e):
expo-router@55.0.14(e0a8113e3689d84edb7af23d00a72ffa):
dependencies:
'@expo/log-box': 55.0.12(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
'@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-dom@19.2.6(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
@ -10771,7 +10808,7 @@ snapshots:
use-latest-callback: 0.2.6(react@19.2.6)
vaul: 1.1.2(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
optionalDependencies:
'@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6)
'@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6)
react-dom: 19.2.6(react@19.2.6)
react-native-gesture-handler: 2.31.2(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
react-native-reanimated: 4.3.0(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
@ -12948,6 +12985,12 @@ snapshots:
react-is: 19.2.6
scheduler: 0.27.0
react-test-renderer@19.2.6(react@19.2.6):
dependencies:
react: 19.2.6
react-is: 19.2.6
scheduler: 0.27.0
react@19.2.6: {}
readdirp@3.6.0:

View File

@ -118,4 +118,30 @@ describe('attachMobileImageToTerminal', () => {
const sendCall = client.calls.find((c) => c.method === 'terminal.send')
expect(sendCall?.params).not.toHaveProperty('client')
})
it('waits for pending live input before sending the image payload', async () => {
const client = clientWithResponses([
{
id: 'start',
ok: false,
error: { code: 'method_not_found', message: 'no' },
_meta: { runtimeId: 'r' }
},
ok('save', '/tmp/pending.png')
])
const beforeTerminalSend = vi.fn(async () => false)
const sent = await attachMobileImageToTerminal('library', {
client,
terminal: 'term-pending',
deviceToken: null,
getConnectionId: async () => null,
pickImage: vi.fn().mockResolvedValue({ base64: 'DDDD' }),
beforeTerminalSend
})
expect(sent).toBe(false)
expect(beforeTerminalSend).toHaveBeenCalledWith('term-pending')
expect(client.calls.some((call) => call.method === 'terminal.send')).toBe(false)
})
})

View File

@ -16,6 +16,7 @@ export type AttachMobileImageDeps = {
// start — lets the UI show a sending spinner only for the transfer, not the
// (potentially long) time the picker is open.
readonly onUploadStart?: () => void
readonly beforeTerminalSend?: (terminal: string) => Promise<boolean>
}
// Uploads a picked image to the host and pastes the resulting file path into the
@ -30,7 +31,8 @@ export async function attachMobileImageToTerminal(
deviceToken,
getConnectionId,
pickImage,
onUploadStart
onUploadStart,
beforeTerminalSend
}: AttachMobileImageDeps
): Promise<boolean> {
const picked = await pickImage(source)
@ -45,6 +47,9 @@ export async function attachMobileImageToTerminal(
// Why: a generated image path is terminal image injection, so it's always
// bracketed (matching desktop paste) regardless of terminal mode.
const payload = buildMobileImagePastePayload(imagePath)
if (beforeTerminalSend && !(await beforeTerminalSend(terminal))) {
return false
}
await client.sendRequest('terminal.send', {
terminal,
text: payload,

View File

@ -24,6 +24,7 @@ type UseMobileImageAttachmentArgs = {
readonly showToast: ShowToast
readonly onSuccess: () => void
readonly onError: () => void
readonly beforeTerminalSend?: (terminal: string) => Promise<boolean>
}
type MobileImageAttachment = {
@ -46,7 +47,8 @@ export function useMobileImageAttachment({
getActiveWorktreeConnectionId,
showToast,
onSuccess,
onError
onError,
beforeTerminalSend
}: UseMobileImageAttachmentArgs): MobileImageAttachment {
const [isAttaching, setIsAttaching] = useState(false)
const attachImage = useCallback(
@ -61,7 +63,8 @@ export function useMobileImageAttachment({
deviceToken: deviceTokenRef.current,
getConnectionId: getActiveWorktreeConnectionId,
pickImage: pickMobileImage,
onUploadStart: () => setIsAttaching(true)
onUploadStart: () => setIsAttaching(true),
beforeTerminalSend
})
// Cancelled picker: no error, no toast.
if (sent) {
@ -88,6 +91,7 @@ export function useMobileImageAttachment({
},
[
activeHandle,
beforeTerminalSend,
canSend,
client,
connState,

View File

@ -0,0 +1,200 @@
import { useCallback, type RefObject } from 'react'
import * as Clipboard from 'expo-clipboard'
import { File as FsFile, Paths } from 'expo-file-system'
import { ImageManipulator, SaveFormat } from 'expo-image-manipulator'
import type { TerminalModes } from '../terminal/TerminalWebView'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import {
buildMobileImagePastePayload,
prepareMobileClipboardImageBase64,
saveMobileClipboardImageAsTempFile,
type MobileClipboardImageResizer
} from './mobile-clipboard-image'
const CLIPBOARD_IMAGE_DATA_URL_PREFIX_RE = /^data:image\/[a-z0-9.+-]+;base64,/i
// Why: clipboard images are re-encoded as lossless PNG, so high-res screenshots and
// photos can exceed the upload byte budget; resize the raster down to fit before upload.
// The iOS ImageManipulator loader cannot decode large base64 data URIs, so use a file.
const resizeMobileClipboardImage: MobileClipboardImageResizer = async (source, target) => {
const base64 = source.replace(CLIPBOARD_IMAGE_DATA_URL_PREFIX_RE, '')
const file = new FsFile(Paths.cache, `orca-clip-resize-${Date.now()}.png`)
let context: ReturnType<typeof ImageManipulator.manipulate> | null = null
let rendered: Awaited<
ReturnType<ReturnType<typeof ImageManipulator.manipulate>['renderAsync']>
> | null = null
let resultUri: string | null = null
try {
file.create({ overwrite: true })
file.write(base64, { encoding: 'base64' })
context = ImageManipulator.manipulate(file.uri)
context.resize({ width: target.width, height: target.height })
rendered = await context.renderAsync()
const result = await rendered.saveAsync({ format: SaveFormat.PNG, base64: true })
resultUri = result.uri
// Why: empty base64 would pass the downstream base64 check and upload a corrupt
// image, so fail loudly here instead of silently sending an invalid payload.
if (!result.base64) {
throw new Error('Failed to encode resized clipboard image')
}
return { data: result.base64, width: result.width, height: result.height }
} finally {
rendered?.release()
context?.release()
if (resultUri) {
try {
new FsFile(resultUri).delete()
} catch {
// Best-effort cleanup; ImageManipulator saves into cache for every retry.
}
}
try {
file.delete()
} catch {
// Best-effort cleanup; the OS reclaims the cache directory regardless.
}
}
}
function buildMobileTerminalClipboardTextPayload(
text: string,
modes: TerminalModes | undefined
): string {
const wrap = modes?.bracketedPasteMode === true && !modes.altScreen
// Why: strip embedded bracketed-paste markers so copied text cannot terminate
// paste mode early and turn trailing bytes into shell commands.
// eslint-disable-next-line no-control-regex -- intentional bracketed-paste marker stripping
const sanitized = wrap ? text.replace(/\x1b\[20[01]~/g, '') : text
return wrap ? `\x1b[200~${sanitized}\x1b[201~` : sanitized
}
type UseMobileTerminalPasteOptions = {
readonly activeHandle: string | null
readonly activeHandleRef: RefObject<string | null>
readonly activeSessionTabTypeRef: RefObject<string | null>
readonly canSend: boolean
readonly client: RpcClient | null
readonly clientRef: RefObject<RpcClient | null>
readonly connState: ConnectionState
readonly connStateRef: RefObject<ConnectionState>
readonly deviceTokenRef: RefObject<string | null>
readonly flushPendingLiveInputBeforeExternalSend: (handle: string) => Promise<boolean>
readonly getActiveWorktreeConnectionId: () => Promise<string | null>
readonly onError: () => void
readonly onSuccess: () => void
readonly ptyModesRef: RefObject<Map<string, TerminalModes>>
readonly refreshCanPaste: () => void
readonly showToast: (message: string, durationMs?: number) => void
}
export function useMobileTerminalPaste({
activeHandle,
activeHandleRef,
activeSessionTabTypeRef,
canSend,
client,
clientRef,
connState,
connStateRef,
deviceTokenRef,
flushPendingLiveInputBeforeExternalSend,
getActiveWorktreeConnectionId,
onError,
onSuccess,
ptyModesRef,
refreshCanPaste,
showToast
}: UseMobileTerminalPasteOptions): () => Promise<void> {
return useCallback(async () => {
if (!client || !activeHandle || !canSend) {
return
}
const targetHandle = activeHandle
try {
const text = await Clipboard.getStringAsync()
let payload: string | null = null
if (text.length > 0) {
payload = buildMobileTerminalClipboardTextPayload(
text,
ptyModesRef.current.get(targetHandle)
)
} else {
const image = await Clipboard.getImageAsync({ format: 'png' })
if (!image) {
refreshCanPaste()
return
}
const connectionId = await getActiveWorktreeConnectionId()
const base64 = await prepareMobileClipboardImageBase64(image, resizeMobileClipboardImage)
const imagePath = await saveMobileClipboardImageAsTempFile(client, base64, {
connectionId
})
payload = buildMobileImagePastePayload(imagePath)
}
const wrappedBytes = new TextEncoder().encode(payload).byteLength
if (wrappedBytes > 256 * 1024) {
onError()
// eslint-disable-next-line no-console
console.warn('[mobile-clip] paste oversized', { wrappedBytes })
showToast('Paste too large (max 256 KiB)', 1500)
return
}
// Why: paste lives in the accessory row and must not overtake pending IME text.
const flushedPendingInput = await flushPendingLiveInputBeforeExternalSend(targetHandle)
if (!flushedPendingInput) {
return
}
const currentClient = clientRef.current
if (
!currentClient ||
connStateRef.current !== 'connected' ||
targetHandle !== activeHandleRef.current ||
activeSessionTabTypeRef.current !== 'terminal'
) {
return
}
await currentClient.sendRequest('terminal.send', {
terminal: targetHandle,
text: payload,
enter: false,
...(deviceTokenRef.current
? { client: { id: deviceTokenRef.current, type: 'mobile' as const } }
: {})
})
onSuccess()
refreshCanPaste()
} catch (e) {
onError()
const err = e as { name?: string; message?: string }
const isDisconnected = connState !== 'connected'
// eslint-disable-next-line no-console
console.warn('[mobile-clip] paste failed', { name: err.name, message: err.message })
if (isDisconnected) {
showToast('Paste failed (disconnected)', 1500)
} else if (err.message === 'Clipboard image is too large') {
showToast('Image too large to paste', 1500)
} else {
showToast('Paste failed', 1500)
}
}
}, [
activeHandle,
activeHandleRef,
activeSessionTabTypeRef,
canSend,
client,
clientRef,
connState,
connStateRef,
deviceTokenRef,
flushPendingLiveInputBeforeExternalSend,
getActiveWorktreeConnectionId,
onError,
onSuccess,
ptyModesRef,
refreshCanPaste,
showToast
])
}

View File

@ -0,0 +1,21 @@
import type { TerminalLiveAccessoryLocalEdit } from './terminal-live-text-commit'
export type TerminalLiveAccessoryInput = {
readonly bytes: string
readonly localEdit?: TerminalLiveAccessoryLocalEdit
}
type TerminalLiveAccessoryKey = {
readonly bytes: string
readonly id: string
}
export function createTerminalLiveAccessoryInput(
key: TerminalLiveAccessoryKey
): TerminalLiveAccessoryInput {
if (key.id === 'backspace' || key.id === 'delete') {
return { bytes: key.bytes, localEdit: key.id }
}
return { bytes: key.bytes }
}

View File

@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { getTerminalLiveAccessoryRawSendTarget } from './terminal-live-accessory-raw-send-target'
describe('terminal live accessory raw send target', () => {
it('Given the original terminal is still active When raw fallback resumes Then returns that handle', () => {
// Given
const targetHandle = 'terminal-a'
// When
const sendTarget = getTerminalLiveAccessoryRawSendTarget({
targetHandle,
activeHandle: targetHandle,
activeSessionTabType: 'terminal'
})
// Then
expect(sendTarget).toBe(targetHandle)
})
it('Given the active terminal changed while waiting When raw fallback resumes Then suppresses the send', () => {
// Given
const targetHandle = 'terminal-a'
// When
const sendTarget = getTerminalLiveAccessoryRawSendTarget({
targetHandle,
activeHandle: 'terminal-b',
activeSessionTabType: 'terminal'
})
// Then
expect(sendTarget).toBeNull()
})
it('Given the target is not an active terminal tab When raw fallback resumes Then suppresses the send', () => {
// Given
const targetHandle = 'terminal-a'
// When
const inactiveTabTarget = getTerminalLiveAccessoryRawSendTarget({
targetHandle,
activeHandle: targetHandle,
activeSessionTabType: 'browser'
})
// Then
expect(inactiveTabTarget).toBeNull()
})
})

View File

@ -0,0 +1,17 @@
type TerminalLiveAccessoryRawSendTargetInput<TTabType extends string> = {
readonly targetHandle: string
readonly activeHandle: string | null
readonly activeSessionTabType: TTabType | null
}
export function getTerminalLiveAccessoryRawSendTarget<TTabType extends string>({
targetHandle,
activeHandle,
activeSessionTabType
}: TerminalLiveAccessoryRawSendTargetInput<TTabType>): string | null {
if (targetHandle !== activeHandle || activeSessionTabType !== 'terminal') {
return null
}
return targetHandle
}

View File

@ -0,0 +1,74 @@
import { describe, expect, it, vi } from 'vitest'
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
describe('terminal live control send order', () => {
it('Given a failed pending text flush When control bytes follow Then skips the control bytes', async () => {
// Given
const events: string[] = []
const flushPendingText = vi.fn(async () => {
events.push('flush')
return false
})
const sendControlBytes = vi.fn(async () => {
events.push('control')
return true
})
// When
const result = await sendTerminalLiveControlAfterPendingFlush(
flushPendingText,
sendControlBytes
)
// Then
expect(result).toBe(false)
expect(sendControlBytes).not.toHaveBeenCalled()
expect(events).toEqual(['flush'])
})
it('Given a successful pending text flush When control bytes follow Then sends them afterward', async () => {
// Given
const events: string[] = []
const flushPendingText = vi.fn(async () => {
events.push('flush')
return true
})
const sendControlBytes = vi.fn(async () => {
events.push('control')
return true
})
// When
const result = await sendTerminalLiveControlAfterPendingFlush(
flushPendingText,
sendControlBytes
)
// Then
expect(result).toBe(true)
expect(events).toEqual(['flush', 'control'])
})
it('Given a failed control byte send When pending text flushed Then reports failure', async () => {
// Given
const events: string[] = []
const flushPendingText = vi.fn(async () => {
events.push('flush')
return true
})
const sendControlBytes = vi.fn(async () => {
events.push('control')
return false
})
// When
const result = await sendTerminalLiveControlAfterPendingFlush(
flushPendingText,
sendControlBytes
)
// Then
expect(result).toBe(false)
expect(events).toEqual(['flush', 'control'])
})
})

View File

@ -0,0 +1,12 @@
export type TerminalLiveAsyncSendStep = () => Promise<boolean>
export async function sendTerminalLiveControlAfterPendingFlush(
flushPendingText: TerminalLiveAsyncSendStep,
sendControlBytes: TerminalLiveAsyncSendStep
): Promise<boolean> {
const flushed = await flushPendingText()
if (!flushed) {
return false
}
return sendControlBytes()
}

View File

@ -0,0 +1 @@
export type TerminalLiveInputSender = (handle: string, bytes: string) => Promise<boolean>

View File

@ -0,0 +1,146 @@
import { describe, expect, it } from 'vitest'
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
import {
queueTerminalLivePendingFlush,
waitForTerminalLivePendingFlush,
type TerminalLivePendingFlushState
} 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 }
// When / Then
await expect(waitForTerminalLivePendingFlush(state)).resolves.toBe(true)
})
it('Given an in-flight flush When control input waits Then control is held until flush succeeds', async () => {
// Given
const events: string[] = []
let resolveFlush: (value: boolean) => void = () => {}
const flushPromise = new Promise<boolean>((resolve) => {
resolveFlush = resolve
})
const state: TerminalLivePendingFlushState = { current: flushPromise }
// When
const controlSend = sendTerminalLiveControlAfterPendingFlush(
() => waitForTerminalLivePendingFlush(state),
async () => {
events.push('control')
return true
}
)
await Promise.resolve()
// Then
expect(events).toEqual([])
resolveFlush(true)
await expect(controlSend).resolves.toBe(true)
expect(events).toEqual(['control'])
})
it('Given an in-flight flush fails When control input waits Then control is skipped', async () => {
// Given
const events: string[] = []
let resolveFlush: (value: boolean) => void = () => {}
const flushPromise = new Promise<boolean>((resolve) => {
resolveFlush = resolve
})
const state: TerminalLivePendingFlushState = { current: flushPromise }
// When
const controlSend = sendTerminalLiveControlAfterPendingFlush(
() => waitForTerminalLivePendingFlush(state),
async () => {
events.push('control')
return true
}
)
resolveFlush(false)
// Then
await expect(controlSend).resolves.toBe(false)
expect(events).toEqual([])
})
it('Given a queued pending flush When it resolves or rejects Then clears the barrier', async () => {
// Given
const resolvedState: TerminalLivePendingFlushState = { current: null }
const rejectedState: TerminalLivePendingFlushState = { current: null }
// When
await expect(queueTerminalLivePendingFlush(resolvedState, async () => true)).resolves.toBe(true)
await expect(
queueTerminalLivePendingFlush(rejectedState, async () => {
throw new Error('send failed')
})
).resolves.toBe(false)
await Promise.resolve()
// Then
expect(resolvedState.current).toBeNull()
expect(rejectedState.current).toBeNull()
})
it('Given a current pending snapshot while another flush is in flight When queued Then sends current text after prior success', async () => {
// Given
const events: string[] = []
let resolveFirstFlush: (value: boolean) => void = () => {}
const firstFlush = new Promise<boolean>((resolve) => {
resolveFirstFlush = resolve
})
const state: TerminalLivePendingFlushState = { current: firstFlush }
// When
const secondFlush = queueTerminalLivePendingFlush(state, async () => {
events.push('second-flush')
return true
})
const controlSend = sendTerminalLiveControlAfterPendingFlush(
() => waitForTerminalLivePendingFlush(state),
async () => {
events.push('control')
return true
}
)
await Promise.resolve()
// Then
expect(events).toEqual([])
resolveFirstFlush(true)
await expect(secondFlush).resolves.toBe(true)
await expect(controlSend).resolves.toBe(true)
expect(events).toEqual(['second-flush', 'control'])
})
it('Given a prior pending flush fails When another snapshot is queued Then skips the current text and control', async () => {
// Given
const events: string[] = []
let resolveFirstFlush: (value: boolean) => void = () => {}
const firstFlush = new Promise<boolean>((resolve) => {
resolveFirstFlush = resolve
})
const state: TerminalLivePendingFlushState = { current: firstFlush }
// When
const secondFlush = queueTerminalLivePendingFlush(state, async () => {
events.push('second-flush')
return true
})
const controlSend = sendTerminalLiveControlAfterPendingFlush(
() => waitForTerminalLivePendingFlush(state),
async () => {
events.push('control')
return true
}
)
resolveFirstFlush(false)
// Then
await expect(secondFlush).resolves.toBe(false)
await expect(controlSend).resolves.toBe(false)
expect(events).toEqual([])
})
})

View File

@ -0,0 +1,29 @@
export type TerminalLivePendingFlushState = {
current: Promise<boolean> | null
}
export function waitForTerminalLivePendingFlush(
state: TerminalLivePendingFlushState
): Promise<boolean> {
return state.current ?? Promise.resolve(true)
}
export function queueTerminalLivePendingFlush(
state: TerminalLivePendingFlushState,
sendPendingText: () => Promise<boolean>
): Promise<boolean> {
const previousFlush = state.current
const flushPromise = (async () => {
if (previousFlush && !(await previousFlush)) {
return false
}
return sendPendingText()
})().catch(() => false)
state.current = flushPromise
void flushPromise.then(() => {
if (state.current === flushPromise) {
state.current = null
}
})
return flushPromise
}

View File

@ -0,0 +1,234 @@
import { describe, expect, it } from 'vitest'
import {
TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS,
getTerminalLiveAccessoryBytesDecision,
getTerminalLiveAccessoryLocalEditText,
getTerminalLiveDeferredTextDelayMs,
getTerminalLiveSpecialKeyDecision,
getTerminalLiveSubmitSequence,
getTerminalLiveTextChangeDecision,
isTerminalLiveTextHangulCandidate,
isTerminalLiveTextImeCandidate
} from './terminal-live-text-commit'
describe('terminal live text commit', () => {
it('Given Korean IME changes When live text changes Then defers candidates and submits only final text before carriage return', () => {
// Given
const koreanCompositionSteps = ['ㅎ', '하', '한'] as const
// When
const decisions = koreanCompositionSteps.map(getTerminalLiveTextChangeDecision)
const sentImmediately = decisions.filter((decision) => decision.kind === 'send-now')
const submitSequence = getTerminalLiveSubmitSequence('한')
const composedWordDecision = getTerminalLiveTextChangeDecision('한글')
const composedWordSubmitSequence = getTerminalLiveSubmitSequence('한글')
// Then
expect(decisions).toEqual([
{ kind: 'defer', text: 'ㅎ', delayMs: null },
{ kind: 'defer', text: '하', delayMs: null },
{ kind: 'defer', text: '한', delayMs: null }
])
expect(sentImmediately).toEqual([])
expect(isTerminalLiveTextHangulCandidate('ㅎ')).toBe(true)
expect(getTerminalLiveDeferredTextDelayMs('ㅎ')).toBeNull()
expect(submitSequence).toEqual(['한', '\r'])
expect(composedWordDecision).toEqual({
kind: 'defer',
text: '한글',
delayMs: null
})
expect(composedWordSubmitSequence).toEqual(['한글', '\r'])
})
it('Given non-Hangul IME text When live text changes Then keeps the bounded settle timer', () => {
// Given
const text = 'あ'
// When
const decision = getTerminalLiveTextChangeDecision(text)
// Then
expect(isTerminalLiveTextImeCandidate(text)).toBe(true)
expect(isTerminalLiveTextHangulCandidate(text)).toBe(false)
expect(getTerminalLiveDeferredTextDelayMs(text)).toBe(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS)
expect(decision).toEqual({ kind: 'defer', text, delayMs: TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS })
})
it('Given Chinese and Vietnamese IME text When live text changes Then does not use Hangul-only indefinite deferral', () => {
// Given
const nonHangulImeTexts = ['你好', 'tiếng Việt'] as const
for (const text of nonHangulImeTexts) {
// When
const decision = getTerminalLiveTextChangeDecision(text)
// Then
expect(isTerminalLiveTextImeCandidate(text)).toBe(true)
expect(isTerminalLiveTextHangulCandidate(text)).toBe(false)
expect(getTerminalLiveDeferredTextDelayMs(text)).toBe(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS)
expect(decision).toEqual({
kind: 'defer',
text,
delayMs: TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS
})
}
})
it('Given ASCII text When live text changes Then sends immediately', () => {
// Given
const text = 'abc123'
// When
const decision = getTerminalLiveTextChangeDecision(text)
// Then
expect(isTerminalLiveTextImeCandidate(text)).toBe(false)
expect(decision).toEqual({ kind: 'send-now', text })
})
it('Given empty text When live text changes Then ignores the change', () => {
// Given
const text = ''
// When
const decision = getTerminalLiveTextChangeDecision(text)
// Then
expect(isTerminalLiveTextImeCandidate(text)).toBe(false)
expect(decision).toEqual({ kind: 'ignore' })
})
it('Given pending text When Backspace or Delete is pressed Then keeps edits local', () => {
// Given
const pendingText = '한'
// When
const backspaceDecision = getTerminalLiveSpecialKeyDecision({ key: 'Backspace', pendingText })
const deleteDecision = getTerminalLiveSpecialKeyDecision({ key: 'Delete', pendingText })
// Then
expect(backspaceDecision).toEqual({ kind: 'local-edit' })
expect(deleteDecision).toEqual({ kind: 'local-edit' })
})
it('Given no pending text When Backspace or Delete is pressed Then sends terminal bytes', () => {
// Given
const pendingText = ''
// When
const backspaceDecision = getTerminalLiveSpecialKeyDecision({ key: 'Backspace', pendingText })
const deleteDecision = getTerminalLiveSpecialKeyDecision({ key: 'Delete', pendingText })
// Then
expect(backspaceDecision).toEqual({ kind: 'send-now', bytes: '\x7f' })
expect(deleteDecision).toEqual({ kind: 'send-now', bytes: '\x1b[3~' })
})
it('Given pending text When a terminal special key is pressed Then flushes pending text before bytes', () => {
// Given
const pendingText = '한'
// When
const decision = getTerminalLiveSpecialKeyDecision({ key: 'Tab', pendingText })
// Then
expect(decision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\t' })
})
it('Given pending text When accessory control bytes are requested Then flushes pending text before bytes', () => {
// Given
const pendingText = '한글'
// When
const tabDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\t', pendingText })
const escapeDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\x1b', pendingText })
const enterDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\r', pendingText })
// Then
expect(tabDecision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\t' })
expect(escapeDecision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\x1b' })
expect(enterDecision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\r' })
})
it('Given pending text When accessory Backspace or Delete bytes are requested Then keeps edits local', () => {
// Given
const pendingText = '한글'
// When
const backspaceDecision = getTerminalLiveAccessoryBytesDecision({
bytes: '\x7f',
localEdit: 'backspace',
pendingText
})
const ctrlBackspaceDecision = getTerminalLiveAccessoryBytesDecision({
bytes: '\b',
localEdit: 'backspace',
pendingText
})
const deleteDecision = getTerminalLiveAccessoryBytesDecision({
bytes: '\x1b[3~',
localEdit: 'delete',
pendingText
})
const customDeleteByteDecision = getTerminalLiveAccessoryBytesDecision({
bytes: '\x7f',
pendingText
})
const backspaceText = getTerminalLiveAccessoryLocalEditText({
localEdit: 'backspace',
pendingText
})
const deleteText = getTerminalLiveAccessoryLocalEditText({
localEdit: 'delete',
pendingText
})
// Then
expect(backspaceDecision).toEqual({ kind: 'local-edit', localEdit: 'backspace' })
expect(ctrlBackspaceDecision).toEqual({ kind: 'local-edit', localEdit: 'backspace' })
expect(deleteDecision).toEqual({ kind: 'local-edit', localEdit: 'delete' })
expect(customDeleteByteDecision).toEqual({
kind: 'flush-then-send',
pendingText,
bytes: '\x7f'
})
expect(backspaceText).toBe('한')
expect(deleteText).toBe('한글')
expect(getTerminalLiveDeferredTextDelayMs(backspaceText)).toBeNull()
expect(getTerminalLiveDeferredTextDelayMs(deleteText)).toBeNull()
})
it('Given no pending text When accessory bytes are requested Then sends terminal bytes', () => {
// Given
const pendingText = ''
// When
const tabDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\t', pendingText })
// Then
expect(tabDecision).toEqual({ kind: 'send-now', bytes: '\t' })
})
it('Given a non-special key When key decision is requested Then ignores it', () => {
// Given
const key = 'a'
// When
const decision = getTerminalLiveSpecialKeyDecision({ key, pendingText: '한' })
// Then
expect(decision).toEqual({ kind: 'ignore' })
})
it('Given no pending text When submit is requested Then sends only carriage return', () => {
// Given
const pendingText = ''
// When
const sequence = getTerminalLiveSubmitSequence(pendingText)
// Then
expect(sequence).toEqual(['\r'])
})
})

View File

@ -0,0 +1,145 @@
import { getTerminalLiveSpecialKeyBytes } from './terminal-live-input'
// Why: React Native does not expose portable composition events here, so
// non-Hangul IME text gets a short settle window before being sent to the PTY.
export const TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS = 150
export type TerminalLiveTextChangeDecision =
| { readonly kind: 'ignore' }
| { readonly kind: 'send-now'; readonly text: string }
| { readonly kind: 'defer'; readonly text: string; readonly delayMs: number | null }
export type TerminalLiveSpecialKeyDecision =
| { readonly kind: 'ignore' }
| { readonly kind: 'local-edit' }
| { readonly kind: 'send-now'; readonly bytes: string }
| { readonly kind: 'flush-then-send'; readonly pendingText: string; readonly bytes: string }
export type TerminalLiveSpecialKeyDecisionInput = {
readonly key: string
readonly pendingText: string
}
export type TerminalLiveAccessoryLocalEdit = 'backspace' | 'delete'
export type TerminalLiveAccessoryBytesDecision =
| { readonly kind: 'local-edit'; readonly localEdit: TerminalLiveAccessoryLocalEdit }
| { readonly kind: 'send-now'; readonly bytes: string }
| { readonly kind: 'flush-then-send'; readonly pendingText: string; readonly bytes: string }
export type TerminalLiveAccessoryBytesDecisionInput = {
readonly bytes: string
readonly localEdit?: TerminalLiveAccessoryLocalEdit
readonly pendingText: string
}
export function isTerminalLiveTextImeCandidate(text: string): boolean {
for (const character of text) {
const codePoint = character.codePointAt(0)
if (codePoint !== undefined && codePoint > 0x7f) {
return true
}
}
return false
}
function isHangulCodePoint(codePoint: number): boolean {
return (
(codePoint >= 0x1100 && codePoint <= 0x11ff) ||
(codePoint >= 0x3130 && codePoint <= 0x318f) ||
(codePoint >= 0xa960 && codePoint <= 0xa97f) ||
(codePoint >= 0xac00 && codePoint <= 0xd7af)
)
}
export function isTerminalLiveTextHangulCandidate(text: string): boolean {
for (const character of text) {
const codePoint = character.codePointAt(0)
if (codePoint !== undefined && isHangulCodePoint(codePoint)) {
return true
}
}
return false
}
export function getTerminalLiveTextChangeDecision(text: string): TerminalLiveTextChangeDecision {
if (text.length === 0) {
return { kind: 'ignore' }
}
if (isTerminalLiveTextHangulCandidate(text)) {
return { kind: 'defer', text, delayMs: null }
}
if (isTerminalLiveTextImeCandidate(text)) {
return { kind: 'defer', text, delayMs: TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS }
}
return { kind: 'send-now', text }
}
export function getTerminalLiveDeferredTextDelayMs(text: string): number | null {
return isTerminalLiveTextHangulCandidate(text) ? null : TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS
}
export function getTerminalLiveSpecialKeyDecision({
key,
pendingText
}: TerminalLiveSpecialKeyDecisionInput): TerminalLiveSpecialKeyDecision {
const bytes = getTerminalLiveSpecialKeyBytes(key)
if (bytes === null) {
return { kind: 'ignore' }
}
if (pendingText.length > 0 && (key === 'Backspace' || key === 'Delete')) {
return { kind: 'local-edit' }
}
if (pendingText.length > 0) {
return { kind: 'flush-then-send', pendingText, bytes }
}
return { kind: 'send-now', bytes }
}
export function getTerminalLiveAccessoryBytesDecision({
bytes,
localEdit,
pendingText
}: TerminalLiveAccessoryBytesDecisionInput): TerminalLiveAccessoryBytesDecision {
if (pendingText.length > 0 && localEdit) {
return { kind: 'local-edit', localEdit }
}
if (pendingText.length > 0) {
return { kind: 'flush-then-send', pendingText, bytes }
}
return { kind: 'send-now', bytes }
}
export function getTerminalLiveAccessoryLocalEditText({
localEdit,
pendingText
}: {
readonly localEdit: TerminalLiveAccessoryLocalEdit
readonly pendingText: string
}): string {
if (localEdit === 'delete') {
// Why: accessory Delete mirrors forward-delete at the hidden input's end;
// it stays local but does not remove the pending IME text.
return pendingText
}
return Array.from(pendingText).slice(0, -1).join('')
}
export type TerminalLiveSubmitSequence = readonly ['\r'] | readonly [string, '\r']
export function getTerminalLiveSubmitSequence(pendingText: string): TerminalLiveSubmitSequence {
if (pendingText.length === 0) {
return ['\r']
}
return [pendingText, '\r']
}

View File

@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import type { RpcResponse } from '../transport/types'
import { isTerminalSendRpcAccepted } from './terminal-send-rpc-response'
const runtimeMeta = { runtimeId: 'test-runtime' } as const
describe('terminal send RPC response', () => {
it('Given accepted terminal send response When checked Then reports success', () => {
// Given
const response: RpcResponse = {
id: '1',
ok: true,
result: { send: { handle: 'terminal-1', accepted: true, bytesWritten: 1 } },
_meta: runtimeMeta
}
// When / Then
expect(isTerminalSendRpcAccepted(response)).toBe(true)
})
it('Given rejected terminal send response When checked Then reports failure', () => {
// Given
const response: RpcResponse = {
id: '1',
ok: true,
result: { send: { handle: 'terminal-1', accepted: false, bytesWritten: 0 } },
_meta: runtimeMeta
}
// When / Then
expect(isTerminalSendRpcAccepted(response)).toBe(false)
})
it('Given RPC failure or malformed terminal send response When checked Then reports failure', () => {
// Given
const rpcFailure: RpcResponse = {
id: '1',
ok: false,
error: { code: 'terminal_error', message: 'failed' },
_meta: runtimeMeta
}
const malformedSuccess: RpcResponse = {
id: '2',
ok: true,
result: {},
_meta: runtimeMeta
}
// When / Then
expect(isTerminalSendRpcAccepted(rpcFailure)).toBe(false)
expect(isTerminalSendRpcAccepted(malformedSuccess)).toBe(false)
})
})

View File

@ -0,0 +1,15 @@
import type { RpcResponse } from '../transport/types'
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
export function isTerminalSendRpcAccepted(response: RpcResponse): boolean {
if (!response.ok) {
return false
}
if (!isRecord(response.result) || !isRecord(response.result.send)) {
return false
}
return response.result.send.accepted === true
}

View File

@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import { getTerminalLiveAccessoryInactiveInputCommitResult } from './use-terminal-live-accessory-input-commit'
type DeferredBoolean = {
readonly promise: Promise<boolean>
readonly resolve: (value: boolean) => void
}
function createDeferredBoolean(): DeferredBoolean {
let resolvePromise: (value: boolean) => void = () => {
throw new Error('deferred promise was resolved before initialization')
}
const promise = new Promise<boolean>((resolve) => {
resolvePromise = resolve
})
return { promise, resolve: resolvePromise }
}
describe('terminal live accessory input commit', () => {
it('Given live input is disabled with an active flush When accessory raw fallback is requested Then waits before allowing raw send', async () => {
// Given
const deferredFlush = createDeferredBoolean()
let settled = false
// When
const resultPromise = getTerminalLiveAccessoryInactiveInputCommitResult(
() => deferredFlush.promise
)
void resultPromise.then(() => {
settled = true
})
await Promise.resolve()
// Then
expect(settled).toBe(false)
deferredFlush.resolve(true)
await expect(resultPromise).resolves.toEqual({ kind: 'allow-raw' })
})
it('Given live input is disabled with a failed active flush When accessory raw fallback is requested Then suppresses raw send', async () => {
// Given
const waitForPendingLiveInputFlush = async (): Promise<boolean> => false
// When
const result = await getTerminalLiveAccessoryInactiveInputCommitResult(
waitForPendingLiveInputFlush
)
// Then
expect(result).toEqual({ kind: 'suppress-raw' })
})
})

View File

@ -0,0 +1,124 @@
import { useCallback, type RefObject } from 'react'
import type { TextInput } from 'react-native'
import {
getTerminalLiveAccessoryBytesDecision,
getTerminalLiveAccessoryLocalEditText,
getTerminalLiveDeferredTextDelayMs
} from './terminal-live-text-commit'
import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input'
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
export type TerminalLiveAccessoryInputCommitResult =
| { readonly kind: 'allow-raw' }
| { readonly kind: 'handled' }
| { readonly kind: 'suppress-raw' }
type TerminalLiveInputCommitScheduler = (
handle: string,
text: string,
delayMs: number | null
) => void
export async function getTerminalLiveAccessoryInactiveInputCommitResult(
waitForPendingLiveInputFlush: () => Promise<boolean>
): Promise<TerminalLiveAccessoryInputCommitResult> {
return (await waitForPendingLiveInputFlush()) ? { kind: 'allow-raw' } : { kind: 'suppress-raw' }
}
type TerminalLiveAccessoryInputCommitOptions = {
readonly activeHandle: string | null
readonly clearPendingLiveInputCommit: () => void
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean>
readonly liveInputRef: RefObject<TextInput | null>
readonly liveInputTerminalHandles: ReadonlySet<string>
readonly pendingLiveInputHandleRef: RefObject<string | null>
readonly pendingLiveInputTextRef: RefObject<string>
readonly schedulePendingLiveInputCommit: TerminalLiveInputCommitScheduler
readonly sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender>
readonly setLiveInputCapture: (text: string) => void
readonly waitForPendingLiveInputFlush: () => Promise<boolean>
}
export function useTerminalLiveAccessoryInputCommit({
activeHandle,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
liveInputRef,
liveInputTerminalHandles,
pendingLiveInputHandleRef,
pendingLiveInputTextRef,
schedulePendingLiveInputCommit,
sendLiveTerminalInputRef,
setLiveInputCapture,
waitForPendingLiveInputFlush
}: TerminalLiveAccessoryInputCommitOptions): (
input: TerminalLiveAccessoryInput
) => Promise<TerminalLiveAccessoryInputCommitResult> {
return useCallback(
async (input: TerminalLiveAccessoryInput): Promise<TerminalLiveAccessoryInputCommitResult> => {
if (!activeHandle) {
return { kind: 'allow-raw' }
}
if (!liveInputTerminalHandles.has(activeHandle)) {
return getTerminalLiveAccessoryInactiveInputCommitResult(waitForPendingLiveInputFlush)
}
const pendingText =
pendingLiveInputHandleRef.current === activeHandle ? pendingLiveInputTextRef.current : ''
if (pendingLiveInputHandleRef.current && pendingLiveInputHandleRef.current !== activeHandle) {
clearPendingLiveInputCommit()
}
const decision = getTerminalLiveAccessoryBytesDecision({ ...input, pendingText })
switch (decision.kind) {
case 'send-now':
// Why: raw accessory bytes must wait behind any in-flight IME text
// flush so composed Hangul reaches the PTY before follow-up controls.
return (await waitForPendingLiveInputFlush())
? { kind: 'allow-raw' }
: { kind: 'suppress-raw' }
case 'local-edit': {
const editedText = getTerminalLiveAccessoryLocalEditText({
localEdit: decision.localEdit,
pendingText
})
if (editedText.length === 0) {
clearPendingLiveInputCommit()
return { kind: 'handled' }
}
// Why: accessory buttons do not emit native TextInput edits, so the
// pending IME buffer must be edited and rescheduled here.
setLiveInputCapture(editedText)
liveInputRef.current?.setNativeProps({ text: editedText })
schedulePendingLiveInputCommit(
activeHandle,
editedText,
getTerminalLiveDeferredTextDelayMs(editedText)
)
return { kind: 'handled' }
}
case 'flush-then-send':
await sendTerminalLiveControlAfterPendingFlush(
() => flushPendingLiveInputText(activeHandle),
() => sendLiveTerminalInputRef.current(activeHandle, decision.bytes)
)
return { kind: 'handled' }
default:
decision satisfies never
return { kind: 'handled' }
}
},
[
activeHandle,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
liveInputRef,
liveInputTerminalHandles,
pendingLiveInputHandleRef,
pendingLiveInputTextRef,
schedulePendingLiveInputCommit,
sendLiveTerminalInputRef,
setLiveInputCapture,
waitForPendingLiveInputFlush
]
)
}

View File

@ -0,0 +1,184 @@
import { createElement, type RefObject } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import type { TextInput } from 'react-native'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
import { TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS } from './terminal-live-text-commit'
import { useTerminalLiveInputCommit } from './use-terminal-live-input-commit'
type TerminalLiveInputCommitHarness = {
readonly captures: readonly string[]
readonly handlers: ReturnType<typeof useTerminalLiveInputCommit<string>>
readonly sent: readonly string[]
readonly unmount: () => void
}
type TerminalLiveInputCommitHarnessOptions = {
readonly sendResult?: boolean
}
function suppressReactTestRendererDeprecationWarning(): () => void {
const originalConsoleError = console.error
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
const firstArg = args[0]
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
return
}
originalConsoleError(...args)
})
return () => consoleErrorSpy.mockRestore()
}
function createTerminalLiveInputCommitHarness({
sendResult = true
}: TerminalLiveInputCommitHarnessOptions = {}): TerminalLiveInputCommitHarness {
const activeHandle = 'terminal-a'
const activeHandleRef: RefObject<string | null> = { current: activeHandle }
const activeSessionTabTypeRef: RefObject<string | null> = { current: 'terminal' }
const captures: string[] = []
const liveInputRef: RefObject<TextInput | null> = { current: null }
const liveInputTerminalHandles = new Set([activeHandle])
const liveInputTerminalHandlesRef: RefObject<Set<string>> = {
current: new Set([activeHandle])
}
const sent: string[] = []
const sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender> = {
current: async (_handle, bytes) => {
sent.push(bytes)
return sendResult
}
}
let handlers: ReturnType<typeof useTerminalLiveInputCommit<string>> | null = null
let renderer: ReactTestRenderer | null = null
function Harness(): null {
handlers = useTerminalLiveInputCommit({
activeHandle,
activeHandleRef,
activeSessionTabType: 'terminal',
activeSessionTabTypeRef,
liveInputRef,
liveInputTerminalHandles,
liveInputTerminalHandlesRef,
sendLiveTerminalInputRef,
setLiveInputCapture: (text) => captures.push(text)
})
return null
}
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
try {
act(() => {
renderer = create(createElement(Harness))
})
} finally {
restoreConsoleError()
}
if (!handlers || !renderer) {
throw new Error('terminal live input hook did not render')
}
return {
captures,
handlers,
sent,
unmount: () => {
act(() => renderer?.unmount())
}
}
}
describe('terminal live input commit hook', () => {
afterEach(() => {
vi.useRealTimers()
})
it('Given Hangul pending text When the old idle window elapses Then does not send jamo to the terminal', async () => {
// Given
vi.useFakeTimers()
const { captures, handlers, sent } = createTerminalLiveInputCommitHarness()
// When
handlers.handleLiveInputChange('ㅎ')
await vi.advanceTimersByTimeAsync(1_000)
// Then
expect(captures).toEqual(['ㅎ'])
expect(sent).toEqual([])
})
it('Given Hangul pending text When submit is requested Then sends composed text before carriage return', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('한')
// When
handlers.handleLiveInputSubmit()
// Then
await vi.waitFor(() => expect(sent).toEqual(['한', '\r']))
})
it('Given Hangul pending text When an external terminal send is requested Then flushes composed text first', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('한')
// When
const flushed = await handlers.flushPendingLiveInputBeforeExternalSend('terminal-a')
// Then
expect(flushed).toBe(true)
expect(sent).toEqual(['한'])
})
it('Given pending text cannot be sent When an external terminal send is requested Then reports failure', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness({ sendResult: false })
handlers.handleLiveInputChange('한')
// When
const flushed = await handlers.flushPendingLiveInputBeforeExternalSend('terminal-a')
// Then
expect(flushed).toBe(false)
expect(sent).toEqual(['한'])
})
it('Given Chinese and Vietnamese IME text When the settle window elapses Then sends the committed text', async () => {
// Given
vi.useFakeTimers()
const { captures, handlers, sent } = createTerminalLiveInputCommitHarness()
// When
handlers.handleLiveInputChange('你好')
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS - 1)
// Then
expect(captures).toEqual(['你好'])
expect(sent).toEqual([])
// When
await vi.advanceTimersByTimeAsync(1)
await vi.waitFor(() => expect(sent).toEqual(['你好']))
handlers.handleLiveInputChange('tiếng Việt')
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS)
// Then
await vi.waitFor(() => expect(sent).toEqual(['你好', 'tiếng Việt']))
})
it('Given deferred IME text When the hook unmounts Then cancels the pending commit timer', async () => {
// Given
vi.useFakeTimers()
const { handlers, sent, unmount } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('é')
// When
unmount()
await vi.advanceTimersByTimeAsync(1_000)
// Then
expect(sent).toEqual([])
})
})

View File

@ -0,0 +1,236 @@
import { useCallback, useEffect, type RefObject } from 'react'
import type { TextInput } from 'react-native'
import {
getTerminalLiveSpecialKeyDecision,
getTerminalLiveSubmitSequence,
getTerminalLiveTextChangeDecision
} from './terminal-live-text-commit'
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input'
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
import { normalizeTerminalTextInput } from './terminal-text-input-normalization'
import { useTerminalLivePendingInputFlush } from './use-terminal-live-pending-input-flush'
import {
useTerminalLiveAccessoryInputCommit,
type TerminalLiveAccessoryInputCommitResult
} from './use-terminal-live-accessory-input-commit'
type TerminalLiveInputKeyPressEvent = {
readonly nativeEvent: {
readonly key: string
}
}
type TerminalLiveInputCommitOptions<TTabType extends string> = {
readonly activeHandle: string | null
readonly activeHandleRef: RefObject<string | null>
readonly activeSessionTabType: TTabType | null | undefined
readonly activeSessionTabTypeRef: RefObject<TTabType | null>
readonly liveInputRef: RefObject<TextInput | null>
readonly liveInputTerminalHandles: ReadonlySet<string>
readonly liveInputTerminalHandlesRef: RefObject<Set<string>>
readonly sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender>
readonly setLiveInputCapture: (text: string) => void
}
type TerminalLiveInputCommitHandlers = {
readonly clearPendingLiveInputCommit: () => void
readonly flushPendingLiveInputBeforeExternalSend: (handle: string) => Promise<boolean>
readonly handleLiveInputAccessoryBytes: (
input: TerminalLiveAccessoryInput
) => Promise<TerminalLiveAccessoryInputCommitResult>
readonly handleLiveInputChange: (text: string) => void
readonly handleLiveInputKeyPress: (event: TerminalLiveInputKeyPressEvent) => void
readonly handleLiveInputSubmit: () => void
}
export function useTerminalLiveInputCommit<TTabType extends string>({
activeHandle,
activeHandleRef,
activeSessionTabType,
activeSessionTabTypeRef,
liveInputRef,
liveInputTerminalHandles,
liveInputTerminalHandlesRef,
sendLiveTerminalInputRef,
setLiveInputCapture
}: TerminalLiveInputCommitOptions<TTabType>): TerminalLiveInputCommitHandlers {
const {
clearPendingLiveInputCommit,
flushPendingLiveInputText,
pendingLiveInputHandleRef,
pendingLiveInputTextRef,
schedulePendingLiveInputCommit,
waitForPendingLiveInputFlush
} = useTerminalLivePendingInputFlush({
activeHandleRef,
activeSessionTabTypeRef,
liveInputRef,
liveInputTerminalHandlesRef,
sendLiveTerminalInputRef,
setLiveInputCapture
})
useEffect(() => {
const pendingHandle = pendingLiveInputHandleRef.current
if (!pendingHandle) {
return
}
if (
!activeHandle ||
pendingHandle !== activeHandle ||
activeSessionTabType !== 'terminal' ||
!liveInputTerminalHandles.has(activeHandle)
) {
clearPendingLiveInputCommit()
}
}, [activeHandle, activeSessionTabType, clearPendingLiveInputCommit, liveInputTerminalHandles])
const flushPendingLiveInputBeforeExternalSend = useCallback(
async (handle: string): Promise<boolean> => {
const pendingHandle = pendingLiveInputHandleRef.current
if (pendingHandle && pendingHandle !== handle) {
clearPendingLiveInputCommit()
return waitForPendingLiveInputFlush()
}
if (pendingHandle === handle && pendingLiveInputTextRef.current.length > 0) {
return flushPendingLiveInputText(handle)
}
return waitForPendingLiveInputFlush()
},
[clearPendingLiveInputCommit, flushPendingLiveInputText, waitForPendingLiveInputFlush]
)
const handleLiveInputChange = useCallback(
(text: string) => {
if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) {
clearPendingLiveInputCommit()
return
}
const normalizedText = normalizeTerminalTextInput(text)
const decision = getTerminalLiveTextChangeDecision(normalizedText)
switch (decision.kind) {
case 'ignore':
clearPendingLiveInputCommit()
return
case 'send-now':
clearPendingLiveInputCommit()
void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () =>
sendLiveTerminalInputRef.current(activeHandle, decision.text)
)
return
case 'defer':
// Why: React Native does not expose composition events here, so keep
// probable IME text in the native field until the commit timer settles.
setLiveInputCapture(decision.text)
schedulePendingLiveInputCommit(activeHandle, decision.text, decision.delayMs)
return
default:
decision satisfies never
}
},
[
activeHandle,
clearPendingLiveInputCommit,
liveInputTerminalHandles,
schedulePendingLiveInputCommit,
sendLiveTerminalInputRef,
setLiveInputCapture,
waitForPendingLiveInputFlush
]
)
const handleLiveInputKeyPress = useCallback(
(event: TerminalLiveInputKeyPressEvent) => {
if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) {
return
}
const pendingText =
pendingLiveInputHandleRef.current === activeHandle ? pendingLiveInputTextRef.current : ''
if (pendingLiveInputHandleRef.current && pendingLiveInputHandleRef.current !== activeHandle) {
clearPendingLiveInputCommit()
}
const decision = getTerminalLiveSpecialKeyDecision({
key: event.nativeEvent.key,
pendingText
})
switch (decision.kind) {
case 'ignore':
case 'local-edit':
return
case 'send-now':
clearPendingLiveInputCommit()
void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () =>
sendLiveTerminalInputRef.current(activeHandle, decision.bytes)
)
return
case 'flush-then-send':
void sendTerminalLiveControlAfterPendingFlush(
() => flushPendingLiveInputText(activeHandle),
() => sendLiveTerminalInputRef.current(activeHandle, decision.bytes)
)
return
default:
decision satisfies never
}
},
[
activeHandle,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
liveInputTerminalHandles,
sendLiveTerminalInputRef,
waitForPendingLiveInputFlush
]
)
const handleLiveInputAccessoryBytes = useTerminalLiveAccessoryInputCommit({
activeHandle,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
liveInputRef,
liveInputTerminalHandles,
pendingLiveInputHandleRef,
pendingLiveInputTextRef,
schedulePendingLiveInputCommit,
sendLiveTerminalInputRef,
setLiveInputCapture,
waitForPendingLiveInputFlush
})
const handleLiveInputSubmit = useCallback(() => {
if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) {
return
}
const pendingText =
pendingLiveInputHandleRef.current === activeHandle ? pendingLiveInputTextRef.current : ''
const sequence = getTerminalLiveSubmitSequence(pendingText)
if (sequence.length === 2) {
void sendTerminalLiveControlAfterPendingFlush(
() => flushPendingLiveInputText(activeHandle),
() => sendLiveTerminalInputRef.current(activeHandle, sequence[1])
)
return
}
clearPendingLiveInputCommit()
void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () =>
sendLiveTerminalInputRef.current(activeHandle, sequence[0])
)
}, [
activeHandle,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
liveInputTerminalHandles,
sendLiveTerminalInputRef,
waitForPendingLiveInputFlush
])
return {
clearPendingLiveInputCommit,
flushPendingLiveInputBeforeExternalSend,
handleLiveInputAccessoryBytes,
handleLiveInputChange,
handleLiveInputKeyPress,
handleLiveInputSubmit
}
}

View File

@ -0,0 +1,139 @@
import { useCallback, useEffect, useRef, type RefObject } from 'react'
import type { TextInput } from 'react-native'
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
import {
queueTerminalLivePendingFlush,
waitForTerminalLivePendingFlush
} from './terminal-live-pending-flush-state'
type TerminalLivePendingInputFlushOptions<TTabType extends string> = {
readonly activeHandleRef: RefObject<string | null>
readonly activeSessionTabTypeRef: RefObject<TTabType | null>
readonly liveInputRef: RefObject<TextInput | null>
readonly liveInputTerminalHandlesRef: RefObject<Set<string>>
readonly sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender>
readonly setLiveInputCapture: (text: string) => void
}
type TerminalLivePendingInputFlush = {
readonly clearPendingLiveInputCommit: () => void
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean>
readonly pendingLiveInputHandleRef: RefObject<string | null>
readonly pendingLiveInputTextRef: RefObject<string>
readonly schedulePendingLiveInputCommit: (
handle: string,
text: string,
delayMs: number | null
) => void
readonly waitForPendingLiveInputFlush: () => Promise<boolean>
}
export function useTerminalLivePendingInputFlush<TTabType extends string>({
activeHandleRef,
activeSessionTabTypeRef,
liveInputRef,
liveInputTerminalHandlesRef,
sendLiveTerminalInputRef,
setLiveInputCapture
}: TerminalLivePendingInputFlushOptions<TTabType>): TerminalLivePendingInputFlush {
const liveInputCommitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pendingLiveInputFlushRef = useRef<Promise<boolean> | null>(null)
const pendingLiveInputTextRef = useRef('')
const pendingLiveInputHandleRef = useRef<string | null>(null)
const clearPendingLiveInputCommit = useCallback(() => {
if (liveInputCommitTimerRef.current) {
clearTimeout(liveInputCommitTimerRef.current)
liveInputCommitTimerRef.current = null
}
pendingLiveInputTextRef.current = ''
pendingLiveInputHandleRef.current = null
setLiveInputCapture('')
liveInputRef.current?.setNativeProps({ text: '' })
}, [liveInputRef, setLiveInputCapture])
const waitForPendingLiveInputFlush = useCallback(async (): Promise<boolean> => {
return waitForTerminalLivePendingFlush(pendingLiveInputFlushRef)
}, [])
const flushPendingLiveInputText = useCallback(
async (expectedHandle: string | null): Promise<boolean> => {
const existingFlush = pendingLiveInputFlushRef.current
if (liveInputCommitTimerRef.current) {
clearTimeout(liveInputCommitTimerRef.current)
liveInputCommitTimerRef.current = null
}
const handle = pendingLiveInputHandleRef.current
const text = pendingLiveInputTextRef.current
pendingLiveInputHandleRef.current = null
pendingLiveInputTextRef.current = ''
setLiveInputCapture('')
liveInputRef.current?.setNativeProps({ text: '' })
if (!handle || text.length === 0) {
return existingFlush ?? false
}
if (
(expectedHandle !== null && handle !== expectedHandle) ||
handle !== activeHandleRef.current ||
activeSessionTabTypeRef.current !== 'terminal' ||
!liveInputTerminalHandlesRef.current.has(handle)
) {
return false
}
return queueTerminalLivePendingFlush(pendingLiveInputFlushRef, () =>
sendLiveTerminalInputRef.current(handle, text)
)
},
[
activeHandleRef,
activeSessionTabTypeRef,
liveInputRef,
liveInputTerminalHandlesRef,
sendLiveTerminalInputRef,
setLiveInputCapture
]
)
const schedulePendingLiveInputCommit = useCallback(
(handle: string, text: string, delayMs: number | null) => {
if (liveInputCommitTimerRef.current) {
clearTimeout(liveInputCommitTimerRef.current)
}
pendingLiveInputHandleRef.current = handle
pendingLiveInputTextRef.current = text
if (delayMs === null) {
liveInputCommitTimerRef.current = null
return
}
liveInputCommitTimerRef.current = setTimeout(() => {
liveInputCommitTimerRef.current = null
void flushPendingLiveInputText(handle)
}, delayMs)
},
[flushPendingLiveInputText]
)
useEffect(() => {
return () => {
if (liveInputCommitTimerRef.current) {
clearTimeout(liveInputCommitTimerRef.current)
liveInputCommitTimerRef.current = null
}
pendingLiveInputHandleRef.current = null
pendingLiveInputTextRef.current = ''
pendingLiveInputFlushRef.current = null
}
}, [])
return {
clearPendingLiveInputCommit,
flushPendingLiveInputText,
pendingLiveInputHandleRef,
pendingLiveInputTextRef,
schedulePendingLiveInputCommit,
waitForPendingLiveInputFlush
}
}

View File

@ -8,6 +8,11 @@ export type TerminalCommandFinishedEventDetail = {
// decoupled consumers (e.g. git status refresh) react to shell commands
// finishing without reaching into terminal internals.
export function dispatchTerminalCommandFinishedEvent(worktreeId: string): void {
// Why: unit tests and non-DOM renderer shims may expose only the preload API.
if (typeof window.dispatchEvent !== 'function') {
return
}
window.dispatchEvent(
new CustomEvent<TerminalCommandFinishedEventDetail>(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, {
detail: { worktreeId }