Fix mobile terminal Korean IME composition on Android (#7011)
* Fix Korean IME composition by deferring live terminal preedit The mobile terminal capture field previously sent and cleared every TextInput change, which can break Hangul composition on Android keyboards. Introduce a small commit model and extracted live-input hook so composed text is flushed deliberately while ASCII remains immediate. Constraint: React Native TextInput has no portable composition event for this path; the fix uses a bounded commit delay for likely IME text. Rejected: Native-module IME integration | unnecessary for the confirmed JS dispatch/clear failure and higher maintenance risk. Confidence: high Scope-risk: moderate Directive: Keep terminal.send payload shape and buffered command input unchanged; do not claim physical Samsung Keyboard QA without device evidence. Tested: cd mobile && pnpm exec vitest run src/terminal/terminal-live-text-commit.test.ts src/terminal/terminal-live-input.test.ts src/terminal/terminal-text-input-normalization.test.ts src/terminal/terminal-keyboard-type.test.ts --reporter=verbose Tested: cd mobile && pnpm exec tsc --noEmit Tested: cd mobile && pnpm exec oxlint src/terminal/terminal-live-text-commit.ts src/terminal/terminal-live-text-commit.test.ts src/terminal/use-terminal-live-input-commit.ts app/h/[hostId]/session/[worktreeId].tsx Not-tested: Physical Galaxy Fold7/Samsung Keyboard and Android emulator/Gboard QA were unavailable; device probes recorded no attached Android device. * Preserve pending Korean IME text before mobile accessory controls Accessory keys share the same pending live-input commit gate as TextInput keypress and submit paths, so control bytes cannot race ahead of composed Hangul. Constraint: React Native mobile input does not expose portable composition events for Samsung/Gboard IME paths. Rejected: Let accessory buttons keep sending directly | Direct sends can drop pending Hangul before Tab/Esc/Enter/Backspace reaches the PTY. Confidence: high Scope-risk: narrow Directive: Keep all terminal control-byte paths behind the pending live-input flush/local-edit decision before sending to the PTY. Tested: pnpm --dir mobile test; pnpm --dir mobile lint; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile exec oxfmt --check src/terminal/terminal-live-text-commit.ts src/terminal/terminal-live-text-commit.test.ts src/terminal/use-terminal-live-input-commit.ts src/terminal/use-terminal-live-accessory-input-commit.ts app/h/[hostId]/session/[worktreeId].tsx; git diff --cached --check Not-tested: Physical Galaxy Fold7 Samsung keyboard manual QA is still external-device only. * Prevent stale IME timer flushes after mobile terminal teardown Pending live-input timers now clear on hook unmount, and accessory Delete documents why it stays local without trimming pending IME text. Constraint: React Native TextInput lacks a portable composition lifecycle, so pending IME text is guarded by a bounded timer that must not survive screen teardown. Rejected: Use clearPendingLiveInputCommit during unmount | it would also touch React state/native props during teardown when only timer/ref cleanup is required. Confidence: high Scope-risk: narrow Directive: Any delayed terminal input commit must have an owner-lifecycle cleanup path before sending to the PTY. Tested: pnpm --dir mobile test; pnpm --dir mobile lint; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile exec vitest run src/terminal/terminal-live-text-commit.test.ts --reporter=verbose; pnpm --dir mobile exec oxfmt --check src/terminal/terminal-live-text-commit.ts src/terminal/use-terminal-live-input-commit.ts; git diff --check Not-tested: Physical Galaxy Fold7 Samsung keyboard manual QA remains unavailable in this environment. * Use semantic accessory edits for mobile IME commits Accessory Backspace/Delete now carry semantic local-edit intent from built-in keys instead of inferring intent from raw bytes, and submit handling is reconnected to the pure submit-sequence model. Constraint: Custom terminal accessory keys may produce the same bytes as built-ins but should still flush pending IME text before sending rather than being silently treated as hidden-input edits. Rejected: Classify local accessory edits by raw bytes | That couples future custom controls to current built-in byte encodings. Confidence: high Scope-risk: narrow Directive: Keep semantic input intent separate from terminal byte payloads when pending IME text is present. Tested: pnpm --dir mobile test; pnpm --dir mobile lint; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile exec oxfmt --check src/terminal/terminal-live-text-commit.ts src/terminal/terminal-live-text-commit.test.ts src/terminal/use-terminal-live-input-commit.ts src/terminal/use-terminal-live-accessory-input-commit.ts app/h/[hostId]/session/[worktreeId].tsx; git diff --check Not-tested: Physical Galaxy Fold7 Samsung keyboard manual QA remains unavailable in this environment. * Respect IME flush failures before control input Propagate terminal.send success from pending Korean IME text before sending Enter, Tab, or accessory bytes, while keeping custom no-pending accessory bytes on the original direct path. Constraint: PR #7011 review required follow-up control bytes only after the pending composed text send actually succeeds. Rejected: Treating send invocation as success | It can still reject or no-op when RPC state changed. Confidence: high Scope-risk: narrow Directive: Keep pending IME flush paths async-success-aware before adding new terminal control inputs. Tested: pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; pnpm --dir mobile exec oxfmt --check changed files; targeted no-excuse clean for mobile/src/terminal changed files. Not-tested: Physical Galaxy Fold7 Samsung keyboard; full session file no-excuse audit still reports pre-existing unrelated violations. * Serialize mobile IME flushes before live controls Treat terminal.send as successful only when the RPC response is ok and the runtime send result is accepted, then route all live-input control sends through a shared in-flight pending-flush barrier. Constraint: PR #7011 review found that resolved RPC promises and per-call sequencing were not enough to prove pending Hangul text reached the PTY before follow-up controls. Rejected: Only awaiting each flush-then-send call | Repeatable accessory keys and no-pending sends can arrive while the first flush is still in flight. Confidence: high Scope-risk: moderate Directive: Keep future mobile terminal control paths behind the pending-flush barrier whenever IME text may be in flight. Tested: pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; pnpm --dir mobile exec oxfmt --check changed files; no-excuse clean for terminal changed files. Not-tested: Physical Galaxy Fold7 Samsung keyboard; full session file no-excuse audit still reports pre-existing unrelated violations. * Queue current IME snapshots behind active flushes Drain the pending snapshot captured by a control action after any already-active terminal send, and make accessory commit handling explicit so raw fallback is not encoded as an inverted boolean. Constraint: Architecture review found the previous single-slot barrier could wait for an older flush while skipping newly pending Hangul text. Rejected: Reusing the prior in-flight promise as the current flush result | It proves only an older snapshot, not the current pending buffer. Confidence: high Scope-risk: narrow Directive: New mobile terminal control paths must distinguish allow-raw, handled, and suppress-raw outcomes explicitly. Tested: pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; pnpm --dir mobile exec oxfmt --check changed files; no-excuse clean for terminal changed files. Not-tested: Physical Galaxy Fold7 Samsung keyboard; full session file no-excuse audit still reports pre-existing unrelated violations. * Preserve accessory raw-send terminal targets Capture the terminal handle at accessory keypress time and suppress raw fallback if the active live terminal changes while waiting for pending IME flushes. Constraint: Independent review found raw accessory bytes could retarget to a different terminal after an async IME flush barrier. Rejected: Re-reading activeHandleRef as the send target after await | It can point at a different terminal than the keypress belonged to. Confidence: high Scope-risk: narrow Directive: Raw accessory fallback must use the keypress-time target and revalidate it after any await. Tested: pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; pnpm --dir mobile exec oxfmt --check changed files; no-excuse clean for terminal changed files. Not-tested: Physical Galaxy Fold7 Samsung keyboard; full session file no-excuse audit still reports pre-existing unrelated violations. * Document accessory flush barrier intent Make the non-obvious raw accessory wait/suppress behavior explicit so future changes preserve IME-before-control ordering. Constraint: CodeRabbit requested a why-comment for the send-now accessory branch. Rejected: Leaving the barrier semantics implicit | The branch can otherwise look like unnecessary async defensive code. Confidence: high Scope-risk: narrow Directive: Keep comments focused on why raw accessory bytes wait behind IME flushes. Tested: targeted terminal vitest suite; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; oxfmt check for changed file. Not-tested: Physical Galaxy Fold7 Samsung keyboard. * Preserve buffered accessory raw sends Keep the stale-handle guard focused on the captured active terminal instead of live-input opt-in state, so buffered mode keeps existing accessory key behavior while async live-input waits still cannot retarget to another terminal. Constraint: Buffered command input behavior must remain unchanged while fixing mobile Korean IME live input ordering. Rejected: Requiring live-input enabled handles for raw accessory fallback | suppresses valid buffered-mode accessory sends. Confidence: high Scope-risk: narrow Directive: Do not use live-input opt-in state as terminal liveness for raw accessory sends; validate captured target, active terminal tab, connection, and client instead. Tested: pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; oxfmt --check changed mobile terminal/session files; TypeScript no-excuse checker for changed terminal files. Not-tested: Physical Galaxy Fold7 Samsung Keyboard manual QA and GitHub Actions jobs, blocked by unavailable device and upstream fork workflow approval. * Keep Hangul IME text pending until explicit flush Avoid timer-driven PTY writes for Hangul candidates so paused Korean composition cannot leak intermediate jamo, while preserving the bounded settle timer for non-Hangul IME text. Also keep disabled live-input accessory fallback behind any existing pending flush barrier. Constraint: React Native TextInput does not expose a portable composition lifecycle on this mobile surface. Rejected: Fixed 150ms auto-flush for Hangul | can emit ㅎ or 하 if the user pauses mid-composition. Confidence: high Scope-risk: narrow Directive: Treat Hangul candidates as pending until submit/control/accessory flush; do not reintroduce idle timer commits for Hangul without device-level composition evidence. Tested: pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; oxfmt --check changed mobile terminal/session files; TypeScript no-excuse checker for changed terminal files. Not-tested: Physical Galaxy Fold7 Samsung Keyboard manual QA and GitHub Actions jobs, blocked by unavailable device and upstream fork workflow approval. * Gate dictation toast on accepted live send Honor the async live-input sender contract so the mobile UI reports dictation insertion only after terminal.send is accepted. Constraint: sendLiveTerminalInput now returns false for stale, disconnected, oversized, or rejected terminal sends. Rejected: Toasting immediately after dispatch | reports success for sends that never reached the PTY. Confidence: high Scope-risk: narrow Directive: Treat live-input UI success as terminal.send acceptance, not request dispatch. Tested: pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; oxfmt --check app/h/[hostId]/session/[worktreeId].tsx. Not-tested: Physical Galaxy Fold7 Samsung Keyboard manual QA and GitHub Actions jobs, blocked by unavailable device and upstream fork workflow approval. * Keep accessory edits on Hangul pending path Make accessory local edits reuse the Hangul-aware defer policy so built-in Backspace/Delete cannot reintroduce timer-driven Hangul PTY writes. Constraint: Hangul IME candidates must remain pending until explicit submit/control/accessory flush. Rejected: Reusing the non-Hangul 150ms settle timer for accessory local edits | can leak pending Hangul after Backspace/Delete. Confidence: high Scope-risk: narrow Directive: Any future pending-text reschedule must use getTerminalLiveDeferredTextDelayMs instead of a hardcoded timer. Tested: pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; oxfmt --check changed mobile terminal/session files; TypeScript no-excuse checker for changed terminal files. Not-tested: Physical Galaxy Fold7 Samsung Keyboard manual QA and GitHub Actions jobs, blocked by unavailable device and upstream fork workflow approval. * Prove Hangul live-input hook ordering Add a direct hook-level regression so Android Korean IME fixes are covered at the orchestration boundary, not only by lower-level helpers. Constraint: React Native mobile TextInput lacks portable composition lifecycle events in this path. Rejected: Relying only on helper tests | misses hook-level pending flush and submit ordering. Confidence: high Scope-risk: narrow Directive: Keep Hangul candidates pending until an explicit terminal action flushes them. Tested: pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; oxfmt --check changed mobile files; no-excuse on terminal modules Not-tested: Physical Galaxy Fold Samsung Keyboard manual QA is not available in this environment. * Keep accessory raw-send tests precise Remove a duplicate raw-target assertion whose title implied disabled live-input behavior that is covered at the accessory commit boundary instead. Constraint: Anti-slop cleanup must preserve existing Hangul/accessory behavior and stay within changed terminal tests. Rejected: Keeping the duplicate disabled-input wording | it tests the same active-terminal predicate as the preceding case. Confidence: high Scope-risk: narrow Directive: Test disabled live-input buffering in the accessory commit layer, not in the raw-target predicate helper. Tested: pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; pnpm --dir mobile exec oxfmt --check changed mobile files; terminal no-excuse checker Not-tested: Physical Galaxy Fold Samsung Keyboard manual QA is not available in this environment. * Explain stale mobile terminal send gates Document why async IME flush paths re-check terminal/client refs before sending raw bytes or reporting live-send success. Constraint: CodeRabbit review requested short why comments for non-obvious stale-send safety gates. Rejected: Leaving the gates undocumented | future edits could remove the stale-target suppression contract. Confidence: high Scope-risk: narrow Directive: Keep async terminal sends guarded by current client, active handle, tab type, and connection state. Tested: pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; pnpm --dir mobile exec oxfmt --check changed mobile files; terminal no-excuse checker Not-tested: Physical Galaxy Fold Samsung Keyboard manual QA is not available in this environment. * Run mobile IME hook tests through effects Move the Hangul live-input hook regression from server rendering to react-test-renderer so effect cleanup and unmount timer cancellation are exercised. Constraint: @testing-library/react-native imports React Native's Flow entry under this Vitest setup, so the narrow effect-running renderer is the compatible test surface. Rejected: Keeping renderToString | it never runs useEffect cleanup and missed the pending timer cleanup path. Rejected: Adding @testing-library/react-native directly | it failed before tests with React Native Flow syntax under the current Vitest transform. Confidence: high Scope-risk: narrow Directive: Hook-level IME tests must use a renderer that runs effects when asserting pending flush cleanup. Tested: vitest targeted terminal tests; pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; oxfmt --check changed mobile files; terminal no-excuse checker Not-tested: Physical Galaxy Fold Samsung Keyboard manual QA is not available in this environment. * Keep hook lifecycle tests quiet Suppress only the react-test-renderer deprecation warning around the effect-running hook harness so real console errors still surface. Constraint: CodeRabbit flagged React 19 renderer warning noise; @testing-library/react-native remains incompatible with the current Vitest/RN Flow transform path. Rejected: Global console silencing | it would hide unrelated test failures. Confidence: high Scope-risk: narrow Directive: Keep the renderer warning suppression scoped to this hook harness and pass all other console errors through. Tested: vitest targeted terminal tests; pnpm --dir mobile test; pnpm --dir mobile exec tsc --noEmit; pnpm --dir mobile lint; oxfmt --check changed mobile files; terminal no-excuse checker Not-tested: Physical Galaxy Fold Samsung Keyboard manual QA is not available in this environment. * fix: flush pending mobile IME input before external sends * fix: guard terminal command finished event dispatch --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
This commit is contained in:
parent
e59c04122d
commit
4f0a141951
|
|
@ -1,8 +1,6 @@
|
|||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import { Animated, AppState, Linking, type AppStateStatus } from 'react-native'
|
||||
import * as Clipboard from 'expo-clipboard'
|
||||
import { ImageManipulator, SaveFormat } from 'expo-image-manipulator'
|
||||
import { File as FsFile, Paths } from 'expo-file-system'
|
||||
import {
|
||||
BackHandler,
|
||||
FlatList,
|
||||
|
|
@ -95,14 +93,18 @@ import {
|
|||
getVisibleTerminalAccessoryKeys,
|
||||
loadTerminalAccessoryLayout
|
||||
} from '../../../../src/terminal/terminal-accessory-layout'
|
||||
import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/terminal-live-accessory-input'
|
||||
import { getTerminalLiveAccessoryRawSendTarget } from '../../../../src/terminal/terminal-live-accessory-raw-send-target'
|
||||
import {
|
||||
clearTerminalLiveInputFocusTimer,
|
||||
defaultTerminalLiveInputHandles,
|
||||
getTerminalLiveSpecialKeyBytes,
|
||||
isTerminalLiveInputWithinByteLimit,
|
||||
pruneTerminalLiveInputHandles,
|
||||
scheduleTerminalLiveInputFocus
|
||||
} from '../../../../src/terminal/terminal-live-input'
|
||||
import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-live-input-sender'
|
||||
import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-send-rpc-response'
|
||||
import { useTerminalLiveInputCommit } from '../../../../src/terminal/use-terminal-live-input-commit'
|
||||
import {
|
||||
getTerminalCommandKeyboardType,
|
||||
getTerminalLiveInputKeyboardType
|
||||
|
|
@ -162,13 +164,8 @@ import {
|
|||
type MobileNewTabAgentOption,
|
||||
type MobileNewTabAgentSettings
|
||||
} from '../../../../src/session/mobile-new-tab-agent-options'
|
||||
import {
|
||||
buildMobileImagePastePayload,
|
||||
prepareMobileClipboardImageBase64,
|
||||
saveMobileClipboardImageAsTempFile,
|
||||
type MobileClipboardImageResizer
|
||||
} from '../../../../src/session/mobile-clipboard-image'
|
||||
import { useMobileImageAttachment } from '../../../../src/session/use-mobile-image-attachment'
|
||||
import { useMobileTerminalPaste } from '../../../../src/session/use-mobile-terminal-paste'
|
||||
import { MobileTerminalLiveInputStatus } from '../../../../src/session/MobileTerminalLiveInputStatus'
|
||||
import { MobileTerminalInputActions } from '../../../../src/session/MobileTerminalInputActions'
|
||||
import { classifyMobileArtifact } from '../../../../src/session/mobile-artifact-kind'
|
||||
|
|
@ -233,52 +230,9 @@ import type {
|
|||
TerminalGestureInputQueue
|
||||
} from './mobile-session-route-types'
|
||||
|
||||
const CLIPBOARD_IMAGE_DATA_URL_PREFIX_RE = /^data:image\/[a-z0-9.+-]+;base64,/i
|
||||
const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450
|
||||
type TerminalLiveAccessoryInput = ReturnType<typeof createTerminalLiveAccessoryInput>
|
||||
|
||||
// Why: clipboard images are re-encoded as lossless PNG, so high-res screenshots and
|
||||
// photos can exceed the upload byte budget; resize the raster down to fit before upload.
|
||||
// The image is staged to a temp file first because the iOS ImageManipulator loader
|
||||
// (Data(contentsOf:)) cannot decode large base64 data URIs — it needs a file:// URI.
|
||||
const resizeMobileClipboardImage: MobileClipboardImageResizer = async (source, target) => {
|
||||
const base64 = source.replace(CLIPBOARD_IMAGE_DATA_URL_PREFIX_RE, '')
|
||||
const file = new FsFile(Paths.cache, `orca-clip-resize-${Date.now()}.png`)
|
||||
let context: ReturnType<typeof ImageManipulator.manipulate> | null = null
|
||||
let rendered: Awaited<
|
||||
ReturnType<ReturnType<typeof ImageManipulator.manipulate>['renderAsync']>
|
||||
> | null = null
|
||||
let resultUri: string | null = null
|
||||
try {
|
||||
file.create({ overwrite: true })
|
||||
file.write(base64, { encoding: 'base64' })
|
||||
context = ImageManipulator.manipulate(file.uri)
|
||||
context.resize({ width: target.width, height: target.height })
|
||||
rendered = await context.renderAsync()
|
||||
const result = await rendered.saveAsync({ format: SaveFormat.PNG, base64: true })
|
||||
resultUri = result.uri
|
||||
// Why: empty base64 would pass the downstream base64 check and upload a corrupt
|
||||
// image, so fail loudly here instead of silently sending an invalid payload.
|
||||
if (!result.base64) {
|
||||
throw new Error('Failed to encode resized clipboard image')
|
||||
}
|
||||
return { data: result.base64, width: result.width, height: result.height }
|
||||
} finally {
|
||||
rendered?.release()
|
||||
context?.release()
|
||||
if (resultUri) {
|
||||
try {
|
||||
new FsFile(resultUri).delete()
|
||||
} catch {
|
||||
// Best-effort cleanup; ImageManipulator saves into cache for every retry.
|
||||
}
|
||||
}
|
||||
try {
|
||||
file.delete()
|
||||
} catch {
|
||||
// Best-effort cleanup; the OS reclaims the cache directory regardless.
|
||||
}
|
||||
}
|
||||
}
|
||||
const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450
|
||||
|
||||
function getActiveTabIdForHandle(
|
||||
tabs: MobileSessionTab[],
|
||||
|
|
@ -1034,6 +988,7 @@ export default function SessionScreen() {
|
|||
const terminalRefs = useRef<Map<string, TerminalWebViewHandle>>(new Map())
|
||||
const liveInputRef = useRef<TextInput>(null)
|
||||
const liveInputFocusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const sendLiveTerminalInputRef = useRef<TerminalLiveInputSender>(async () => false)
|
||||
const sessionTabActionSheetKeyboardHideSubRef = useRef<ReturnType<
|
||||
typeof Keyboard.addListener
|
||||
> | null>(null)
|
||||
|
|
@ -1068,6 +1023,7 @@ export default function SessionScreen() {
|
|||
// render where client was still null/connecting, silently no-opping the
|
||||
// in-app-browser open). Route through a ref kept current every render.
|
||||
const handleCreateBrowserRef = useRef<((rawUrl?: string) => Promise<boolean>) | null>(null)
|
||||
|
||||
const initialEmptySessionAutoCreateRef = useRef<string | null>(null)
|
||||
const markdownSaveSeqRef = useRef<Map<string, number>>(new Map())
|
||||
const markdownSaveInFlightRef = useRef<Set<string>>(new Set())
|
||||
|
|
@ -1094,6 +1050,24 @@ export default function SessionScreen() {
|
|||
const [terminalFrameWidth, setTerminalFrameWidth] = useState(0)
|
||||
|
||||
const activeSessionTab = sessionTabs.find((tab) => tab.id === activeSessionTabId) ?? null
|
||||
const {
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputBeforeExternalSend,
|
||||
handleLiveInputAccessoryBytes,
|
||||
handleLiveInputChange,
|
||||
handleLiveInputKeyPress,
|
||||
handleLiveInputSubmit
|
||||
} = useTerminalLiveInputCommit({
|
||||
activeHandle,
|
||||
activeHandleRef,
|
||||
activeSessionTabType: activeSessionTab?.type,
|
||||
activeSessionTabTypeRef,
|
||||
liveInputRef,
|
||||
liveInputTerminalHandles,
|
||||
liveInputTerminalHandlesRef,
|
||||
sendLiveTerminalInputRef,
|
||||
setLiveInputCapture
|
||||
})
|
||||
const canSend =
|
||||
connState === 'connected' &&
|
||||
activeHandle != null &&
|
||||
|
|
@ -1244,8 +1218,16 @@ export default function SessionScreen() {
|
|||
if (!insertHandle) {
|
||||
return
|
||||
}
|
||||
sendLiveTerminalInput(insertHandle, route.text)
|
||||
showToast('Dictation inserted')
|
||||
void (async () => {
|
||||
const flushedPendingInput = await flushPendingLiveInputBeforeExternalSend(insertHandle)
|
||||
if (!flushedPendingInput) {
|
||||
return
|
||||
}
|
||||
const sent = await sendLiveTerminalInput(insertHandle, route.text)
|
||||
if (sent) {
|
||||
showToast('Dictation inserted')
|
||||
}
|
||||
})()
|
||||
return
|
||||
}
|
||||
setInput((current) => appendBufferedDictation(current, route.text))
|
||||
|
|
@ -2680,7 +2662,7 @@ export default function SessionScreen() {
|
|||
terminalsRef.current = []
|
||||
setSessionTabs([])
|
||||
setActiveSessionTabId(null)
|
||||
setLiveInputCapture('')
|
||||
clearPendingLiveInputCommit()
|
||||
liveInputTerminalHandlesRef.current = new Set()
|
||||
defaultedLiveInputTerminalHandlesRef.current = new Set()
|
||||
setLiveInputTerminalHandles(new Set())
|
||||
|
|
@ -2690,9 +2672,16 @@ export default function SessionScreen() {
|
|||
return () => {
|
||||
sessionTabActionSheetRequestSeqRef.current += 1
|
||||
sessionTabActionSheetKeyboardHideSubRef.current?.remove()
|
||||
clearPendingLiveInputCommit()
|
||||
clearDelayedActionTimers()
|
||||
}
|
||||
}, [clearDelayedActionTimers, clearTerminalCache, hostId, worktreeId])
|
||||
}, [
|
||||
clearDelayedActionTimers,
|
||||
clearPendingLiveInputCommit,
|
||||
clearTerminalCache,
|
||||
hostId,
|
||||
worktreeId
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (connState !== 'connected') {
|
||||
|
|
@ -3097,46 +3086,62 @@ export default function SessionScreen() {
|
|||
}
|
||||
}
|
||||
|
||||
async function handleAccessoryKey(bytes: string) {
|
||||
async function handleAccessoryKey(input: TerminalLiveAccessoryInput) {
|
||||
if (!client || !activeHandle || !canSend) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await client.sendRequest('terminal.send', {
|
||||
terminal: activeHandle,
|
||||
text: bytes,
|
||||
const targetHandle = activeHandle
|
||||
const accessoryCommit = await handleLiveInputAccessoryBytes(input)
|
||||
if (accessoryCommit.kind !== 'allow-raw') {
|
||||
return
|
||||
}
|
||||
const currentClient = clientRef.current
|
||||
// Why: async IME flushing can outlive the original terminal selection.
|
||||
const rawSendTarget = getTerminalLiveAccessoryRawSendTarget({
|
||||
targetHandle,
|
||||
activeHandle: activeHandleRef.current,
|
||||
activeSessionTabType: activeSessionTabTypeRef.current
|
||||
})
|
||||
if (!currentClient || !rawSendTarget || connStateRef.current !== 'connected') {
|
||||
return
|
||||
}
|
||||
await currentClient
|
||||
.sendRequest('terminal.send', {
|
||||
terminal: rawSendTarget,
|
||||
text: input.bytes,
|
||||
enter: false,
|
||||
...(deviceTokenRef.current
|
||||
? { client: { id: deviceTokenRef.current, type: 'mobile' as const } }
|
||||
: {})
|
||||
})
|
||||
} catch {
|
||||
// Transient failure
|
||||
}
|
||||
.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
}
|
||||
|
||||
const sendLiveTerminalInput = useCallback(
|
||||
(handle: string, bytes: string) => {
|
||||
async (handle: string, bytes: string): Promise<boolean> => {
|
||||
const text = normalizeTerminalTextInput(bytes)
|
||||
if (text.length === 0) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
if (!isTerminalLiveInputWithinByteLimit(text)) {
|
||||
triggerError()
|
||||
showToast('Input too large (max 256 KiB)', 1500)
|
||||
return
|
||||
return false
|
||||
}
|
||||
const rpc = clientRef.current
|
||||
// Why: callers suppress follow-up controls/toasts when this live send is stale.
|
||||
if (
|
||||
!rpc ||
|
||||
connStateRef.current !== 'connected' ||
|
||||
handle !== activeHandleRef.current ||
|
||||
activeSessionTabTypeRef.current !== 'terminal'
|
||||
) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
void rpc
|
||||
return rpc
|
||||
.sendRequest('terminal.send', {
|
||||
terminal: handle,
|
||||
text,
|
||||
|
|
@ -3145,12 +3150,11 @@ export default function SessionScreen() {
|
|||
? { client: { id: deviceTokenRef.current, type: 'mobile' as const } }
|
||||
: {})
|
||||
})
|
||||
.catch(() => {
|
||||
// Transient failure
|
||||
})
|
||||
.then(isTerminalSendRpcAccepted, () => false)
|
||||
},
|
||||
[showToast]
|
||||
)
|
||||
sendLiveTerminalInputRef.current = sendLiveTerminalInput
|
||||
|
||||
const focusLiveInput = useCallback(() => {
|
||||
if (!canSend || !liveInputEnabled) {
|
||||
|
|
@ -3387,70 +3391,14 @@ export default function SessionScreen() {
|
|||
liveInputTerminalHandlesRef.current = next
|
||||
return next
|
||||
})
|
||||
setLiveInputCapture('')
|
||||
clearPendingLiveInputCommit()
|
||||
if (nextEnabled) {
|
||||
scheduleTerminalLiveInputFocus(liveInputFocusTimerRef, () => liveInputRef.current?.focus())
|
||||
} else {
|
||||
clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef)
|
||||
liveInputRef.current?.blur()
|
||||
}
|
||||
}, [activeHandle, liveInputTerminalHandles])
|
||||
|
||||
const handleLiveInputChange = useCallback(
|
||||
(text: string) => {
|
||||
if (!activeHandle) {
|
||||
setLiveInputCapture('')
|
||||
liveInputRef.current?.setNativeProps({ text: '' })
|
||||
return
|
||||
}
|
||||
if (!liveInputTerminalHandles.has(activeHandle)) {
|
||||
setLiveInputCapture('')
|
||||
liveInputRef.current?.setNativeProps({ text: '' })
|
||||
return
|
||||
}
|
||||
const normalizedText = normalizeTerminalTextInput(text)
|
||||
if (normalizedText.length > 0) {
|
||||
sendLiveTerminalInput(activeHandle, normalizedText)
|
||||
}
|
||||
setLiveInputCapture('')
|
||||
// Why: the field is only a keyboard capture surface. Clearing the
|
||||
// native value prevents subsequent phone-keyboard events from replaying
|
||||
// already-sent characters when React state remains the empty string.
|
||||
liveInputRef.current?.setNativeProps({ text: '' })
|
||||
},
|
||||
[activeHandle, liveInputTerminalHandles, sendLiveTerminalInput]
|
||||
)
|
||||
|
||||
const handleLiveInputKeyPress = useCallback(
|
||||
(event: { nativeEvent: { key: string } }) => {
|
||||
if (!activeHandle) {
|
||||
return
|
||||
}
|
||||
if (!liveInputTerminalHandles.has(activeHandle)) {
|
||||
return
|
||||
}
|
||||
const bytes = getTerminalLiveSpecialKeyBytes(event.nativeEvent.key)
|
||||
if (!bytes) {
|
||||
return
|
||||
}
|
||||
sendLiveTerminalInput(activeHandle, bytes)
|
||||
setLiveInputCapture('')
|
||||
liveInputRef.current?.setNativeProps({ text: '' })
|
||||
},
|
||||
[activeHandle, liveInputTerminalHandles, sendLiveTerminalInput]
|
||||
)
|
||||
|
||||
const handleLiveInputSubmit = useCallback(() => {
|
||||
if (!activeHandle) {
|
||||
return
|
||||
}
|
||||
if (!liveInputTerminalHandles.has(activeHandle)) {
|
||||
return
|
||||
}
|
||||
sendLiveTerminalInput(activeHandle, '\r')
|
||||
setLiveInputCapture('')
|
||||
liveInputRef.current?.setNativeProps({ text: '' })
|
||||
}, [activeHandle, liveInputTerminalHandles, sendLiveTerminalInput])
|
||||
}, [activeHandle, clearPendingLiveInputCommit, liveInputTerminalHandles])
|
||||
|
||||
const allowTerminalGestureInput = useCallback(
|
||||
(handle: string, sequenceCount: number): boolean => {
|
||||
|
|
@ -3650,11 +3598,11 @@ export default function SessionScreen() {
|
|||
}
|
||||
}, [])
|
||||
const startAccessoryRepeat = useCallback(
|
||||
(bytes: string) => {
|
||||
(input: TerminalLiveAccessoryInput) => {
|
||||
stopAccessoryRepeat()
|
||||
repeatTimeoutRef.current = setTimeout(() => {
|
||||
repeatIntervalRef.current = setInterval(() => {
|
||||
void handleAccessoryKeyRef.current(bytes)
|
||||
void handleAccessoryKeyRef.current(input)
|
||||
}, 45)
|
||||
}, 400)
|
||||
},
|
||||
|
|
@ -3673,11 +3621,13 @@ export default function SessionScreen() {
|
|||
clearToastHideTimer()
|
||||
clearDelayedActionTimers()
|
||||
clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef)
|
||||
clearPendingLiveInputCommit()
|
||||
sessionTabActionSheetRequestSeqRef.current += 1
|
||||
clearSessionTabActionSheetKeyboardListener()
|
||||
stopAccessoryRepeat()
|
||||
},
|
||||
[
|
||||
clearPendingLiveInputCommit,
|
||||
clearDelayedActionTimers,
|
||||
clearSessionTabActionSheetKeyboardListener,
|
||||
clearTerminalCache,
|
||||
|
|
@ -3801,84 +3751,38 @@ export default function SessionScreen() {
|
|||
})
|
||||
}, [])
|
||||
|
||||
const handlePaste = useCallback(async () => {
|
||||
if (!client || !activeHandle || !canSend) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const text = await Clipboard.getStringAsync()
|
||||
let payload: string | null = null
|
||||
if (text.length > 0) {
|
||||
const modes = ptyModesRef.current.get(activeHandle) || {
|
||||
bracketedPasteMode: false,
|
||||
altScreen: false,
|
||||
mouseTrackingMode: 'none',
|
||||
sgrMouseMode: false,
|
||||
sgrMousePixelsMode: false
|
||||
}
|
||||
const wrap = modes.bracketedPasteMode && !modes.altScreen
|
||||
// Why: strip embedded bracketed-paste markers from clipboard text so a
|
||||
// malicious copy containing `\x1b[201~` can't terminate paste mode early
|
||||
// and have the trailing bytes interpreted as shell commands. Matches
|
||||
// xterm.js / iTerm2 behavior.
|
||||
// eslint-disable-next-line no-control-regex -- intentional bracketed-paste marker stripping
|
||||
const sanitized = wrap ? text.replace(/\x1b\[20[01]~/g, '') : text
|
||||
payload = wrap ? `\x1b[200~${sanitized}\x1b[201~` : sanitized
|
||||
} else {
|
||||
const image = await Clipboard.getImageAsync({ format: 'png' })
|
||||
if (!image) {
|
||||
refreshCanPaste()
|
||||
return
|
||||
}
|
||||
const connectionId = await getActiveWorktreeConnectionId()
|
||||
const base64 = await prepareMobileClipboardImageBase64(image, resizeMobileClipboardImage)
|
||||
const imagePath = await saveMobileClipboardImageAsTempFile(client, base64, {
|
||||
connectionId
|
||||
})
|
||||
payload = buildMobileImagePastePayload(imagePath)
|
||||
}
|
||||
|
||||
const wrappedBytes = new TextEncoder().encode(payload).byteLength
|
||||
if (wrappedBytes > 256 * 1024) {
|
||||
triggerError()
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[mobile-clip] paste oversized', { wrappedBytes })
|
||||
showToast('Paste too large (max 256 KiB)', 1500)
|
||||
return
|
||||
}
|
||||
await client.sendRequest('terminal.send', {
|
||||
terminal: activeHandle,
|
||||
text: payload,
|
||||
enter: false,
|
||||
...(deviceTokenRef.current
|
||||
? { client: { id: deviceTokenRef.current, type: 'mobile' as const } }
|
||||
: {})
|
||||
})
|
||||
triggerSelection()
|
||||
refreshCanPaste()
|
||||
} catch (e) {
|
||||
triggerError()
|
||||
const err = e as { name?: string; message?: string }
|
||||
const isDisconnected = connState !== 'connected'
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[mobile-clip] paste failed', { name: err.name, message: err.message })
|
||||
if (isDisconnected) {
|
||||
showToast('Paste failed (disconnected)', 1500)
|
||||
} else if (err.message === 'Clipboard image is too large') {
|
||||
showToast('Image too large to paste', 1500)
|
||||
} else {
|
||||
showToast('Paste failed', 1500)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
const handlePaste = useMobileTerminalPaste({
|
||||
client,
|
||||
activeHandle,
|
||||
activeHandleRef,
|
||||
activeSessionTabTypeRef,
|
||||
canSend,
|
||||
connState,
|
||||
connStateRef,
|
||||
clientRef,
|
||||
deviceTokenRef,
|
||||
flushPendingLiveInputBeforeExternalSend,
|
||||
getActiveWorktreeConnectionId,
|
||||
onError: triggerError,
|
||||
onSuccess: triggerSelection,
|
||||
ptyModesRef,
|
||||
refreshCanPaste,
|
||||
showToast
|
||||
])
|
||||
})
|
||||
|
||||
const flushPendingLiveInputBeforeAttachmentSend = useCallback(
|
||||
async (targetHandle: string): Promise<boolean> => {
|
||||
const flushedPendingInput = await flushPendingLiveInputBeforeExternalSend(targetHandle)
|
||||
// Why: image picking/upload and IME flushing can outlive the original tab.
|
||||
return (
|
||||
flushedPendingInput &&
|
||||
connStateRef.current === 'connected' &&
|
||||
targetHandle === activeHandleRef.current &&
|
||||
activeSessionTabTypeRef.current === 'terminal'
|
||||
)
|
||||
},
|
||||
[flushPendingLiveInputBeforeExternalSend]
|
||||
)
|
||||
|
||||
const { attachImage, isAttaching } = useMobileImageAttachment({
|
||||
client,
|
||||
|
|
@ -3886,6 +3790,7 @@ export default function SessionScreen() {
|
|||
canSend,
|
||||
connState,
|
||||
deviceTokenRef,
|
||||
beforeTerminalSend: flushPendingLiveInputBeforeAttachmentSend,
|
||||
getActiveWorktreeConnectionId,
|
||||
showToast,
|
||||
onSuccess: triggerSelection,
|
||||
|
|
@ -5046,8 +4951,9 @@ export default function SessionScreen() {
|
|||
if (!key.repeatable) {
|
||||
return
|
||||
}
|
||||
void handleAccessoryKey(key.bytes)
|
||||
startAccessoryRepeat(key.bytes)
|
||||
const input = createTerminalLiveAccessoryInput(key)
|
||||
void handleAccessoryKey(input)
|
||||
startAccessoryRepeat(input)
|
||||
}}
|
||||
onPressOut={() => {
|
||||
if (key.repeatable) {
|
||||
|
|
@ -5058,7 +4964,7 @@ export default function SessionScreen() {
|
|||
if (key.repeatable) {
|
||||
return
|
||||
}
|
||||
void handleAccessoryKey(key.bytes)
|
||||
void handleAccessoryKey(createTerminalLiveAccessoryInput(key))
|
||||
}}
|
||||
accessibilityLabel={key.accessibilityLabel ?? `Send ${key.label}`}
|
||||
>
|
||||
|
|
@ -5082,7 +4988,7 @@ export default function SessionScreen() {
|
|||
!canSend && styles.accessoryKeyDisabled
|
||||
]}
|
||||
disabled={!canSend}
|
||||
onPress={() => void handleAccessoryKey(key.bytes)}
|
||||
onPress={() => void handleAccessoryKey({ bytes: key.bytes })}
|
||||
onLongPress={() => {
|
||||
triggerMediumImpact()
|
||||
setDeleteKeyTarget(key)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export type AttachMobileImageDeps = {
|
|||
// start — lets the UI show a sending spinner only for the transfer, not the
|
||||
// (potentially long) time the picker is open.
|
||||
readonly onUploadStart?: () => void
|
||||
readonly beforeTerminalSend?: (terminal: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
// Uploads a picked image to the host and pastes the resulting file path into the
|
||||
|
|
@ -30,7 +31,8 @@ export async function attachMobileImageToTerminal(
|
|||
deviceToken,
|
||||
getConnectionId,
|
||||
pickImage,
|
||||
onUploadStart
|
||||
onUploadStart,
|
||||
beforeTerminalSend
|
||||
}: AttachMobileImageDeps
|
||||
): Promise<boolean> {
|
||||
const picked = await pickImage(source)
|
||||
|
|
@ -45,6 +47,9 @@ export async function attachMobileImageToTerminal(
|
|||
// Why: a generated image path is terminal image injection, so it's always
|
||||
// bracketed (matching desktop paste) regardless of terminal mode.
|
||||
const payload = buildMobileImagePastePayload(imagePath)
|
||||
if (beforeTerminalSend && !(await beforeTerminalSend(terminal))) {
|
||||
return false
|
||||
}
|
||||
await client.sendRequest('terminal.send', {
|
||||
terminal,
|
||||
text: payload,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ type UseMobileImageAttachmentArgs = {
|
|||
readonly showToast: ShowToast
|
||||
readonly onSuccess: () => void
|
||||
readonly onError: () => void
|
||||
readonly beforeTerminalSend?: (terminal: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
type MobileImageAttachment = {
|
||||
|
|
@ -46,7 +47,8 @@ export function useMobileImageAttachment({
|
|||
getActiveWorktreeConnectionId,
|
||||
showToast,
|
||||
onSuccess,
|
||||
onError
|
||||
onError,
|
||||
beforeTerminalSend
|
||||
}: UseMobileImageAttachmentArgs): MobileImageAttachment {
|
||||
const [isAttaching, setIsAttaching] = useState(false)
|
||||
const attachImage = useCallback(
|
||||
|
|
@ -61,7 +63,8 @@ export function useMobileImageAttachment({
|
|||
deviceToken: deviceTokenRef.current,
|
||||
getConnectionId: getActiveWorktreeConnectionId,
|
||||
pickImage: pickMobileImage,
|
||||
onUploadStart: () => setIsAttaching(true)
|
||||
onUploadStart: () => setIsAttaching(true),
|
||||
beforeTerminalSend
|
||||
})
|
||||
// Cancelled picker: no error, no toast.
|
||||
if (sent) {
|
||||
|
|
@ -88,6 +91,7 @@ export function useMobileImageAttachment({
|
|||
},
|
||||
[
|
||||
activeHandle,
|
||||
beforeTerminalSend,
|
||||
canSend,
|
||||
client,
|
||||
connState,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,200 @@
|
|||
import { useCallback, type RefObject } from 'react'
|
||||
import * as Clipboard from 'expo-clipboard'
|
||||
import { File as FsFile, Paths } from 'expo-file-system'
|
||||
import { ImageManipulator, SaveFormat } from 'expo-image-manipulator'
|
||||
import type { TerminalModes } from '../terminal/TerminalWebView'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
import {
|
||||
buildMobileImagePastePayload,
|
||||
prepareMobileClipboardImageBase64,
|
||||
saveMobileClipboardImageAsTempFile,
|
||||
type MobileClipboardImageResizer
|
||||
} from './mobile-clipboard-image'
|
||||
|
||||
const CLIPBOARD_IMAGE_DATA_URL_PREFIX_RE = /^data:image\/[a-z0-9.+-]+;base64,/i
|
||||
|
||||
// Why: clipboard images are re-encoded as lossless PNG, so high-res screenshots and
|
||||
// photos can exceed the upload byte budget; resize the raster down to fit before upload.
|
||||
// The iOS ImageManipulator loader cannot decode large base64 data URIs, so use a file.
|
||||
const resizeMobileClipboardImage: MobileClipboardImageResizer = async (source, target) => {
|
||||
const base64 = source.replace(CLIPBOARD_IMAGE_DATA_URL_PREFIX_RE, '')
|
||||
const file = new FsFile(Paths.cache, `orca-clip-resize-${Date.now()}.png`)
|
||||
let context: ReturnType<typeof ImageManipulator.manipulate> | null = null
|
||||
let rendered: Awaited<
|
||||
ReturnType<ReturnType<typeof ImageManipulator.manipulate>['renderAsync']>
|
||||
> | null = null
|
||||
let resultUri: string | null = null
|
||||
try {
|
||||
file.create({ overwrite: true })
|
||||
file.write(base64, { encoding: 'base64' })
|
||||
context = ImageManipulator.manipulate(file.uri)
|
||||
context.resize({ width: target.width, height: target.height })
|
||||
rendered = await context.renderAsync()
|
||||
const result = await rendered.saveAsync({ format: SaveFormat.PNG, base64: true })
|
||||
resultUri = result.uri
|
||||
// Why: empty base64 would pass the downstream base64 check and upload a corrupt
|
||||
// image, so fail loudly here instead of silently sending an invalid payload.
|
||||
if (!result.base64) {
|
||||
throw new Error('Failed to encode resized clipboard image')
|
||||
}
|
||||
return { data: result.base64, width: result.width, height: result.height }
|
||||
} finally {
|
||||
rendered?.release()
|
||||
context?.release()
|
||||
if (resultUri) {
|
||||
try {
|
||||
new FsFile(resultUri).delete()
|
||||
} catch {
|
||||
// Best-effort cleanup; ImageManipulator saves into cache for every retry.
|
||||
}
|
||||
}
|
||||
try {
|
||||
file.delete()
|
||||
} catch {
|
||||
// Best-effort cleanup; the OS reclaims the cache directory regardless.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildMobileTerminalClipboardTextPayload(
|
||||
text: string,
|
||||
modes: TerminalModes | undefined
|
||||
): string {
|
||||
const wrap = modes?.bracketedPasteMode === true && !modes.altScreen
|
||||
// Why: strip embedded bracketed-paste markers so copied text cannot terminate
|
||||
// paste mode early and turn trailing bytes into shell commands.
|
||||
// eslint-disable-next-line no-control-regex -- intentional bracketed-paste marker stripping
|
||||
const sanitized = wrap ? text.replace(/\x1b\[20[01]~/g, '') : text
|
||||
return wrap ? `\x1b[200~${sanitized}\x1b[201~` : sanitized
|
||||
}
|
||||
|
||||
type UseMobileTerminalPasteOptions = {
|
||||
readonly activeHandle: string | null
|
||||
readonly activeHandleRef: RefObject<string | null>
|
||||
readonly activeSessionTabTypeRef: RefObject<string | null>
|
||||
readonly canSend: boolean
|
||||
readonly client: RpcClient | null
|
||||
readonly clientRef: RefObject<RpcClient | null>
|
||||
readonly connState: ConnectionState
|
||||
readonly connStateRef: RefObject<ConnectionState>
|
||||
readonly deviceTokenRef: RefObject<string | null>
|
||||
readonly flushPendingLiveInputBeforeExternalSend: (handle: string) => Promise<boolean>
|
||||
readonly getActiveWorktreeConnectionId: () => Promise<string | null>
|
||||
readonly onError: () => void
|
||||
readonly onSuccess: () => void
|
||||
readonly ptyModesRef: RefObject<Map<string, TerminalModes>>
|
||||
readonly refreshCanPaste: () => void
|
||||
readonly showToast: (message: string, durationMs?: number) => void
|
||||
}
|
||||
|
||||
export function useMobileTerminalPaste({
|
||||
activeHandle,
|
||||
activeHandleRef,
|
||||
activeSessionTabTypeRef,
|
||||
canSend,
|
||||
client,
|
||||
clientRef,
|
||||
connState,
|
||||
connStateRef,
|
||||
deviceTokenRef,
|
||||
flushPendingLiveInputBeforeExternalSend,
|
||||
getActiveWorktreeConnectionId,
|
||||
onError,
|
||||
onSuccess,
|
||||
ptyModesRef,
|
||||
refreshCanPaste,
|
||||
showToast
|
||||
}: UseMobileTerminalPasteOptions): () => Promise<void> {
|
||||
return useCallback(async () => {
|
||||
if (!client || !activeHandle || !canSend) {
|
||||
return
|
||||
}
|
||||
const targetHandle = activeHandle
|
||||
try {
|
||||
const text = await Clipboard.getStringAsync()
|
||||
let payload: string | null = null
|
||||
if (text.length > 0) {
|
||||
payload = buildMobileTerminalClipboardTextPayload(
|
||||
text,
|
||||
ptyModesRef.current.get(targetHandle)
|
||||
)
|
||||
} else {
|
||||
const image = await Clipboard.getImageAsync({ format: 'png' })
|
||||
if (!image) {
|
||||
refreshCanPaste()
|
||||
return
|
||||
}
|
||||
const connectionId = await getActiveWorktreeConnectionId()
|
||||
const base64 = await prepareMobileClipboardImageBase64(image, resizeMobileClipboardImage)
|
||||
const imagePath = await saveMobileClipboardImageAsTempFile(client, base64, {
|
||||
connectionId
|
||||
})
|
||||
payload = buildMobileImagePastePayload(imagePath)
|
||||
}
|
||||
|
||||
const wrappedBytes = new TextEncoder().encode(payload).byteLength
|
||||
if (wrappedBytes > 256 * 1024) {
|
||||
onError()
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[mobile-clip] paste oversized', { wrappedBytes })
|
||||
showToast('Paste too large (max 256 KiB)', 1500)
|
||||
return
|
||||
}
|
||||
// Why: paste lives in the accessory row and must not overtake pending IME text.
|
||||
const flushedPendingInput = await flushPendingLiveInputBeforeExternalSend(targetHandle)
|
||||
if (!flushedPendingInput) {
|
||||
return
|
||||
}
|
||||
const currentClient = clientRef.current
|
||||
if (
|
||||
!currentClient ||
|
||||
connStateRef.current !== 'connected' ||
|
||||
targetHandle !== activeHandleRef.current ||
|
||||
activeSessionTabTypeRef.current !== 'terminal'
|
||||
) {
|
||||
return
|
||||
}
|
||||
await currentClient.sendRequest('terminal.send', {
|
||||
terminal: targetHandle,
|
||||
text: payload,
|
||||
enter: false,
|
||||
...(deviceTokenRef.current
|
||||
? { client: { id: deviceTokenRef.current, type: 'mobile' as const } }
|
||||
: {})
|
||||
})
|
||||
onSuccess()
|
||||
refreshCanPaste()
|
||||
} catch (e) {
|
||||
onError()
|
||||
const err = e as { name?: string; message?: string }
|
||||
const isDisconnected = connState !== 'connected'
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[mobile-clip] paste failed', { name: err.name, message: err.message })
|
||||
if (isDisconnected) {
|
||||
showToast('Paste failed (disconnected)', 1500)
|
||||
} else if (err.message === 'Clipboard image is too large') {
|
||||
showToast('Image too large to paste', 1500)
|
||||
} else {
|
||||
showToast('Paste failed', 1500)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
activeHandle,
|
||||
activeHandleRef,
|
||||
activeSessionTabTypeRef,
|
||||
canSend,
|
||||
client,
|
||||
clientRef,
|
||||
connState,
|
||||
connStateRef,
|
||||
deviceTokenRef,
|
||||
flushPendingLiveInputBeforeExternalSend,
|
||||
getActiveWorktreeConnectionId,
|
||||
onError,
|
||||
onSuccess,
|
||||
ptyModesRef,
|
||||
refreshCanPaste,
|
||||
showToast
|
||||
])
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
type TerminalLiveAccessoryRawSendTargetInput<TTabType extends string> = {
|
||||
readonly targetHandle: string
|
||||
readonly activeHandle: string | null
|
||||
readonly activeSessionTabType: TTabType | null
|
||||
}
|
||||
|
||||
export function getTerminalLiveAccessoryRawSendTarget<TTabType extends string>({
|
||||
targetHandle,
|
||||
activeHandle,
|
||||
activeSessionTabType
|
||||
}: TerminalLiveAccessoryRawSendTargetInput<TTabType>): string | null {
|
||||
if (targetHandle !== activeHandle || activeSessionTabType !== 'terminal') {
|
||||
return null
|
||||
}
|
||||
|
||||
return targetHandle
|
||||
}
|
||||
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
export type TerminalLiveAsyncSendStep = () => Promise<boolean>
|
||||
|
||||
export async function sendTerminalLiveControlAfterPendingFlush(
|
||||
flushPendingText: TerminalLiveAsyncSendStep,
|
||||
sendControlBytes: TerminalLiveAsyncSendStep
|
||||
): Promise<boolean> {
|
||||
const flushed = await flushPendingText()
|
||||
if (!flushed) {
|
||||
return false
|
||||
}
|
||||
return sendControlBytes()
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export type TerminalLiveInputSender = (handle: string, bytes: string) => Promise<boolean>
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
|
||||
import {
|
||||
queueTerminalLivePendingFlush,
|
||||
waitForTerminalLivePendingFlush,
|
||||
type TerminalLivePendingFlushState
|
||||
} from './terminal-live-pending-flush-state'
|
||||
|
||||
describe('terminal live pending flush state', () => {
|
||||
it('Given no in-flight flush When waiting for the barrier Then allows control input', async () => {
|
||||
// Given
|
||||
const state: TerminalLivePendingFlushState = { current: null }
|
||||
|
||||
// When / Then
|
||||
await expect(waitForTerminalLivePendingFlush(state)).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('Given an in-flight flush When control input waits Then control is held until flush succeeds', async () => {
|
||||
// Given
|
||||
const events: string[] = []
|
||||
let resolveFlush: (value: boolean) => void = () => {}
|
||||
const flushPromise = new Promise<boolean>((resolve) => {
|
||||
resolveFlush = resolve
|
||||
})
|
||||
const state: TerminalLivePendingFlushState = { current: flushPromise }
|
||||
|
||||
// When
|
||||
const controlSend = sendTerminalLiveControlAfterPendingFlush(
|
||||
() => waitForTerminalLivePendingFlush(state),
|
||||
async () => {
|
||||
events.push('control')
|
||||
return true
|
||||
}
|
||||
)
|
||||
await Promise.resolve()
|
||||
|
||||
// Then
|
||||
expect(events).toEqual([])
|
||||
resolveFlush(true)
|
||||
await expect(controlSend).resolves.toBe(true)
|
||||
expect(events).toEqual(['control'])
|
||||
})
|
||||
|
||||
it('Given an in-flight flush fails When control input waits Then control is skipped', async () => {
|
||||
// Given
|
||||
const events: string[] = []
|
||||
let resolveFlush: (value: boolean) => void = () => {}
|
||||
const flushPromise = new Promise<boolean>((resolve) => {
|
||||
resolveFlush = resolve
|
||||
})
|
||||
const state: TerminalLivePendingFlushState = { current: flushPromise }
|
||||
|
||||
// When
|
||||
const controlSend = sendTerminalLiveControlAfterPendingFlush(
|
||||
() => waitForTerminalLivePendingFlush(state),
|
||||
async () => {
|
||||
events.push('control')
|
||||
return true
|
||||
}
|
||||
)
|
||||
resolveFlush(false)
|
||||
|
||||
// Then
|
||||
await expect(controlSend).resolves.toBe(false)
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
|
||||
it('Given a queued pending flush When it resolves or rejects Then clears the barrier', async () => {
|
||||
// Given
|
||||
const resolvedState: TerminalLivePendingFlushState = { current: null }
|
||||
const rejectedState: TerminalLivePendingFlushState = { current: null }
|
||||
|
||||
// When
|
||||
await expect(queueTerminalLivePendingFlush(resolvedState, async () => true)).resolves.toBe(true)
|
||||
await expect(
|
||||
queueTerminalLivePendingFlush(rejectedState, async () => {
|
||||
throw new Error('send failed')
|
||||
})
|
||||
).resolves.toBe(false)
|
||||
await Promise.resolve()
|
||||
|
||||
// Then
|
||||
expect(resolvedState.current).toBeNull()
|
||||
expect(rejectedState.current).toBeNull()
|
||||
})
|
||||
|
||||
it('Given a current pending snapshot while another flush is in flight When queued Then sends current text after prior success', async () => {
|
||||
// Given
|
||||
const events: string[] = []
|
||||
let resolveFirstFlush: (value: boolean) => void = () => {}
|
||||
const firstFlush = new Promise<boolean>((resolve) => {
|
||||
resolveFirstFlush = resolve
|
||||
})
|
||||
const state: TerminalLivePendingFlushState = { current: firstFlush }
|
||||
|
||||
// When
|
||||
const secondFlush = queueTerminalLivePendingFlush(state, async () => {
|
||||
events.push('second-flush')
|
||||
return true
|
||||
})
|
||||
const controlSend = sendTerminalLiveControlAfterPendingFlush(
|
||||
() => waitForTerminalLivePendingFlush(state),
|
||||
async () => {
|
||||
events.push('control')
|
||||
return true
|
||||
}
|
||||
)
|
||||
await Promise.resolve()
|
||||
|
||||
// Then
|
||||
expect(events).toEqual([])
|
||||
resolveFirstFlush(true)
|
||||
await expect(secondFlush).resolves.toBe(true)
|
||||
await expect(controlSend).resolves.toBe(true)
|
||||
expect(events).toEqual(['second-flush', 'control'])
|
||||
})
|
||||
|
||||
it('Given a prior pending flush fails When another snapshot is queued Then skips the current text and control', async () => {
|
||||
// Given
|
||||
const events: string[] = []
|
||||
let resolveFirstFlush: (value: boolean) => void = () => {}
|
||||
const firstFlush = new Promise<boolean>((resolve) => {
|
||||
resolveFirstFlush = resolve
|
||||
})
|
||||
const state: TerminalLivePendingFlushState = { current: firstFlush }
|
||||
|
||||
// When
|
||||
const secondFlush = queueTerminalLivePendingFlush(state, async () => {
|
||||
events.push('second-flush')
|
||||
return true
|
||||
})
|
||||
const controlSend = sendTerminalLiveControlAfterPendingFlush(
|
||||
() => waitForTerminalLivePendingFlush(state),
|
||||
async () => {
|
||||
events.push('control')
|
||||
return true
|
||||
}
|
||||
)
|
||||
resolveFirstFlush(false)
|
||||
|
||||
// Then
|
||||
await expect(secondFlush).resolves.toBe(false)
|
||||
await expect(controlSend).resolves.toBe(false)
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
export type TerminalLivePendingFlushState = {
|
||||
current: Promise<boolean> | null
|
||||
}
|
||||
|
||||
export function waitForTerminalLivePendingFlush(
|
||||
state: TerminalLivePendingFlushState
|
||||
): Promise<boolean> {
|
||||
return state.current ?? Promise.resolve(true)
|
||||
}
|
||||
|
||||
export function queueTerminalLivePendingFlush(
|
||||
state: TerminalLivePendingFlushState,
|
||||
sendPendingText: () => Promise<boolean>
|
||||
): Promise<boolean> {
|
||||
const previousFlush = state.current
|
||||
const flushPromise = (async () => {
|
||||
if (previousFlush && !(await previousFlush)) {
|
||||
return false
|
||||
}
|
||||
return sendPendingText()
|
||||
})().catch(() => false)
|
||||
state.current = flushPromise
|
||||
void flushPromise.then(() => {
|
||||
if (state.current === flushPromise) {
|
||||
state.current = null
|
||||
}
|
||||
})
|
||||
return flushPromise
|
||||
}
|
||||
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
|
|
@ -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']
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import type { RpcResponse } from '../transport/types'
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
export function isTerminalSendRpcAccepted(response: RpcResponse): boolean {
|
||||
if (!response.ok) {
|
||||
return false
|
||||
}
|
||||
if (!isRecord(response.result) || !isRecord(response.result.send)) {
|
||||
return false
|
||||
}
|
||||
return response.result.send.accepted === true
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { getTerminalLiveAccessoryInactiveInputCommitResult } from './use-terminal-live-accessory-input-commit'
|
||||
|
||||
type DeferredBoolean = {
|
||||
readonly promise: Promise<boolean>
|
||||
readonly resolve: (value: boolean) => void
|
||||
}
|
||||
|
||||
function createDeferredBoolean(): DeferredBoolean {
|
||||
let resolvePromise: (value: boolean) => void = () => {
|
||||
throw new Error('deferred promise was resolved before initialization')
|
||||
}
|
||||
const promise = new Promise<boolean>((resolve) => {
|
||||
resolvePromise = resolve
|
||||
})
|
||||
return { promise, resolve: resolvePromise }
|
||||
}
|
||||
|
||||
describe('terminal live accessory input commit', () => {
|
||||
it('Given live input is disabled with an active flush When accessory raw fallback is requested Then waits before allowing raw send', async () => {
|
||||
// Given
|
||||
const deferredFlush = createDeferredBoolean()
|
||||
let settled = false
|
||||
|
||||
// When
|
||||
const resultPromise = getTerminalLiveAccessoryInactiveInputCommitResult(
|
||||
() => deferredFlush.promise
|
||||
)
|
||||
void resultPromise.then(() => {
|
||||
settled = true
|
||||
})
|
||||
await Promise.resolve()
|
||||
|
||||
// Then
|
||||
expect(settled).toBe(false)
|
||||
deferredFlush.resolve(true)
|
||||
await expect(resultPromise).resolves.toEqual({ kind: 'allow-raw' })
|
||||
})
|
||||
|
||||
it('Given live input is disabled with a failed active flush When accessory raw fallback is requested Then suppresses raw send', async () => {
|
||||
// Given
|
||||
const waitForPendingLiveInputFlush = async (): Promise<boolean> => false
|
||||
|
||||
// When
|
||||
const result = await getTerminalLiveAccessoryInactiveInputCommitResult(
|
||||
waitForPendingLiveInputFlush
|
||||
)
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({ kind: 'suppress-raw' })
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
import { useCallback, type RefObject } from 'react'
|
||||
import type { TextInput } from 'react-native'
|
||||
import {
|
||||
getTerminalLiveAccessoryBytesDecision,
|
||||
getTerminalLiveAccessoryLocalEditText,
|
||||
getTerminalLiveDeferredTextDelayMs
|
||||
} from './terminal-live-text-commit'
|
||||
import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input'
|
||||
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
|
||||
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
|
||||
|
||||
export type TerminalLiveAccessoryInputCommitResult =
|
||||
| { readonly kind: 'allow-raw' }
|
||||
| { readonly kind: 'handled' }
|
||||
| { readonly kind: 'suppress-raw' }
|
||||
|
||||
type TerminalLiveInputCommitScheduler = (
|
||||
handle: string,
|
||||
text: string,
|
||||
delayMs: number | null
|
||||
) => void
|
||||
|
||||
export async function getTerminalLiveAccessoryInactiveInputCommitResult(
|
||||
waitForPendingLiveInputFlush: () => Promise<boolean>
|
||||
): Promise<TerminalLiveAccessoryInputCommitResult> {
|
||||
return (await waitForPendingLiveInputFlush()) ? { kind: 'allow-raw' } : { kind: 'suppress-raw' }
|
||||
}
|
||||
|
||||
type TerminalLiveAccessoryInputCommitOptions = {
|
||||
readonly activeHandle: string | null
|
||||
readonly clearPendingLiveInputCommit: () => void
|
||||
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean>
|
||||
readonly liveInputRef: RefObject<TextInput | null>
|
||||
readonly liveInputTerminalHandles: ReadonlySet<string>
|
||||
readonly pendingLiveInputHandleRef: RefObject<string | null>
|
||||
readonly pendingLiveInputTextRef: RefObject<string>
|
||||
readonly schedulePendingLiveInputCommit: TerminalLiveInputCommitScheduler
|
||||
readonly sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender>
|
||||
readonly setLiveInputCapture: (text: string) => void
|
||||
readonly waitForPendingLiveInputFlush: () => Promise<boolean>
|
||||
}
|
||||
|
||||
export function useTerminalLiveAccessoryInputCommit({
|
||||
activeHandle,
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputText,
|
||||
liveInputRef,
|
||||
liveInputTerminalHandles,
|
||||
pendingLiveInputHandleRef,
|
||||
pendingLiveInputTextRef,
|
||||
schedulePendingLiveInputCommit,
|
||||
sendLiveTerminalInputRef,
|
||||
setLiveInputCapture,
|
||||
waitForPendingLiveInputFlush
|
||||
}: TerminalLiveAccessoryInputCommitOptions): (
|
||||
input: TerminalLiveAccessoryInput
|
||||
) => Promise<TerminalLiveAccessoryInputCommitResult> {
|
||||
return useCallback(
|
||||
async (input: TerminalLiveAccessoryInput): Promise<TerminalLiveAccessoryInputCommitResult> => {
|
||||
if (!activeHandle) {
|
||||
return { kind: 'allow-raw' }
|
||||
}
|
||||
if (!liveInputTerminalHandles.has(activeHandle)) {
|
||||
return getTerminalLiveAccessoryInactiveInputCommitResult(waitForPendingLiveInputFlush)
|
||||
}
|
||||
const pendingText =
|
||||
pendingLiveInputHandleRef.current === activeHandle ? pendingLiveInputTextRef.current : ''
|
||||
if (pendingLiveInputHandleRef.current && pendingLiveInputHandleRef.current !== activeHandle) {
|
||||
clearPendingLiveInputCommit()
|
||||
}
|
||||
const decision = getTerminalLiveAccessoryBytesDecision({ ...input, pendingText })
|
||||
switch (decision.kind) {
|
||||
case 'send-now':
|
||||
// Why: raw accessory bytes must wait behind any in-flight IME text
|
||||
// flush so composed Hangul reaches the PTY before follow-up controls.
|
||||
return (await waitForPendingLiveInputFlush())
|
||||
? { kind: 'allow-raw' }
|
||||
: { kind: 'suppress-raw' }
|
||||
case 'local-edit': {
|
||||
const editedText = getTerminalLiveAccessoryLocalEditText({
|
||||
localEdit: decision.localEdit,
|
||||
pendingText
|
||||
})
|
||||
if (editedText.length === 0) {
|
||||
clearPendingLiveInputCommit()
|
||||
return { kind: 'handled' }
|
||||
}
|
||||
// Why: accessory buttons do not emit native TextInput edits, so the
|
||||
// pending IME buffer must be edited and rescheduled here.
|
||||
setLiveInputCapture(editedText)
|
||||
liveInputRef.current?.setNativeProps({ text: editedText })
|
||||
schedulePendingLiveInputCommit(
|
||||
activeHandle,
|
||||
editedText,
|
||||
getTerminalLiveDeferredTextDelayMs(editedText)
|
||||
)
|
||||
return { kind: 'handled' }
|
||||
}
|
||||
case 'flush-then-send':
|
||||
await sendTerminalLiveControlAfterPendingFlush(
|
||||
() => flushPendingLiveInputText(activeHandle),
|
||||
() => sendLiveTerminalInputRef.current(activeHandle, decision.bytes)
|
||||
)
|
||||
return { kind: 'handled' }
|
||||
default:
|
||||
decision satisfies never
|
||||
return { kind: 'handled' }
|
||||
}
|
||||
},
|
||||
[
|
||||
activeHandle,
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputText,
|
||||
liveInputRef,
|
||||
liveInputTerminalHandles,
|
||||
pendingLiveInputHandleRef,
|
||||
pendingLiveInputTextRef,
|
||||
schedulePendingLiveInputCommit,
|
||||
sendLiveTerminalInputRef,
|
||||
setLiveInputCapture,
|
||||
waitForPendingLiveInputFlush
|
||||
]
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
import { createElement, type RefObject } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import type { TextInput } from 'react-native'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
|
||||
import { TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS } from './terminal-live-text-commit'
|
||||
import { useTerminalLiveInputCommit } from './use-terminal-live-input-commit'
|
||||
|
||||
type TerminalLiveInputCommitHarness = {
|
||||
readonly captures: readonly string[]
|
||||
readonly handlers: ReturnType<typeof useTerminalLiveInputCommit<string>>
|
||||
readonly sent: readonly string[]
|
||||
readonly unmount: () => void
|
||||
}
|
||||
|
||||
type TerminalLiveInputCommitHarnessOptions = {
|
||||
readonly sendResult?: boolean
|
||||
}
|
||||
|
||||
function suppressReactTestRendererDeprecationWarning(): () => void {
|
||||
const originalConsoleError = console.error
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
const firstArg = args[0]
|
||||
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
return () => consoleErrorSpy.mockRestore()
|
||||
}
|
||||
|
||||
function createTerminalLiveInputCommitHarness({
|
||||
sendResult = true
|
||||
}: TerminalLiveInputCommitHarnessOptions = {}): TerminalLiveInputCommitHarness {
|
||||
const activeHandle = 'terminal-a'
|
||||
const activeHandleRef: RefObject<string | null> = { current: activeHandle }
|
||||
const activeSessionTabTypeRef: RefObject<string | null> = { current: 'terminal' }
|
||||
const captures: string[] = []
|
||||
const liveInputRef: RefObject<TextInput | null> = { current: null }
|
||||
const liveInputTerminalHandles = new Set([activeHandle])
|
||||
const liveInputTerminalHandlesRef: RefObject<Set<string>> = {
|
||||
current: new Set([activeHandle])
|
||||
}
|
||||
const sent: string[] = []
|
||||
const sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender> = {
|
||||
current: async (_handle, bytes) => {
|
||||
sent.push(bytes)
|
||||
return sendResult
|
||||
}
|
||||
}
|
||||
let handlers: ReturnType<typeof useTerminalLiveInputCommit<string>> | null = null
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
function Harness(): null {
|
||||
handlers = useTerminalLiveInputCommit({
|
||||
activeHandle,
|
||||
activeHandleRef,
|
||||
activeSessionTabType: 'terminal',
|
||||
activeSessionTabTypeRef,
|
||||
liveInputRef,
|
||||
liveInputTerminalHandles,
|
||||
liveInputTerminalHandlesRef,
|
||||
sendLiveTerminalInputRef,
|
||||
setLiveInputCapture: (text) => captures.push(text)
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
} finally {
|
||||
restoreConsoleError()
|
||||
}
|
||||
if (!handlers || !renderer) {
|
||||
throw new Error('terminal live input hook did not render')
|
||||
}
|
||||
|
||||
return {
|
||||
captures,
|
||||
handlers,
|
||||
sent,
|
||||
unmount: () => {
|
||||
act(() => renderer?.unmount())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('terminal live input commit hook', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('Given Hangul pending text When the old idle window elapses Then does not send jamo to the terminal', async () => {
|
||||
// Given
|
||||
vi.useFakeTimers()
|
||||
const { captures, handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
|
||||
// When
|
||||
handlers.handleLiveInputChange('ㅎ')
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
|
||||
// Then
|
||||
expect(captures).toEqual(['ㅎ'])
|
||||
expect(sent).toEqual([])
|
||||
})
|
||||
|
||||
it('Given Hangul pending text When submit is requested Then sends composed text before carriage return', async () => {
|
||||
// Given
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
handlers.handleLiveInputChange('한')
|
||||
|
||||
// When
|
||||
handlers.handleLiveInputSubmit()
|
||||
|
||||
// Then
|
||||
await vi.waitFor(() => expect(sent).toEqual(['한', '\r']))
|
||||
})
|
||||
|
||||
it('Given Hangul pending text When an external terminal send is requested Then flushes composed text first', async () => {
|
||||
// Given
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
handlers.handleLiveInputChange('한')
|
||||
|
||||
// When
|
||||
const flushed = await handlers.flushPendingLiveInputBeforeExternalSend('terminal-a')
|
||||
|
||||
// Then
|
||||
expect(flushed).toBe(true)
|
||||
expect(sent).toEqual(['한'])
|
||||
})
|
||||
|
||||
it('Given pending text cannot be sent When an external terminal send is requested Then reports failure', async () => {
|
||||
// Given
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness({ sendResult: false })
|
||||
handlers.handleLiveInputChange('한')
|
||||
|
||||
// When
|
||||
const flushed = await handlers.flushPendingLiveInputBeforeExternalSend('terminal-a')
|
||||
|
||||
// Then
|
||||
expect(flushed).toBe(false)
|
||||
expect(sent).toEqual(['한'])
|
||||
})
|
||||
|
||||
it('Given Chinese and Vietnamese IME text When the settle window elapses Then sends the committed text', async () => {
|
||||
// Given
|
||||
vi.useFakeTimers()
|
||||
const { captures, handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
|
||||
// When
|
||||
handlers.handleLiveInputChange('你好')
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS - 1)
|
||||
|
||||
// Then
|
||||
expect(captures).toEqual(['你好'])
|
||||
expect(sent).toEqual([])
|
||||
|
||||
// When
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await vi.waitFor(() => expect(sent).toEqual(['你好']))
|
||||
handlers.handleLiveInputChange('tiếng Việt')
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS)
|
||||
|
||||
// Then
|
||||
await vi.waitFor(() => expect(sent).toEqual(['你好', 'tiếng Việt']))
|
||||
})
|
||||
|
||||
it('Given deferred IME text When the hook unmounts Then cancels the pending commit timer', async () => {
|
||||
// Given
|
||||
vi.useFakeTimers()
|
||||
const { handlers, sent, unmount } = createTerminalLiveInputCommitHarness()
|
||||
handlers.handleLiveInputChange('é')
|
||||
|
||||
// When
|
||||
unmount()
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
|
||||
// Then
|
||||
expect(sent).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,236 @@
|
|||
import { useCallback, useEffect, type RefObject } from 'react'
|
||||
import type { TextInput } from 'react-native'
|
||||
import {
|
||||
getTerminalLiveSpecialKeyDecision,
|
||||
getTerminalLiveSubmitSequence,
|
||||
getTerminalLiveTextChangeDecision
|
||||
} from './terminal-live-text-commit'
|
||||
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
|
||||
import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input'
|
||||
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
|
||||
import { normalizeTerminalTextInput } from './terminal-text-input-normalization'
|
||||
import { useTerminalLivePendingInputFlush } from './use-terminal-live-pending-input-flush'
|
||||
import {
|
||||
useTerminalLiveAccessoryInputCommit,
|
||||
type TerminalLiveAccessoryInputCommitResult
|
||||
} from './use-terminal-live-accessory-input-commit'
|
||||
|
||||
type TerminalLiveInputKeyPressEvent = {
|
||||
readonly nativeEvent: {
|
||||
readonly key: string
|
||||
}
|
||||
}
|
||||
|
||||
type TerminalLiveInputCommitOptions<TTabType extends string> = {
|
||||
readonly activeHandle: string | null
|
||||
readonly activeHandleRef: RefObject<string | null>
|
||||
readonly activeSessionTabType: TTabType | null | undefined
|
||||
readonly activeSessionTabTypeRef: RefObject<TTabType | null>
|
||||
readonly liveInputRef: RefObject<TextInput | null>
|
||||
readonly liveInputTerminalHandles: ReadonlySet<string>
|
||||
readonly liveInputTerminalHandlesRef: RefObject<Set<string>>
|
||||
readonly sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender>
|
||||
readonly setLiveInputCapture: (text: string) => void
|
||||
}
|
||||
|
||||
type TerminalLiveInputCommitHandlers = {
|
||||
readonly clearPendingLiveInputCommit: () => void
|
||||
readonly flushPendingLiveInputBeforeExternalSend: (handle: string) => Promise<boolean>
|
||||
readonly handleLiveInputAccessoryBytes: (
|
||||
input: TerminalLiveAccessoryInput
|
||||
) => Promise<TerminalLiveAccessoryInputCommitResult>
|
||||
readonly handleLiveInputChange: (text: string) => void
|
||||
readonly handleLiveInputKeyPress: (event: TerminalLiveInputKeyPressEvent) => void
|
||||
readonly handleLiveInputSubmit: () => void
|
||||
}
|
||||
|
||||
export function useTerminalLiveInputCommit<TTabType extends string>({
|
||||
activeHandle,
|
||||
activeHandleRef,
|
||||
activeSessionTabType,
|
||||
activeSessionTabTypeRef,
|
||||
liveInputRef,
|
||||
liveInputTerminalHandles,
|
||||
liveInputTerminalHandlesRef,
|
||||
sendLiveTerminalInputRef,
|
||||
setLiveInputCapture
|
||||
}: TerminalLiveInputCommitOptions<TTabType>): TerminalLiveInputCommitHandlers {
|
||||
const {
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputText,
|
||||
pendingLiveInputHandleRef,
|
||||
pendingLiveInputTextRef,
|
||||
schedulePendingLiveInputCommit,
|
||||
waitForPendingLiveInputFlush
|
||||
} = useTerminalLivePendingInputFlush({
|
||||
activeHandleRef,
|
||||
activeSessionTabTypeRef,
|
||||
liveInputRef,
|
||||
liveInputTerminalHandlesRef,
|
||||
sendLiveTerminalInputRef,
|
||||
setLiveInputCapture
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const pendingHandle = pendingLiveInputHandleRef.current
|
||||
if (!pendingHandle) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
!activeHandle ||
|
||||
pendingHandle !== activeHandle ||
|
||||
activeSessionTabType !== 'terminal' ||
|
||||
!liveInputTerminalHandles.has(activeHandle)
|
||||
) {
|
||||
clearPendingLiveInputCommit()
|
||||
}
|
||||
}, [activeHandle, activeSessionTabType, clearPendingLiveInputCommit, liveInputTerminalHandles])
|
||||
|
||||
const flushPendingLiveInputBeforeExternalSend = useCallback(
|
||||
async (handle: string): Promise<boolean> => {
|
||||
const pendingHandle = pendingLiveInputHandleRef.current
|
||||
if (pendingHandle && pendingHandle !== handle) {
|
||||
clearPendingLiveInputCommit()
|
||||
return waitForPendingLiveInputFlush()
|
||||
}
|
||||
if (pendingHandle === handle && pendingLiveInputTextRef.current.length > 0) {
|
||||
return flushPendingLiveInputText(handle)
|
||||
}
|
||||
return waitForPendingLiveInputFlush()
|
||||
},
|
||||
[clearPendingLiveInputCommit, flushPendingLiveInputText, waitForPendingLiveInputFlush]
|
||||
)
|
||||
|
||||
const handleLiveInputChange = useCallback(
|
||||
(text: string) => {
|
||||
if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) {
|
||||
clearPendingLiveInputCommit()
|
||||
return
|
||||
}
|
||||
const normalizedText = normalizeTerminalTextInput(text)
|
||||
const decision = getTerminalLiveTextChangeDecision(normalizedText)
|
||||
switch (decision.kind) {
|
||||
case 'ignore':
|
||||
clearPendingLiveInputCommit()
|
||||
return
|
||||
case 'send-now':
|
||||
clearPendingLiveInputCommit()
|
||||
void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () =>
|
||||
sendLiveTerminalInputRef.current(activeHandle, decision.text)
|
||||
)
|
||||
return
|
||||
case 'defer':
|
||||
// Why: React Native does not expose composition events here, so keep
|
||||
// probable IME text in the native field until the commit timer settles.
|
||||
setLiveInputCapture(decision.text)
|
||||
schedulePendingLiveInputCommit(activeHandle, decision.text, decision.delayMs)
|
||||
return
|
||||
default:
|
||||
decision satisfies never
|
||||
}
|
||||
},
|
||||
[
|
||||
activeHandle,
|
||||
clearPendingLiveInputCommit,
|
||||
liveInputTerminalHandles,
|
||||
schedulePendingLiveInputCommit,
|
||||
sendLiveTerminalInputRef,
|
||||
setLiveInputCapture,
|
||||
waitForPendingLiveInputFlush
|
||||
]
|
||||
)
|
||||
|
||||
const handleLiveInputKeyPress = useCallback(
|
||||
(event: TerminalLiveInputKeyPressEvent) => {
|
||||
if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) {
|
||||
return
|
||||
}
|
||||
const pendingText =
|
||||
pendingLiveInputHandleRef.current === activeHandle ? pendingLiveInputTextRef.current : ''
|
||||
if (pendingLiveInputHandleRef.current && pendingLiveInputHandleRef.current !== activeHandle) {
|
||||
clearPendingLiveInputCommit()
|
||||
}
|
||||
const decision = getTerminalLiveSpecialKeyDecision({
|
||||
key: event.nativeEvent.key,
|
||||
pendingText
|
||||
})
|
||||
switch (decision.kind) {
|
||||
case 'ignore':
|
||||
case 'local-edit':
|
||||
return
|
||||
case 'send-now':
|
||||
clearPendingLiveInputCommit()
|
||||
void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () =>
|
||||
sendLiveTerminalInputRef.current(activeHandle, decision.bytes)
|
||||
)
|
||||
return
|
||||
case 'flush-then-send':
|
||||
void sendTerminalLiveControlAfterPendingFlush(
|
||||
() => flushPendingLiveInputText(activeHandle),
|
||||
() => sendLiveTerminalInputRef.current(activeHandle, decision.bytes)
|
||||
)
|
||||
return
|
||||
default:
|
||||
decision satisfies never
|
||||
}
|
||||
},
|
||||
[
|
||||
activeHandle,
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputText,
|
||||
liveInputTerminalHandles,
|
||||
sendLiveTerminalInputRef,
|
||||
waitForPendingLiveInputFlush
|
||||
]
|
||||
)
|
||||
|
||||
const handleLiveInputAccessoryBytes = useTerminalLiveAccessoryInputCommit({
|
||||
activeHandle,
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputText,
|
||||
liveInputRef,
|
||||
liveInputTerminalHandles,
|
||||
pendingLiveInputHandleRef,
|
||||
pendingLiveInputTextRef,
|
||||
schedulePendingLiveInputCommit,
|
||||
sendLiveTerminalInputRef,
|
||||
setLiveInputCapture,
|
||||
waitForPendingLiveInputFlush
|
||||
})
|
||||
|
||||
const handleLiveInputSubmit = useCallback(() => {
|
||||
if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) {
|
||||
return
|
||||
}
|
||||
const pendingText =
|
||||
pendingLiveInputHandleRef.current === activeHandle ? pendingLiveInputTextRef.current : ''
|
||||
const sequence = getTerminalLiveSubmitSequence(pendingText)
|
||||
if (sequence.length === 2) {
|
||||
void sendTerminalLiveControlAfterPendingFlush(
|
||||
() => flushPendingLiveInputText(activeHandle),
|
||||
() => sendLiveTerminalInputRef.current(activeHandle, sequence[1])
|
||||
)
|
||||
return
|
||||
}
|
||||
clearPendingLiveInputCommit()
|
||||
void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () =>
|
||||
sendLiveTerminalInputRef.current(activeHandle, sequence[0])
|
||||
)
|
||||
}, [
|
||||
activeHandle,
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputText,
|
||||
liveInputTerminalHandles,
|
||||
sendLiveTerminalInputRef,
|
||||
waitForPendingLiveInputFlush
|
||||
])
|
||||
|
||||
return {
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputBeforeExternalSend,
|
||||
handleLiveInputAccessoryBytes,
|
||||
handleLiveInputChange,
|
||||
handleLiveInputKeyPress,
|
||||
handleLiveInputSubmit
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
import { useCallback, useEffect, useRef, type RefObject } from 'react'
|
||||
import type { TextInput } from 'react-native'
|
||||
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
|
||||
import {
|
||||
queueTerminalLivePendingFlush,
|
||||
waitForTerminalLivePendingFlush
|
||||
} from './terminal-live-pending-flush-state'
|
||||
|
||||
type TerminalLivePendingInputFlushOptions<TTabType extends string> = {
|
||||
readonly activeHandleRef: RefObject<string | null>
|
||||
readonly activeSessionTabTypeRef: RefObject<TTabType | null>
|
||||
readonly liveInputRef: RefObject<TextInput | null>
|
||||
readonly liveInputTerminalHandlesRef: RefObject<Set<string>>
|
||||
readonly sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender>
|
||||
readonly setLiveInputCapture: (text: string) => void
|
||||
}
|
||||
|
||||
type TerminalLivePendingInputFlush = {
|
||||
readonly clearPendingLiveInputCommit: () => void
|
||||
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean>
|
||||
readonly pendingLiveInputHandleRef: RefObject<string | null>
|
||||
readonly pendingLiveInputTextRef: RefObject<string>
|
||||
readonly schedulePendingLiveInputCommit: (
|
||||
handle: string,
|
||||
text: string,
|
||||
delayMs: number | null
|
||||
) => void
|
||||
readonly waitForPendingLiveInputFlush: () => Promise<boolean>
|
||||
}
|
||||
|
||||
export function useTerminalLivePendingInputFlush<TTabType extends string>({
|
||||
activeHandleRef,
|
||||
activeSessionTabTypeRef,
|
||||
liveInputRef,
|
||||
liveInputTerminalHandlesRef,
|
||||
sendLiveTerminalInputRef,
|
||||
setLiveInputCapture
|
||||
}: TerminalLivePendingInputFlushOptions<TTabType>): TerminalLivePendingInputFlush {
|
||||
const liveInputCommitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const pendingLiveInputFlushRef = useRef<Promise<boolean> | null>(null)
|
||||
const pendingLiveInputTextRef = useRef('')
|
||||
const pendingLiveInputHandleRef = useRef<string | null>(null)
|
||||
|
||||
const clearPendingLiveInputCommit = useCallback(() => {
|
||||
if (liveInputCommitTimerRef.current) {
|
||||
clearTimeout(liveInputCommitTimerRef.current)
|
||||
liveInputCommitTimerRef.current = null
|
||||
}
|
||||
pendingLiveInputTextRef.current = ''
|
||||
pendingLiveInputHandleRef.current = null
|
||||
setLiveInputCapture('')
|
||||
liveInputRef.current?.setNativeProps({ text: '' })
|
||||
}, [liveInputRef, setLiveInputCapture])
|
||||
|
||||
const waitForPendingLiveInputFlush = useCallback(async (): Promise<boolean> => {
|
||||
return waitForTerminalLivePendingFlush(pendingLiveInputFlushRef)
|
||||
}, [])
|
||||
|
||||
const flushPendingLiveInputText = useCallback(
|
||||
async (expectedHandle: string | null): Promise<boolean> => {
|
||||
const existingFlush = pendingLiveInputFlushRef.current
|
||||
if (liveInputCommitTimerRef.current) {
|
||||
clearTimeout(liveInputCommitTimerRef.current)
|
||||
liveInputCommitTimerRef.current = null
|
||||
}
|
||||
|
||||
const handle = pendingLiveInputHandleRef.current
|
||||
const text = pendingLiveInputTextRef.current
|
||||
pendingLiveInputHandleRef.current = null
|
||||
pendingLiveInputTextRef.current = ''
|
||||
setLiveInputCapture('')
|
||||
liveInputRef.current?.setNativeProps({ text: '' })
|
||||
|
||||
if (!handle || text.length === 0) {
|
||||
return existingFlush ?? false
|
||||
}
|
||||
if (
|
||||
(expectedHandle !== null && handle !== expectedHandle) ||
|
||||
handle !== activeHandleRef.current ||
|
||||
activeSessionTabTypeRef.current !== 'terminal' ||
|
||||
!liveInputTerminalHandlesRef.current.has(handle)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
return queueTerminalLivePendingFlush(pendingLiveInputFlushRef, () =>
|
||||
sendLiveTerminalInputRef.current(handle, text)
|
||||
)
|
||||
},
|
||||
[
|
||||
activeHandleRef,
|
||||
activeSessionTabTypeRef,
|
||||
liveInputRef,
|
||||
liveInputTerminalHandlesRef,
|
||||
sendLiveTerminalInputRef,
|
||||
setLiveInputCapture
|
||||
]
|
||||
)
|
||||
|
||||
const schedulePendingLiveInputCommit = useCallback(
|
||||
(handle: string, text: string, delayMs: number | null) => {
|
||||
if (liveInputCommitTimerRef.current) {
|
||||
clearTimeout(liveInputCommitTimerRef.current)
|
||||
}
|
||||
pendingLiveInputHandleRef.current = handle
|
||||
pendingLiveInputTextRef.current = text
|
||||
if (delayMs === null) {
|
||||
liveInputCommitTimerRef.current = null
|
||||
return
|
||||
}
|
||||
liveInputCommitTimerRef.current = setTimeout(() => {
|
||||
liveInputCommitTimerRef.current = null
|
||||
void flushPendingLiveInputText(handle)
|
||||
}, delayMs)
|
||||
},
|
||||
[flushPendingLiveInputText]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (liveInputCommitTimerRef.current) {
|
||||
clearTimeout(liveInputCommitTimerRef.current)
|
||||
liveInputCommitTimerRef.current = null
|
||||
}
|
||||
pendingLiveInputHandleRef.current = null
|
||||
pendingLiveInputTextRef.current = ''
|
||||
pendingLiveInputFlushRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputText,
|
||||
pendingLiveInputHandleRef,
|
||||
pendingLiveInputTextRef,
|
||||
schedulePendingLiveInputCommit,
|
||||
waitForPendingLiveInputFlush
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,11 @@ export type TerminalCommandFinishedEventDetail = {
|
|||
// decoupled consumers (e.g. git status refresh) react to shell commands
|
||||
// finishing without reaching into terminal internals.
|
||||
export function dispatchTerminalCommandFinishedEvent(worktreeId: string): void {
|
||||
// Why: unit tests and non-DOM renderer shims may expose only the preload API.
|
||||
if (typeof window.dispatchEvent !== 'function') {
|
||||
return
|
||||
}
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<TerminalCommandFinishedEventDetail>(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, {
|
||||
detail: { worktreeId }
|
||||
|
|
|
|||
Loading…
Reference in New Issue