From 4f0a141951fd91db413d90701ee3371667b3961e Mon Sep 17 00:00:00 2001 From: WONGIL Date: Thu, 2 Jul 2026 18:31:19 +0900 Subject: [PATCH] Fix mobile terminal Korean IME composition on Android (#7011) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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> --- .../app/h/[hostId]/session/[worktreeId].tsx | 326 +++++++----------- mobile/package.json | 2 + mobile/pnpm-lock.yaml | 63 +++- .../session/mobile-image-attachment.test.ts | 26 ++ mobile/src/session/mobile-image-attachment.ts | 7 +- .../session/use-mobile-image-attachment.ts | 8 +- .../src/session/use-mobile-terminal-paste.ts | 200 +++++++++++ .../terminal/terminal-live-accessory-input.ts | 21 ++ ...nal-live-accessory-raw-send-target.test.ts | 49 +++ ...terminal-live-accessory-raw-send-target.ts | 17 + .../terminal-live-control-send-order.test.ts | 74 ++++ .../terminal-live-control-send-order.ts | 12 + .../terminal/terminal-live-input-sender.ts | 1 + .../terminal-live-pending-flush-state.test.ts | 146 ++++++++ .../terminal-live-pending-flush-state.ts | 29 ++ .../terminal-live-text-commit.test.ts | 234 +++++++++++++ .../src/terminal/terminal-live-text-commit.ts | 145 ++++++++ .../terminal-send-rpc-response.test.ts | 53 +++ .../terminal/terminal-send-rpc-response.ts | 15 + ...rminal-live-accessory-input-commit.test.ts | 52 +++ ...se-terminal-live-accessory-input-commit.ts | 124 +++++++ .../use-terminal-live-input-commit.test.ts | 184 ++++++++++ .../use-terminal-live-input-commit.ts | 236 +++++++++++++ .../use-terminal-live-pending-input-flush.ts | 139 ++++++++ .../hooks/terminal-command-finished-event.ts | 5 + 25 files changed, 1945 insertions(+), 223 deletions(-) create mode 100644 mobile/src/session/use-mobile-terminal-paste.ts create mode 100644 mobile/src/terminal/terminal-live-accessory-input.ts create mode 100644 mobile/src/terminal/terminal-live-accessory-raw-send-target.test.ts create mode 100644 mobile/src/terminal/terminal-live-accessory-raw-send-target.ts create mode 100644 mobile/src/terminal/terminal-live-control-send-order.test.ts create mode 100644 mobile/src/terminal/terminal-live-control-send-order.ts create mode 100644 mobile/src/terminal/terminal-live-input-sender.ts create mode 100644 mobile/src/terminal/terminal-live-pending-flush-state.test.ts create mode 100644 mobile/src/terminal/terminal-live-pending-flush-state.ts create mode 100644 mobile/src/terminal/terminal-live-text-commit.test.ts create mode 100644 mobile/src/terminal/terminal-live-text-commit.ts create mode 100644 mobile/src/terminal/terminal-send-rpc-response.test.ts create mode 100644 mobile/src/terminal/terminal-send-rpc-response.ts create mode 100644 mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts create mode 100644 mobile/src/terminal/use-terminal-live-accessory-input-commit.ts create mode 100644 mobile/src/terminal/use-terminal-live-input-commit.test.ts create mode 100644 mobile/src/terminal/use-terminal-live-input-commit.ts create mode 100644 mobile/src/terminal/use-terminal-live-pending-input-flush.ts diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 1912f7a04..316664572 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -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 -// 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 | null = null - let rendered: Awaited< - ReturnType['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>(new Map()) const liveInputRef = useRef(null) const liveInputFocusTimerRef = useRef | null>(null) + const sendLiveTerminalInputRef = useRef(async () => false) const sessionTabActionSheetKeyboardHideSubRef = useRef | 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) | null>(null) + const initialEmptySessionAutoCreateRef = useRef(null) const markdownSaveSeqRef = useRef>(new Map()) const markdownSaveInFlightRef = useRef>(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 => { 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 => { + 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) diff --git a/mobile/package.json b/mobile/package.json index 734519e97..9d7550276 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -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", diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 7785a4d05..aecf79fe0 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -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: diff --git a/mobile/src/session/mobile-image-attachment.test.ts b/mobile/src/session/mobile-image-attachment.test.ts index 3a94348aa..1bf3df59e 100644 --- a/mobile/src/session/mobile-image-attachment.test.ts +++ b/mobile/src/session/mobile-image-attachment.test.ts @@ -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) + }) }) diff --git a/mobile/src/session/mobile-image-attachment.ts b/mobile/src/session/mobile-image-attachment.ts index 507b58eb6..b7d14d9a0 100644 --- a/mobile/src/session/mobile-image-attachment.ts +++ b/mobile/src/session/mobile-image-attachment.ts @@ -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 } // 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 { 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, diff --git a/mobile/src/session/use-mobile-image-attachment.ts b/mobile/src/session/use-mobile-image-attachment.ts index 3894928f4..417dd8d69 100644 --- a/mobile/src/session/use-mobile-image-attachment.ts +++ b/mobile/src/session/use-mobile-image-attachment.ts @@ -24,6 +24,7 @@ type UseMobileImageAttachmentArgs = { readonly showToast: ShowToast readonly onSuccess: () => void readonly onError: () => void + readonly beforeTerminalSend?: (terminal: string) => Promise } 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, diff --git a/mobile/src/session/use-mobile-terminal-paste.ts b/mobile/src/session/use-mobile-terminal-paste.ts new file mode 100644 index 000000000..e3148be67 --- /dev/null +++ b/mobile/src/session/use-mobile-terminal-paste.ts @@ -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 | null = null + let rendered: Awaited< + ReturnType['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 + readonly activeSessionTabTypeRef: RefObject + readonly canSend: boolean + readonly client: RpcClient | null + readonly clientRef: RefObject + readonly connState: ConnectionState + readonly connStateRef: RefObject + readonly deviceTokenRef: RefObject + readonly flushPendingLiveInputBeforeExternalSend: (handle: string) => Promise + readonly getActiveWorktreeConnectionId: () => Promise + readonly onError: () => void + readonly onSuccess: () => void + readonly ptyModesRef: RefObject> + 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 { + 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 + ]) +} diff --git a/mobile/src/terminal/terminal-live-accessory-input.ts b/mobile/src/terminal/terminal-live-accessory-input.ts new file mode 100644 index 000000000..5f483ebc0 --- /dev/null +++ b/mobile/src/terminal/terminal-live-accessory-input.ts @@ -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 } +} diff --git a/mobile/src/terminal/terminal-live-accessory-raw-send-target.test.ts b/mobile/src/terminal/terminal-live-accessory-raw-send-target.test.ts new file mode 100644 index 000000000..5e484c354 --- /dev/null +++ b/mobile/src/terminal/terminal-live-accessory-raw-send-target.test.ts @@ -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() + }) +}) diff --git a/mobile/src/terminal/terminal-live-accessory-raw-send-target.ts b/mobile/src/terminal/terminal-live-accessory-raw-send-target.ts new file mode 100644 index 000000000..f87138d21 --- /dev/null +++ b/mobile/src/terminal/terminal-live-accessory-raw-send-target.ts @@ -0,0 +1,17 @@ +type TerminalLiveAccessoryRawSendTargetInput = { + readonly targetHandle: string + readonly activeHandle: string | null + readonly activeSessionTabType: TTabType | null +} + +export function getTerminalLiveAccessoryRawSendTarget({ + targetHandle, + activeHandle, + activeSessionTabType +}: TerminalLiveAccessoryRawSendTargetInput): string | null { + if (targetHandle !== activeHandle || activeSessionTabType !== 'terminal') { + return null + } + + return targetHandle +} diff --git a/mobile/src/terminal/terminal-live-control-send-order.test.ts b/mobile/src/terminal/terminal-live-control-send-order.test.ts new file mode 100644 index 000000000..8f1797c7c --- /dev/null +++ b/mobile/src/terminal/terminal-live-control-send-order.test.ts @@ -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']) + }) +}) diff --git a/mobile/src/terminal/terminal-live-control-send-order.ts b/mobile/src/terminal/terminal-live-control-send-order.ts new file mode 100644 index 000000000..185b49ee4 --- /dev/null +++ b/mobile/src/terminal/terminal-live-control-send-order.ts @@ -0,0 +1,12 @@ +export type TerminalLiveAsyncSendStep = () => Promise + +export async function sendTerminalLiveControlAfterPendingFlush( + flushPendingText: TerminalLiveAsyncSendStep, + sendControlBytes: TerminalLiveAsyncSendStep +): Promise { + const flushed = await flushPendingText() + if (!flushed) { + return false + } + return sendControlBytes() +} diff --git a/mobile/src/terminal/terminal-live-input-sender.ts b/mobile/src/terminal/terminal-live-input-sender.ts new file mode 100644 index 000000000..c3538d1f7 --- /dev/null +++ b/mobile/src/terminal/terminal-live-input-sender.ts @@ -0,0 +1 @@ +export type TerminalLiveInputSender = (handle: string, bytes: string) => Promise diff --git a/mobile/src/terminal/terminal-live-pending-flush-state.test.ts b/mobile/src/terminal/terminal-live-pending-flush-state.test.ts new file mode 100644 index 000000000..27dd4147d --- /dev/null +++ b/mobile/src/terminal/terminal-live-pending-flush-state.test.ts @@ -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((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((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((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((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([]) + }) +}) diff --git a/mobile/src/terminal/terminal-live-pending-flush-state.ts b/mobile/src/terminal/terminal-live-pending-flush-state.ts new file mode 100644 index 000000000..ea760fcee --- /dev/null +++ b/mobile/src/terminal/terminal-live-pending-flush-state.ts @@ -0,0 +1,29 @@ +export type TerminalLivePendingFlushState = { + current: Promise | null +} + +export function waitForTerminalLivePendingFlush( + state: TerminalLivePendingFlushState +): Promise { + return state.current ?? Promise.resolve(true) +} + +export function queueTerminalLivePendingFlush( + state: TerminalLivePendingFlushState, + sendPendingText: () => Promise +): Promise { + 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 +} diff --git a/mobile/src/terminal/terminal-live-text-commit.test.ts b/mobile/src/terminal/terminal-live-text-commit.test.ts new file mode 100644 index 000000000..00435fb22 --- /dev/null +++ b/mobile/src/terminal/terminal-live-text-commit.test.ts @@ -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']) + }) +}) diff --git a/mobile/src/terminal/terminal-live-text-commit.ts b/mobile/src/terminal/terminal-live-text-commit.ts new file mode 100644 index 000000000..aac74b893 --- /dev/null +++ b/mobile/src/terminal/terminal-live-text-commit.ts @@ -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'] +} diff --git a/mobile/src/terminal/terminal-send-rpc-response.test.ts b/mobile/src/terminal/terminal-send-rpc-response.test.ts new file mode 100644 index 000000000..5ef459a6f --- /dev/null +++ b/mobile/src/terminal/terminal-send-rpc-response.test.ts @@ -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) + }) +}) diff --git a/mobile/src/terminal/terminal-send-rpc-response.ts b/mobile/src/terminal/terminal-send-rpc-response.ts new file mode 100644 index 000000000..454c5e329 --- /dev/null +++ b/mobile/src/terminal/terminal-send-rpc-response.ts @@ -0,0 +1,15 @@ +import type { RpcResponse } from '../transport/types' + +function isRecord(value: unknown): value is Record { + 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 +} diff --git a/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts b/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts new file mode 100644 index 000000000..b6bcb3806 --- /dev/null +++ b/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { getTerminalLiveAccessoryInactiveInputCommitResult } from './use-terminal-live-accessory-input-commit' + +type DeferredBoolean = { + readonly promise: Promise + 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((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 => false + + // When + const result = await getTerminalLiveAccessoryInactiveInputCommitResult( + waitForPendingLiveInputFlush + ) + + // Then + expect(result).toEqual({ kind: 'suppress-raw' }) + }) +}) diff --git a/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts b/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts new file mode 100644 index 000000000..b7e8cc8b2 --- /dev/null +++ b/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts @@ -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 +): Promise { + return (await waitForPendingLiveInputFlush()) ? { kind: 'allow-raw' } : { kind: 'suppress-raw' } +} + +type TerminalLiveAccessoryInputCommitOptions = { + readonly activeHandle: string | null + readonly clearPendingLiveInputCommit: () => void + readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise + readonly liveInputRef: RefObject + readonly liveInputTerminalHandles: ReadonlySet + readonly pendingLiveInputHandleRef: RefObject + readonly pendingLiveInputTextRef: RefObject + readonly schedulePendingLiveInputCommit: TerminalLiveInputCommitScheduler + readonly sendLiveTerminalInputRef: RefObject + readonly setLiveInputCapture: (text: string) => void + readonly waitForPendingLiveInputFlush: () => Promise +} + +export function useTerminalLiveAccessoryInputCommit({ + activeHandle, + clearPendingLiveInputCommit, + flushPendingLiveInputText, + liveInputRef, + liveInputTerminalHandles, + pendingLiveInputHandleRef, + pendingLiveInputTextRef, + schedulePendingLiveInputCommit, + sendLiveTerminalInputRef, + setLiveInputCapture, + waitForPendingLiveInputFlush +}: TerminalLiveAccessoryInputCommitOptions): ( + input: TerminalLiveAccessoryInput +) => Promise { + return useCallback( + async (input: TerminalLiveAccessoryInput): Promise => { + 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 + ] + ) +} diff --git a/mobile/src/terminal/use-terminal-live-input-commit.test.ts b/mobile/src/terminal/use-terminal-live-input-commit.test.ts new file mode 100644 index 000000000..005d4b77f --- /dev/null +++ b/mobile/src/terminal/use-terminal-live-input-commit.test.ts @@ -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> + 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 = { current: activeHandle } + const activeSessionTabTypeRef: RefObject = { current: 'terminal' } + const captures: string[] = [] + const liveInputRef: RefObject = { current: null } + const liveInputTerminalHandles = new Set([activeHandle]) + const liveInputTerminalHandlesRef: RefObject> = { + current: new Set([activeHandle]) + } + const sent: string[] = [] + const sendLiveTerminalInputRef: RefObject = { + current: async (_handle, bytes) => { + sent.push(bytes) + return sendResult + } + } + let handlers: ReturnType> | 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([]) + }) +}) diff --git a/mobile/src/terminal/use-terminal-live-input-commit.ts b/mobile/src/terminal/use-terminal-live-input-commit.ts new file mode 100644 index 000000000..2e2dd9e15 --- /dev/null +++ b/mobile/src/terminal/use-terminal-live-input-commit.ts @@ -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 = { + readonly activeHandle: string | null + readonly activeHandleRef: RefObject + readonly activeSessionTabType: TTabType | null | undefined + readonly activeSessionTabTypeRef: RefObject + readonly liveInputRef: RefObject + readonly liveInputTerminalHandles: ReadonlySet + readonly liveInputTerminalHandlesRef: RefObject> + readonly sendLiveTerminalInputRef: RefObject + readonly setLiveInputCapture: (text: string) => void +} + +type TerminalLiveInputCommitHandlers = { + readonly clearPendingLiveInputCommit: () => void + readonly flushPendingLiveInputBeforeExternalSend: (handle: string) => Promise + readonly handleLiveInputAccessoryBytes: ( + input: TerminalLiveAccessoryInput + ) => Promise + readonly handleLiveInputChange: (text: string) => void + readonly handleLiveInputKeyPress: (event: TerminalLiveInputKeyPressEvent) => void + readonly handleLiveInputSubmit: () => void +} + +export function useTerminalLiveInputCommit({ + activeHandle, + activeHandleRef, + activeSessionTabType, + activeSessionTabTypeRef, + liveInputRef, + liveInputTerminalHandles, + liveInputTerminalHandlesRef, + sendLiveTerminalInputRef, + setLiveInputCapture +}: TerminalLiveInputCommitOptions): 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 => { + 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 + } +} diff --git a/mobile/src/terminal/use-terminal-live-pending-input-flush.ts b/mobile/src/terminal/use-terminal-live-pending-input-flush.ts new file mode 100644 index 000000000..2c6998c99 --- /dev/null +++ b/mobile/src/terminal/use-terminal-live-pending-input-flush.ts @@ -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 = { + readonly activeHandleRef: RefObject + readonly activeSessionTabTypeRef: RefObject + readonly liveInputRef: RefObject + readonly liveInputTerminalHandlesRef: RefObject> + readonly sendLiveTerminalInputRef: RefObject + readonly setLiveInputCapture: (text: string) => void +} + +type TerminalLivePendingInputFlush = { + readonly clearPendingLiveInputCommit: () => void + readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise + readonly pendingLiveInputHandleRef: RefObject + readonly pendingLiveInputTextRef: RefObject + readonly schedulePendingLiveInputCommit: ( + handle: string, + text: string, + delayMs: number | null + ) => void + readonly waitForPendingLiveInputFlush: () => Promise +} + +export function useTerminalLivePendingInputFlush({ + activeHandleRef, + activeSessionTabTypeRef, + liveInputRef, + liveInputTerminalHandlesRef, + sendLiveTerminalInputRef, + setLiveInputCapture +}: TerminalLivePendingInputFlushOptions): TerminalLivePendingInputFlush { + const liveInputCommitTimerRef = useRef | null>(null) + const pendingLiveInputFlushRef = useRef | null>(null) + const pendingLiveInputTextRef = useRef('') + const pendingLiveInputHandleRef = useRef(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 => { + return waitForTerminalLivePendingFlush(pendingLiveInputFlushRef) + }, []) + + const flushPendingLiveInputText = useCallback( + async (expectedHandle: string | null): Promise => { + 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 + } +} diff --git a/src/renderer/src/hooks/terminal-command-finished-event.ts b/src/renderer/src/hooks/terminal-command-finished-event.ts index 79bf8a978..5a304b418 100644 --- a/src/renderer/src/hooks/terminal-command-finished-event.ts +++ b/src/renderer/src/hooks/terminal-command-finished-event.ts @@ -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(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, { detail: { worktreeId }