diff --git a/.github/workflows/mobile-android-release.yml b/.github/workflows/mobile-android-release.yml index 3e0045f240f..0fcf6ff2a45 100644 --- a/.github/workflows/mobile-android-release.yml +++ b/.github/workflows/mobile-android-release.yml @@ -9,22 +9,8 @@ on: - 'mobile-android-v*' workflow_dispatch: inputs: - bump_patch_version: - description: 'Bump the mobile marketing version patch number before release' - required: false - default: false - type: boolean release_version: - description: 'Optional exact mobile marketing version override, e.g. 0.0.15' - required: false - type: string - bump_android_version_code: - description: 'Increment the Android versionCode before release' - required: false - default: false - type: boolean - android_version_code: - description: 'Optional exact Android versionCode override' + description: 'Optional exact mobile marketing version assertion, e.g. 0.0.22' required: false type: string publish_github_release: @@ -69,9 +55,6 @@ jobs: id: release env: MOBILE_ANDROID_RELEASE_VERSION: ${{ github.event.inputs.release_version }} - MOBILE_ANDROID_BUMP_PATCH_VERSION: ${{ github.event.inputs.bump_patch_version }} - MOBILE_ANDROID_VERSION_CODE: ${{ github.event.inputs.android_version_code }} - MOBILE_ANDROID_BUMP_VERSION_CODE: ${{ github.event.inputs.bump_android_version_code }} MOBILE_ANDROID_PUBLISH_RELEASE: ${{ github.event.inputs.publish_github_release }} run: node scripts/prepare-android-release.mjs diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index a7b8fe1814b..4890c0db02e 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -1,6 +1,6 @@ name: Cut Release -# Why: single entry point for manually and scheduled cutting releases. +# Why: single entry point for manually cutting releases. # Replaces the old local `pnpm release:*` scripts and the standalone scheduled # RC workflow so releases are always reproducible from CI and can never be # accidentally tagged against an uncommitted or non-main working tree. @@ -37,16 +37,15 @@ on: type: string default: main dry_run: - description: Validate a scheduled-RC run without creating a tag + description: Validate an RC release cut without creating a tag required: false default: false type: boolean - schedule: - # Why: GitHub scheduled workflows are not guaranteed to start exactly on - # time and can be delayed or dropped around high-load periods. We retry - # across the full target hour and dedupe per PT slot. Cron is UTC, so run - # during both PST and PDT equivalents and let the PT-hour gate decide. - - cron: '*/5 10,11,22,23 * * *' + version_suffix: + description: Extra prerelease identifier appended to an rc version (e.g. "perf" -> 1.2.3-rc.4.perf). rc kind only. + required: false + type: string + default: '' permissions: contents: write @@ -182,8 +181,8 @@ jobs: - name: Publish complete release-cut RC drafts from prior runs id: publish_drafts - # Why: this must run even when the current slot already cut a tag; a - # later cron retry may be the first chance to unstick a complete RC draft. + # Why: a manual RC dispatch should unstick any complete RC draft before + # deciding whether to cut another tag. if: steps.window.outputs.allowed == 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -198,6 +197,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} KIND: ${{ github.event_name == 'schedule' && 'rc' || inputs.kind }} + VERSION_SUFFIX: ${{ github.event_name == 'schedule' && '' || inputs.version_suffix }} run: | set -euo pipefail @@ -372,6 +372,18 @@ jobs: fi new="${base}-rc.$((highest_rc + 1))" fi + if [[ -n "${VERSION_SUFFIX:-}" ]]; then + # Why a dot-appended identifier (rc.N.perf): it sorts just + # above its own base rc.N but BELOW rc.N+1, so suffixed side- + # branch builds never outrank the main RC series and cannot + # hijack the update channel; clients find them by matching the + # identifier ("perf") in the prerelease components. + if [[ ! "$VERSION_SUFFIX" =~ ^[0-9A-Za-z]+$ ]]; then + echo "::error::version_suffix must be alphanumeric, got: $VERSION_SUFFIX" >&2 + exit 1 + fi + new="${new}.${VERSION_SUFFIX}" + fi ;; patch|minor|major) new="$(bump "$latest_stable" "$KIND")" diff --git a/.gitignore b/.gitignore index 0bfbb0e5e84..7cdf3374035 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,9 @@ pnpm-debug.log* # Coverage coverage/ +# Benchmark run output +/tools/benchmarks/results/*.json + # Temp tmp/ .tmp/ diff --git a/config/scripts/create-draft-release.mjs b/config/scripts/create-draft-release.mjs index ffd8af6d86e..8064af7f6f9 100644 --- a/config/scripts/create-draft-release.mjs +++ b/config/scripts/create-draft-release.mjs @@ -6,6 +6,51 @@ const API_VERSION = '2022-11-28' const MAX_RELEASE_BODY_LENGTH = 120_000 const TRUNCATION_NOTICE = '\n\n---\nRelease notes were truncated because GitHub release bodies are limited to 125,000 characters.' +const DESKTOP_RELEASE_TAG_PATTERN = /^v(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$/ + +export function parseDesktopReleaseTag(tag) { + const match = DESKTOP_RELEASE_TAG_PATTERN.exec(tag) + if (!match) { + return null + } + return { + tag, + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + rc: match[4] === undefined ? null : Number(match[4]) + } +} + +function compareDesktopReleaseTags(a, b) { + const versionDiff = a.major - b.major || a.minor - b.minor || a.patch - b.patch + if (versionDiff !== 0) { + return versionDiff + } + if (a.rc === b.rc) { + return 0 + } + if (a.rc === null) { + return 1 + } + if (b.rc === null) { + return -1 + } + return a.rc - b.rc +} + +export function latestPreviousDesktopReleaseTag(tags, tag) { + const current = parseDesktopReleaseTag(tag) + if (!current) { + return '' + } + const previousTags = tags + .map((candidate) => parseDesktopReleaseTag(candidate)) + .filter((candidate) => candidate && candidate.tag !== current.tag) + .filter((candidate) => compareDesktopReleaseTags(candidate, current) < 0) + .sort(compareDesktopReleaseTags) + return previousTags.at(-1)?.tag ?? '' +} function githubHeaders(token) { return { @@ -30,6 +75,29 @@ async function githubJson(fetchImpl, url, token, options = {}) { return res.json() } +async function fetchRepoTags(repo, token, fetchImpl) { + const tags = [] + for (let page = 1; ; page += 1) { + const pageTags = await githubJson( + fetchImpl, + `https://api.github.com/repos/${repo}/tags?per_page=100&page=${page}`, + token + ) + if (!Array.isArray(pageTags)) { + throw new Error(`GitHub tags response page ${page} for ${repo} was not an array`) + } + for (const tag of pageTags) { + if (typeof tag?.name === 'string') { + tags.push(tag.name) + } + } + if (pageTags.length < 100) { + break + } + } + return tags +} + export function truncateReleaseBody(body, maxLength = MAX_RELEASE_BODY_LENGTH) { if (body.length <= maxLength) { return body @@ -60,16 +128,26 @@ export async function createDraftRelease({ throw new Error('token is required') } + const previousTag = latestPreviousDesktopReleaseTag( + await fetchRepoTags(repo, token, fetchImpl), + tag + ) + const generateNotesBody = { + tag_name: tag, + target_commitish: tag, + ...(previousTag ? { previous_tag_name: previousTag } : {}) + } + + // Why: draft releases are invisible to GitHub's generate-notes baseline. + // Passing the previous desktop tag keeps each release from accumulating + // notes from older drafts. const releaseNotes = await githubJson( fetchImpl, `https://api.github.com/repos/${repo}/releases/generate-notes`, token, { method: 'POST', - body: JSON.stringify({ - tag_name: tag, - target_commitish: tag - }) + body: JSON.stringify(generateNotesBody) } ) diff --git a/config/scripts/create-draft-release.test.mjs b/config/scripts/create-draft-release.test.mjs index 66530004cee..985224b2b93 100644 --- a/config/scripts/create-draft-release.test.mjs +++ b/config/scripts/create-draft-release.test.mjs @@ -1,5 +1,10 @@ import { describe, expect, it, vi } from 'vitest' -import { createDraftRelease, truncateReleaseBody } from './create-draft-release.mjs' +import { + createDraftRelease, + latestPreviousDesktopReleaseTag, + parseDesktopReleaseTag, + truncateReleaseBody +} from './create-draft-release.mjs' function jsonResponse(body, init = {}) { return { @@ -24,10 +29,60 @@ describe('truncateReleaseBody', () => { }) }) +describe('parseDesktopReleaseTag', () => { + it('parses stable and rc desktop release tags only', () => { + expect(parseDesktopReleaseTag('v1.4.36')).toMatchObject({ + tag: 'v1.4.36', + major: 1, + minor: 4, + patch: 36, + rc: null + }) + expect(parseDesktopReleaseTag('v1.4.36-rc.2')).toMatchObject({ + tag: 'v1.4.36-rc.2', + major: 1, + minor: 4, + patch: 36, + rc: 2 + }) + expect(parseDesktopReleaseTag('mobile-v0.0.12')).toBeNull() + }) +}) + +describe('latestPreviousDesktopReleaseTag', () => { + it('bounds stable notes to the previous rc when one exists', () => { + expect(latestPreviousDesktopReleaseTag(['v1.4.35', 'v1.4.36-rc.0', 'v1.4.36'], 'v1.4.36')).toBe( + 'v1.4.36-rc.0' + ) + }) + + it('bounds the first rc notes to the previous stable release', () => { + expect( + latestPreviousDesktopReleaseTag(['v1.4.35', 'v1.4.36-rc.0', 'mobile-v0.0.12'], 'v1.4.36-rc.0') + ).toBe('v1.4.35') + }) + + it('bounds later rc notes to the prior rc', () => { + expect(latestPreviousDesktopReleaseTag(['v1.4.36-rc.0', 'v1.4.36-rc.1'], 'v1.4.36-rc.1')).toBe( + 'v1.4.36-rc.0' + ) + }) + + it('returns empty string for the first desktop release when no earlier tag exists', () => { + expect(latestPreviousDesktopReleaseTag(['v1.4.36', 'mobile-v0.0.12'], 'v1.4.36')).toBe('') + expect(latestPreviousDesktopReleaseTag([], 'v1.4.36')).toBe('') + }) + + it('returns empty string when the current tag is not a desktop release tag', () => { + expect(latestPreviousDesktopReleaseTag(['v1.4.35', 'v1.4.36'], 'mobile-v0.0.12')).toBe('') + }) +}) + describe('createDraftRelease', () => { it('creates a draft release with bounded generated notes', async () => { const fetchImpl = vi .fn() + .mockResolvedValueOnce(jsonResponse([{ name: 'v1.4.35' }, { name: 'v1.4.36' }])) .mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'a'.repeat(130_000) })) .mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36', draft: true })) @@ -41,17 +96,23 @@ describe('createDraftRelease', () => { expect(fetchImpl).toHaveBeenNthCalledWith( 1, + 'https://api.github.com/repos/stablyai/orca/tags?per_page=100&page=1', + expect.any(Object) + ) + expect(fetchImpl).toHaveBeenNthCalledWith( + 2, 'https://api.github.com/repos/stablyai/orca/releases/generate-notes', expect.objectContaining({ method: 'POST', body: JSON.stringify({ tag_name: 'v1.4.36', - target_commitish: 'v1.4.36' + target_commitish: 'v1.4.36', + previous_tag_name: 'v1.4.35' }) }) ) expect(fetchImpl).toHaveBeenNthCalledWith( - 2, + 3, 'https://api.github.com/repos/stablyai/orca/releases', expect.objectContaining({ method: 'POST', @@ -59,7 +120,7 @@ describe('createDraftRelease', () => { }) ) - const createBody = JSON.parse(fetchImpl.mock.calls[1][1].body) + const createBody = JSON.parse(fetchImpl.mock.calls[2][1].body) expect(createBody).toMatchObject({ tag_name: 'v1.4.36', name: 'v1.4.36', @@ -73,6 +134,7 @@ describe('createDraftRelease', () => { it('marks rc tags as prereleases', async () => { const fetchImpl = vi .fn() + .mockResolvedValueOnce(jsonResponse([{ name: 'v1.4.36' }, { name: 'v1.4.36-rc.1' }])) .mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36-rc.1', body: 'notes' })) .mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36-rc.1', draft: true })) @@ -84,7 +146,58 @@ describe('createDraftRelease', () => { log: vi.fn() }) - const createBody = JSON.parse(fetchImpl.mock.calls[1][1].body) + const createBody = JSON.parse(fetchImpl.mock.calls[2][1].body) expect(createBody.prerelease).toBe(true) }) + + it('omits previous_tag_name for the first desktop release so notes fall back to the GitHub default', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(jsonResponse([{ name: 'v1.4.36' }, { name: 'mobile-v0.0.12' }])) + .mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'notes' })) + .mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36', draft: true })) + + await createDraftRelease({ + repo: 'stablyai/orca', + tag: 'v1.4.36', + token: 'token', + fetchImpl, + log: vi.fn() + }) + + const generateNotesBody = JSON.parse(fetchImpl.mock.calls[1][1].body) + expect(generateNotesBody).toEqual({ tag_name: 'v1.4.36', target_commitish: 'v1.4.36' }) + expect(generateNotesBody).not.toHaveProperty('previous_tag_name') + }) + + it('paginates through every tag page before choosing the previous tag', async () => { + const firstPage = Array.from({ length: 100 }, (_, index) => ({ name: `mobile-v0.0.${index}` })) + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(jsonResponse(firstPage)) + .mockResolvedValueOnce(jsonResponse([{ name: 'v1.4.35' }])) + .mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'notes' })) + .mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36', draft: true })) + + await createDraftRelease({ + repo: 'stablyai/orca', + tag: 'v1.4.36', + token: 'token', + fetchImpl, + log: vi.fn() + }) + + expect(fetchImpl).toHaveBeenNthCalledWith( + 1, + 'https://api.github.com/repos/stablyai/orca/tags?per_page=100&page=1', + expect.any(Object) + ) + expect(fetchImpl).toHaveBeenNthCalledWith( + 2, + 'https://api.github.com/repos/stablyai/orca/tags?per_page=100&page=2', + expect.any(Object) + ) + const generateNotesBody = JSON.parse(fetchImpl.mock.calls[2][1].body) + expect(generateNotesBody.previous_tag_name).toBe('v1.4.35') + }) }) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index f83dbfcb7ae..ff7c9fd7520 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 3.3m + + downloads: 3.4m @@ -15,7 +15,7 @@ downloads downloads - 3.3m - 3.3m + 3.4m + 3.4m diff --git a/docs/assets/wechat-qr.png b/docs/assets/wechat-qr.png index 3c71c4952ea..bf270d857e8 100644 Binary files a/docs/assets/wechat-qr.png and b/docs/assets/wechat-qr.png differ diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md index 52cf23f2706..630cf497921 100644 --- a/docs/readme/README.es.md +++ b/docs/readme/README.es.md @@ -36,7 +36,7 @@ Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar. -[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono. - **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk) +- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) --- diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index a65661afee1..df6adecb5da 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -36,7 +36,7 @@ スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。 -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。 - **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk) +- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) --- diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index f62e14648ab..a2e113f090d 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -36,7 +36,7 @@ 휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다. -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin 데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요. - **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk) +- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) --- diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index 46ff846a7ee..1831f10e4c0 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -36,7 +36,7 @@ 用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。 -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin 与桌面应用配对,用手机监控并指挥你的智能体。 - **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk) +- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) --- diff --git a/mobile/.gitignore b/mobile/.gitignore index 9fa2fa13bb7..449fa3f2439 100644 --- a/mobile/.gitignore +++ b/mobile/.gitignore @@ -1,4 +1,5 @@ node_modules/ +src/terminal/terminal-webview-engine.generated.ts .expo/ dist/ /android/ diff --git a/mobile/.oxlintrc.json b/mobile/.oxlintrc.json index 828d42615a1..4929f3c562d 100644 --- a/mobile/.oxlintrc.json +++ b/mobile/.oxlintrc.json @@ -1,6 +1,7 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", "extends": ["../.oxlintrc.json"], + "ignorePatterns": ["src/terminal/terminal-webview-engine.generated.ts"], "rules": { "react-hooks/exhaustive-deps": "off", "react/no-unescaped-entities": "off", diff --git a/mobile/app.json b/mobile/app.json index 3a09a5531c8..27d3b7d88c6 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Orca", "slug": "orca-mobile", - "version": "0.0.20", + "version": "0.0.22", "orientation": "default", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", @@ -75,7 +75,7 @@ "allowBackup": false, "permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"], "package": "com.stably.orca.mobile", - "versionCode": 2 + "versionCode": 4 }, "plugins": [ "expo-router", diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index a934334b8f2..b0c766cb206 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -96,6 +96,7 @@ import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/termi import { getTerminalLiveAccessoryRawSendTarget } from '../../../../src/terminal/terminal-live-accessory-raw-send-target' import { clearTerminalLiveInputFocusTimer, + focusTerminalLiveInputTarget, isTerminalLiveInputWithinByteLimit, scheduleTerminalLiveInputFocus } from '../../../../src/terminal/terminal-live-input' @@ -3134,8 +3135,12 @@ export default function SessionScreen() { if (!canSend || !liveInputEnabled) { return } - liveInputRef.current?.focus() - }, [canSend, liveInputEnabled]) + focusTerminalLiveInputTarget(liveInputRef.current, { + keyboardHeight, + refocus: () => + scheduleTerminalLiveInputFocus(liveInputFocusTimerRef, () => liveInputRef.current?.focus()) + }) + }, [canSend, keyboardHeight, liveInputEnabled]) const clearSessionTabActionSheetKeyboardListener = useCallback(() => { sessionTabActionSheetKeyboardHideSubRef.current?.remove() @@ -4947,10 +4952,16 @@ export default function SessionScreen() { {liveInputEnabled ? ( [ + styles.liveInputFocusTarget, + pressed && styles.liveInputFocusTargetPressed, + !canSend && styles.liveInputFocusTargetDisabled + ]} disabled={!canSend} onPress={focusLiveInput} - accessibilityLabel="Focus live terminal input" + accessibilityRole="button" + accessibilityLabel="Show keyboard for live terminal input" + accessibilityHint="Typed text is sent directly to the active terminal" > =18'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.28.1': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.25.4': + resolution: {integrity: sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.28.1': resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.25.4': + resolution: {integrity: sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.28.1': resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.25.4': + resolution: {integrity: sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.28.1': resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.25.4': + resolution: {integrity: sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.28.1': resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.25.4': + resolution: {integrity: sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.28.1': resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.25.4': + resolution: {integrity: sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.28.1': resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.25.4': + resolution: {integrity: sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.28.1': resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.25.4': + resolution: {integrity: sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.28.1': resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.25.4': + resolution: {integrity: sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.28.1': resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.25.4': + resolution: {integrity: sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.28.1': resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.25.4': + resolution: {integrity: sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.28.1': resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.25.4': + resolution: {integrity: sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.28.1': resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.25.4': + resolution: {integrity: sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.28.1': resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.25.4': + resolution: {integrity: sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.28.1': resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.25.4': + resolution: {integrity: sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.28.1': resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.25.4': + resolution: {integrity: sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.28.1': resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/netbsd-arm64@0.25.4': + resolution: {integrity: sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-arm64@0.28.1': resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.25.4': + resolution: {integrity: sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.28.1': resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/openbsd-arm64@0.25.4': + resolution: {integrity: sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.28.1': resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.25.4': + resolution: {integrity: sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.28.1': resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} @@ -1289,24 +1430,48 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.25.4': + resolution: {integrity: sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.28.1': resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.25.4': + resolution: {integrity: sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.28.1': resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.25.4': + resolution: {integrity: sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.28.1': resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.25.4': + resolution: {integrity: sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.28.1': resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} @@ -1730,56 +1895,48 @@ 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==} @@ -1852,56 +2009,48 @@ 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==} @@ -2363,42 +2512,36 @@ 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==} @@ -2653,6 +2796,19 @@ packages: resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} engines: {node: '>=14.6'} + '@xterm/addon-unicode11@0.10.0-beta.285': + resolution: {integrity: sha512-rfijFu7UcYpaFx5wzxvTpQbIyyq/amf2PuS9pktywcFQr4ITxRgid5EVzKLRG1vchkApNcQplWeYxGEtjiw0Cw==} + peerDependencies: + '@xterm/xterm': ^6.1.0-beta.285 + + '@xterm/addon-webgl@0.20.0-beta.284': + resolution: {integrity: sha512-tzkUiEfdpHCY8mXCbuIaP9V67QDfBJvDr9jdxs5jjxNCIQvw+NCoKD97y5sUrQhrIlr7xrDGniPgPYThQ/1FWg==} + peerDependencies: + '@xterm/xterm': ^6.1.0-beta.285 + + '@xterm/xterm@6.1.0-beta.285': + resolution: {integrity: sha512-S3K58tepMkbpWRBzOGKd0In6AVvt9QPAnNs8DJ8rPUPODYtsCYWAtINHKYtC2OpXcE5EBKM35dl+Dgv03OoE/w==} + abab@2.0.6: resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} deprecated: Use your platform's native atob() and btoa() methods instead @@ -2681,8 +2837,8 @@ packages: resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} engines: {node: '>=0.4.0'} - acorn@8.16.0: - resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} engines: {node: '>=0.4.0'} hasBin: true @@ -3378,6 +3534,11 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + esbuild@0.25.4: + resolution: {integrity: sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.28.1: resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} @@ -4660,28 +4821,24 @@ 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==} @@ -7675,81 +7832,156 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.25.4': + optional: true + '@esbuild/aix-ppc64@0.28.1': optional: true + '@esbuild/android-arm64@0.25.4': + optional: true + '@esbuild/android-arm64@0.28.1': optional: true + '@esbuild/android-arm@0.25.4': + optional: true + '@esbuild/android-arm@0.28.1': optional: true + '@esbuild/android-x64@0.25.4': + optional: true + '@esbuild/android-x64@0.28.1': optional: true + '@esbuild/darwin-arm64@0.25.4': + optional: true + '@esbuild/darwin-arm64@0.28.1': optional: true + '@esbuild/darwin-x64@0.25.4': + optional: true + '@esbuild/darwin-x64@0.28.1': optional: true + '@esbuild/freebsd-arm64@0.25.4': + optional: true + '@esbuild/freebsd-arm64@0.28.1': optional: true + '@esbuild/freebsd-x64@0.25.4': + optional: true + '@esbuild/freebsd-x64@0.28.1': optional: true + '@esbuild/linux-arm64@0.25.4': + optional: true + '@esbuild/linux-arm64@0.28.1': optional: true + '@esbuild/linux-arm@0.25.4': + optional: true + '@esbuild/linux-arm@0.28.1': optional: true + '@esbuild/linux-ia32@0.25.4': + optional: true + '@esbuild/linux-ia32@0.28.1': optional: true + '@esbuild/linux-loong64@0.25.4': + optional: true + '@esbuild/linux-loong64@0.28.1': optional: true + '@esbuild/linux-mips64el@0.25.4': + optional: true + '@esbuild/linux-mips64el@0.28.1': optional: true + '@esbuild/linux-ppc64@0.25.4': + optional: true + '@esbuild/linux-ppc64@0.28.1': optional: true + '@esbuild/linux-riscv64@0.25.4': + optional: true + '@esbuild/linux-riscv64@0.28.1': optional: true + '@esbuild/linux-s390x@0.25.4': + optional: true + '@esbuild/linux-s390x@0.28.1': optional: true + '@esbuild/linux-x64@0.25.4': + optional: true + '@esbuild/linux-x64@0.28.1': optional: true + '@esbuild/netbsd-arm64@0.25.4': + optional: true + '@esbuild/netbsd-arm64@0.28.1': optional: true + '@esbuild/netbsd-x64@0.25.4': + optional: true + '@esbuild/netbsd-x64@0.28.1': optional: true + '@esbuild/openbsd-arm64@0.25.4': + optional: true + '@esbuild/openbsd-arm64@0.28.1': optional: true + '@esbuild/openbsd-x64@0.25.4': + optional: true + '@esbuild/openbsd-x64@0.28.1': optional: true '@esbuild/openharmony-arm64@0.28.1': optional: true + '@esbuild/sunos-x64@0.25.4': + optional: true + '@esbuild/sunos-x64@0.28.1': optional: true + '@esbuild/win32-arm64@0.25.4': + optional: true + '@esbuild/win32-arm64@0.28.1': optional: true + '@esbuild/win32-ia32@0.25.4': + optional: true + '@esbuild/win32-ia32@0.28.1': optional: true + '@esbuild/win32-x64@0.25.4': + optional: true + '@esbuild/win32-x64@0.28.1': optional: true @@ -9327,13 +9559,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.9(vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))': + '@vitest/mocker@4.1.9(vite@8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) '@vitest/pretty-format@4.1.9': dependencies: @@ -9363,6 +9595,16 @@ snapshots: '@xmldom/xmldom@0.9.10': {} + '@xterm/addon-unicode11@0.10.0-beta.285(@xterm/xterm@6.1.0-beta.285)': + dependencies: + '@xterm/xterm': 6.1.0-beta.285 + + '@xterm/addon-webgl@0.20.0-beta.284(@xterm/xterm@6.1.0-beta.285)': + dependencies: + '@xterm/xterm': 6.1.0-beta.285 + + '@xterm/xterm@6.1.0-beta.285': {} + abab@2.0.6: {} abort-controller@3.0.0: @@ -9381,7 +9623,7 @@ snapshots: acorn-globals@7.0.1: dependencies: - acorn: 8.16.0 + acorn: 8.15.0 acorn-walk: 8.3.5 acorn-jsx@5.3.2(acorn@8.17.0): @@ -9390,9 +9632,9 @@ snapshots: acorn-walk@8.3.5: dependencies: - acorn: 8.16.0 + acorn: 8.15.0 - acorn@8.16.0: {} + acorn@8.15.0: {} acorn@8.17.0: {} @@ -10235,6 +10477,34 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + esbuild@0.25.4: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.4 + '@esbuild/android-arm': 0.25.4 + '@esbuild/android-arm64': 0.25.4 + '@esbuild/android-x64': 0.25.4 + '@esbuild/darwin-arm64': 0.25.4 + '@esbuild/darwin-x64': 0.25.4 + '@esbuild/freebsd-arm64': 0.25.4 + '@esbuild/freebsd-x64': 0.25.4 + '@esbuild/linux-arm': 0.25.4 + '@esbuild/linux-arm64': 0.25.4 + '@esbuild/linux-ia32': 0.25.4 + '@esbuild/linux-loong64': 0.25.4 + '@esbuild/linux-mips64el': 0.25.4 + '@esbuild/linux-ppc64': 0.25.4 + '@esbuild/linux-riscv64': 0.25.4 + '@esbuild/linux-s390x': 0.25.4 + '@esbuild/linux-x64': 0.25.4 + '@esbuild/netbsd-arm64': 0.25.4 + '@esbuild/netbsd-x64': 0.25.4 + '@esbuild/openbsd-arm64': 0.25.4 + '@esbuild/openbsd-x64': 0.25.4 + '@esbuild/sunos-x64': 0.25.4 + '@esbuild/win32-arm64': 0.25.4 + '@esbuild/win32-ia32': 0.25.4 + '@esbuild/win32-x64': 0.25.4 + esbuild@0.28.1: optionalDependencies: '@esbuild/aix-ppc64': 0.28.1 @@ -10688,7 +10958,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.6(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.25.4)(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) @@ -10708,7 +10978,7 @@ snapshots: jest-snapshot-prettier: prettier@2.8.8 jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@25.9.3)) resolve-workspace-root: 2.0.1 - ts-jest: 29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(jest@29.7.0(@types/node@25.9.3))(typescript@5.9.3) + ts-jest: 29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@25.9.3))(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - '@babel/core' @@ -11832,7 +12102,7 @@ snapshots: jsdom@20.0.3: dependencies: abab: 2.0.6 - acorn: 8.16.0 + acorn: 8.15.0 acorn-globals: 7.0.1 cssom: 0.5.0 cssstyle: 2.3.0 @@ -13463,7 +13733,7 @@ snapshots: terser@5.46.2: dependencies: '@jridgewell/source-map': 0.3.11 - acorn: 8.16.0 + acorn: 8.15.0 commander: 2.20.3 source-map-support: 0.5.21 @@ -13527,7 +13797,7 @@ snapshots: picomatch: 4.0.4 typescript: 5.9.3 - ts-jest@29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(jest@29.7.0(@types/node@25.9.3))(typescript@5.9.3): + ts-jest@29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@25.9.3))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 @@ -13543,7 +13813,7 @@ snapshots: '@babel/core': 7.29.7 '@jest/types': 29.6.3 babel-jest: 29.7.0(@babel/core@7.29.7) - esbuild: 0.28.1 + esbuild: 0.25.4 tsconfig-paths@3.15.0: dependencies: @@ -13700,7 +13970,7 @@ snapshots: - '@types/react' - '@types/react-dom' - vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0): + vite@8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -13709,16 +13979,16 @@ snapshots: tinyglobby: 0.2.17 optionalDependencies: '@types/node': 25.9.3 - esbuild: 0.28.1 + esbuild: 0.25.4 fsevents: 2.3.3 terser: 5.48.0 tsx: 4.22.4 yaml: 2.9.0 - vitest@4.1.9(@types/node@25.9.3)(jsdom@20.0.3)(vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)): + vitest@4.1.9(@types/node@25.9.3)(jsdom@20.0.3)(vite@8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + '@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.9 '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 @@ -13735,7 +14005,7 @@ snapshots: tinyexec: 1.1.2 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.9.3 diff --git a/mobile/scripts/build-terminal-webview-engine.mjs b/mobile/scripts/build-terminal-webview-engine.mjs new file mode 100644 index 00000000000..ef6bf0dc1b7 --- /dev/null +++ b/mobile/scripts/build-terminal-webview-engine.mjs @@ -0,0 +1,108 @@ +import { readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { createRequire } from 'node:module' +import * as esbuild from 'esbuild' + +const require = createRequire(import.meta.url) +const scriptDir = import.meta.dirname +const mobileRoot = path.resolve(scriptDir, '..') +const outputPath = path.join(mobileRoot, 'src', 'terminal', 'terminal-webview-engine.generated.ts') +const target = 'chrome74' + +const packages = ['@xterm/xterm', '@xterm/addon-unicode11', '@xterm/addon-webgl'] + +async function readPackageVersion(packageName) { + // Why: a package.json module specifier must use '/' — path.join emits '\' on + // Windows, yielding an unresolvable bare specifier that fails postinstall there. + const packageJsonPath = require.resolve(`${packageName}/package.json`) + const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')) + return `${packageName}@${packageJson.version}` +} + +function htmlText(value, closingTag) { + return value.replace(new RegExp(` { + afterEach(() => { + for (const dir of tempDirs) { + rmSync(dir, { force: true, recursive: true }) + } + tempDirs = [] + }) + + it('uses committed Android release identity without mutating app config', () => { + const { configPath, contents } = createAppConfig() + + const output = execFileSync(process.execPath, [scriptPath], { + encoding: 'utf8', + env: { + ...process.env, + MOBILE_APP_CONFIG_PATH: configPath, + MOBILE_ANDROID_PUBLISH_RELEASE: 'true' + } + }) + + expect(output).toContain('Prepared Orca Mobile Android 0.0.22 (4)') + expect(output).toContain('Release tag: mobile-android-v0.0.22') + expect(readFileSync(configPath, 'utf8')).toBe(contents) + }) + + it('rejects release-only Android versionCode bumps', () => { + const { configPath } = createAppConfig() + + const result = spawnSync(process.execPath, [scriptPath], { + encoding: 'utf8', + env: { + ...process.env, + MOBILE_APP_CONFIG_PATH: configPath, + MOBILE_ANDROID_BUMP_VERSION_CODE: 'true' + } + }) + + expect(result.status).toBe(1) + expect(result.stderr).toContain( + 'Android versionCode changes must be committed in mobile/app.json before release' + ) + }) + + it('rejects release versions that do not match committed app config', () => { + const { configPath } = createAppConfig() + + const result = spawnSync(process.execPath, [scriptPath], { + encoding: 'utf8', + env: { + ...process.env, + MOBILE_APP_CONFIG_PATH: configPath, + MOBILE_ANDROID_RELEASE_VERSION: '0.0.23' + } + }) + + expect(result.status).toBe(1) + expect(result.stderr).toContain( + 'MOBILE_ANDROID_RELEASE_VERSION must match the committed mobile app version' + ) + }) +}) diff --git a/mobile/src/session/MobileTerminalLiveInputStatus.tsx b/mobile/src/session/MobileTerminalLiveInputStatus.tsx index bde542ab342..dc135c3eb60 100644 --- a/mobile/src/session/MobileTerminalLiveInputStatus.tsx +++ b/mobile/src/session/MobileTerminalLiveInputStatus.tsx @@ -27,9 +27,11 @@ export function MobileTerminalLiveInputStatus({ ? 'Tap mic to stop' : dictation.isProcessing ? 'Transcribing on desktop' - : isAttaching - ? 'Uploading image to host' - : 'Keyboard goes directly to terminal' + : dictation.isStarting + ? 'Preparing microphone' + : isAttaching + ? 'Uploading image to host' + : 'Tap to show keyboard' return ( diff --git a/mobile/src/terminal/TerminalWebView.tsx b/mobile/src/terminal/TerminalWebView.tsx index a964ac0a9f6..1798fedd1d8 100644 --- a/mobile/src/terminal/TerminalWebView.tsx +++ b/mobile/src/terminal/TerminalWebView.tsx @@ -1,76 +1,30 @@ import { useRef, useCallback, forwardRef, useImperativeHandle, useEffect, useMemo } from 'react' -import { StyleSheet, type StyleProp, type ViewStyle } from 'react-native' +import { View, type StyleProp, type ViewStyle } from 'react-native' import { WebView } from 'react-native-webview' import type { WebViewMessageEvent } from 'react-native-webview' -import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types' -import { colors } from '../theme/mobile-theme' import type { TerminalOscLinkRange } from './terminal-osc-link-ranges' -import { XTERM_HTML } from './terminal-webview-html' +import type { + MobileTerminalTheme, + TerminalSelectionEvents, + TerminalWebViewHandle +} from './terminal-webview-contract' +import { + TerminalWebViewEngineErrorOverlay, + useTerminalWebViewEngineErrorState +} from './terminal-webview-engine-error-state' +import { TERMINAL_WEBVIEW_FRAME_STYLES } from './terminal-webview-frame-styles' +import { useTerminalWebReadyWatchdog } from './terminal-webview-ready-watchdog' +import { XTERM_WEBVIEW_SOURCE } from './terminal-webview-html' import type { TerminalWebViewCommand } from './terminal-webview-messages' import { createTerminalWebViewPendingMessages } from './terminal-webview-pending-messages' -type TerminalMouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any' -type TerminalOscLinks = TerminalOscLinkRange[] - -export type TerminalModes = { - bracketedPasteMode: boolean - altScreen: boolean - mouseTrackingMode: TerminalMouseTrackingMode - sgrMouseMode: boolean - sgrMousePixelsMode: boolean -} - -export type TerminalKeyboardAvoidanceMetrics = { - cursorY: number - rows: number - altScreen: boolean -} - -export type MobileTerminalTheme = RuntimeMobileTerminalTheme - -export type TerminalSelectionEvents = { - onSelectionMode?: (active: boolean) => void - onSelectionCopy?: (text: string) => void - onSelectionEvicted?: () => void - onModesChanged?: (modes: TerminalModes) => void - onKeyboardAvoidanceMetrics?: (metrics: TerminalKeyboardAvoidanceMetrics) => void - onHaptic?: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void - onTerminalInput?: (bytes: string) => void - onTerminalTap?: () => void - // Tap landed on a detected file path; RN resolves + opens it. - onFileTap?: (pathText: string, line: number | null, column: number | null) => void - // WebView-detected URL tap; RN chooses the mobile routing destination. - onOpenUrl?: (url: string) => void - // Why: pinch-to-zoom in the terminal snaps to a text-size preset and reports it - // here so the app persists it and keeps Settings + other panes in sync. - onTextScaleChange?: (scale: number) => void -} - -export type TerminalWebViewHandle = { - write: (data: string) => void - init: ( - cols: number, - rows: number, - initialData?: string, - preserveScroll?: boolean, - oscLinks?: TerminalOscLinks - ) => void - resize: (cols: number, rows: number) => void - // Why: reflow the local xterm buffer (scrollback included) to a new width - // after a server-side PTY reflow, so older wrapped lines rewrap to match the - // latest output. No-op on the alternate screen. - reflow: (cols: number, rows: number) => void - clear: () => void - measureFitDimensions: (containerHeight?: number) => Promise<{ cols: number; rows: number } | null> - resetZoom: () => void - cancelSelect: () => void - doSelectAll: () => void - // Why: lets callers await the WebView-side `init` rAF chain (term.open - // → renderService population → first paint) so a follow-up measure - // doesn't race ahead and find term=null or cellWidth=0. Resolves on - // the next 'ready' notify after the most recent init. - awaitReady: () => Promise -} +export type { + MobileTerminalTheme, + TerminalKeyboardAvoidanceMetrics, + TerminalModes, + TerminalSelectionEvents, + TerminalWebViewHandle +} from './terminal-webview-contract' type Props = { style?: StyleProp @@ -79,18 +33,16 @@ type Props = { // scale; raw xterm fontSize can't drive apparent size because the fit cancels it. textScale?: number onWebReady?: () => void + onEngineError?: (message: string) => void } & TerminalSelectionEvents -// Why: WebView treats source identity as page identity on some platforms; keep -// parent/session re-renders from reloading xterm and forcing fresh snapshots. -const XTERM_WEBVIEW_SOURCE = { html: XTERM_HTML } - export const TerminalWebView = forwardRef(function TerminalWebView( { style, terminalTheme, textScale = 1, onWebReady, + onEngineError, onSelectionMode, onSelectionCopy, onSelectionEvicted, @@ -119,6 +71,12 @@ export const TerminalWebView = forwardRef(function // race ahead of term.open() / renderService population. const readyPromiseRef = useRef | null>(null) const readyResolveRef = useRef<(() => void) | null>(null) + const { clearEngineError, engineError, reportEngineError, reportNativeEngineError } = + useTerminalWebViewEngineErrorState(onEngineError) + const { armWebReadyWatchdog, clearWebReadyWatchdog } = useTerminalWebReadyWatchdog( + isWebReadyRef, + reportEngineError + ) const sendToWebView = useCallback((msg: TerminalWebViewCommand) => { messageIdRef.current += 1 @@ -151,6 +109,8 @@ export const TerminalWebView = forwardRef(function if (msg.type === 'web-ready') { isWebReadyRef.current = true + clearWebReadyWatchdog() + clearEngineError() onWebReady?.() flushPendingMessages() } else if (msg.type === 'ready') { @@ -175,6 +135,9 @@ export const TerminalWebView = forwardRef(function const tag = typeof msg.tag === 'string' ? msg.tag : '[fit]' // eslint-disable-next-line no-console console.log(tag, msg.payload) + } else if (msg.type === 'error') { + const message = typeof msg.message === 'string' ? msg.message : 'Unknown terminal error' + reportEngineError(message, msg.fatal !== false) } else if (msg.type === 'set-select-mode') { onSelectionMode?.(!!msg.enabled) } else if (msg.type === 'selection') { @@ -246,6 +209,9 @@ export const TerminalWebView = forwardRef(function }, [ flushPendingMessages, + clearEngineError, + clearWebReadyWatchdog, + reportEngineError, onWebReady, onSelectionMode, onSelectionCopy, @@ -263,10 +229,16 @@ export const TerminalWebView = forwardRef(function const handleLoadStart = useCallback(() => { isWebReadyRef.current = false + armWebReadyWatchdog() // Why: messages queued for a previous WebView generation are stale after a reload; // dropping them avoids replaying terminal chunks before the next init snapshot. pendingMessages.clear() - }, [pendingMessages]) + }, [armWebReadyWatchdog, pendingMessages]) + + const handleReload = useCallback(() => { + clearEngineError() + webViewRef.current?.reload() + }, [clearEngineError]) useEffect(() => { postMessage({ type: 'set-theme', terminalTheme }) @@ -289,7 +261,7 @@ export const TerminalWebView = forwardRef(function rows: number, initialData?: string, preserveScroll?: boolean, - oscLinks?: TerminalOscLinks + oscLinks?: TerminalOscLinkRange[] ) { // Why: arm a fresh ready promise BEFORE posting init. The WebView // resolves it via the 'ready' notify at the end of its rAF chain. @@ -395,29 +367,35 @@ export const TerminalWebView = forwardRef(function ) return ( - + + reportNativeEngineError('Terminal WebView load failed', event)} + onHttpError={(event) => reportNativeEngineError('Terminal WebView HTTP error', event)} + onRenderProcessGone={(event) => + reportNativeEngineError('Terminal WebView render process ended', event) + } + onContentProcessDidTerminate={(event) => + reportNativeEngineError('Terminal WebView content process ended', event) + } + /> + {engineError ? ( + + ) : null} + ) }) - -const styles = StyleSheet.create({ - webview: { - flex: 1, - backgroundColor: colors.terminalBg - } -}) diff --git a/mobile/src/terminal/terminal-live-hangul-mirror.test.ts b/mobile/src/terminal/terminal-live-hangul-mirror.test.ts new file mode 100644 index 00000000000..b1a6669506c --- /dev/null +++ b/mobile/src/terminal/terminal-live-hangul-mirror.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from 'vitest' +import { + buildTerminalLiveMirrorPayload, + computeTerminalLiveMirrorStep, + isTerminalLiveHangulCodePoint, + type TerminalLiveMirrorStep +} from './terminal-live-hangul-mirror' + +type MirrorRun = { + readonly payloads: readonly string[] + readonly sentText: string + readonly heldText: string +} + +function runMirrorSequence( + fieldStates: readonly string[], + options: { readonly commitAtEnd: boolean } = { commitAtEnd: false } +): MirrorRun { + const payloads: string[] = [] + let sentText = '' + let heldText = '' + for (const fieldText of fieldStates) { + const step = computeTerminalLiveMirrorStep(sentText, fieldText, { commitHeld: false }) + const payload = buildTerminalLiveMirrorPayload(step) + if (payload.length > 0) { + payloads.push(payload) + } + sentText = step.nextSentText + heldText = step.heldText + } + if (options.commitAtEnd) { + const lastField = sentText + heldText + const step = computeTerminalLiveMirrorStep(sentText, lastField, { commitHeld: true }) + const payload = buildTerminalLiveMirrorPayload(step) + if (payload.length > 0) { + payloads.push(payload) + } + sentText = step.nextSentText + heldText = step.heldText + } + return { payloads, sentText, heldText } +} + +describe('terminal live hangul mirror', () => { + it('Given single-syllable composition When steps run Then leaks no jamo and commits only the final syllable', () => { + // Given / When + const run = runMirrorSequence(['ㅎ', '하', '한'], { commitAtEnd: true }) + + // Then + expect(run.payloads).toEqual(['한']) + expect(run.sentText).toBe('한') + expect(run.heldText).toBe('') + }) + + it('Given multi-syllable composition When a new syllable starts Then streams the stable prefix without erases', () => { + // Given / When + const run = runMirrorSequence(['ㅎ', '하', '한', '한ㄱ', '한그', '한글'], { commitAtEnd: true }) + + // Then + expect(run.payloads).toEqual(['한', '글']) + expect(run.sentText).toBe('한글') + }) + + it('Given dubeolsik resplit 간→가나 When steps run Then never sends the intermediate syllable', () => { + // Given / When + const run = runMirrorSequence(['ㄱ', '가', '간', '가나'], { commitAtEnd: true }) + + // Then + expect(run.payloads).toEqual(['가', '나']) + expect(run.sentText).toBe('가나') + }) + + it('Given a timer-committed syllable When composition continues Then erases and recommits via DEL correction', () => { + // Given: '하' was committed by the settle timer + const commit = computeTerminalLiveMirrorStep('', '하', { commitHeld: true }) + expect(buildTerminalLiveMirrorPayload(commit)).toBe('하') + expect(commit.nextSentText).toBe('하') + + // When: user keeps composing '하' → '한' + const correction = computeTerminalLiveMirrorStep(commit.nextSentText, '한', { + commitHeld: false + }) + + // Then: one DEL erases the stale syllable; the new one is held again + expect(buildTerminalLiveMirrorPayload(correction)).toBe('\x7f') + expect(correction.nextSentText).toBe('') + expect(correction.heldText).toBe('한') + + const recommit = computeTerminalLiveMirrorStep('', '한', { commitHeld: true }) + expect(buildTerminalLiveMirrorPayload(recommit)).toBe('한') + }) + + it('Given pure ASCII typing When steps run Then mirrors immediately with no held text', () => { + // Given / When + const run = runMirrorSequence(['a', 'ab', 'abc']) + + // Then + expect(run.payloads).toEqual(['a', 'b', 'c']) + expect(run.heldText).toBe('') + }) + + it('Given a trailing space after Hangul When the step runs Then the space commits the held syllable', () => { + // Given: '한글' typed, '한' streamed, '글' held + const beforeSpace = runMirrorSequence(['ㅎ', '하', '한', '한ㄱ', '한그', '한글']) + expect(beforeSpace.sentText).toBe('한') + expect(beforeSpace.heldText).toBe('글') + + // When + const step = computeTerminalLiveMirrorStep(beforeSpace.sentText, '한글 ', { + commitHeld: false + }) + + // Then + expect(buildTerminalLiveMirrorPayload(step)).toBe('글 ') + expect(step.heldText).toBe('') + expect(step.nextSentText).toBe('한글 ') + }) + + it('Given a trailing ASCII letter after Hangul When the step runs Then Hangul is committed with the letter', () => { + // Given + const held = computeTerminalLiveMirrorStep('', '한', { commitHeld: false }) + expect(held.heldText).toBe('한') + + // When + const step = computeTerminalLiveMirrorStep(held.nextSentText, '한a', { commitHeld: false }) + + // Then + expect(buildTerminalLiveMirrorPayload(step)).toBe('한a') + expect(step.heldText).toBe('') + }) + + it('Given sent text When the user deletes everything Then erases with one DEL per code point', () => { + // Given / When + const step = computeTerminalLiveMirrorStep('한글a', '', { commitHeld: false }) + + // Then + expect(step).toEqual({ + eraseCount: 3, + appendText: '', + nextSentText: '', + heldText: '' + }) + expect(buildTerminalLiveMirrorPayload(step)).toBe('\x7f\x7f\x7f') + }) + + it('Given empty field and empty sent text When committing Then produces a zero step', () => { + // Given / When + const step = computeTerminalLiveMirrorStep('', '', { commitHeld: true }) + + // Then + expect(buildTerminalLiveMirrorPayload(step)).toBe('') + expect(step).toEqual({ + eraseCount: 0, + appendText: '', + nextSentText: '', + heldText: '' + }) + }) + + it('Given non-Hangul IME text When the step runs Then it mirrors immediately without holding', () => { + // Given / When + const chinese = computeTerminalLiveMirrorStep('', '你好', { commitHeld: false }) + const vietnamese = computeTerminalLiveMirrorStep('', 'tiếng', { commitHeld: false }) + + // Then + expect(buildTerminalLiveMirrorPayload(chinese)).toBe('你好') + expect(chinese.heldText).toBe('') + expect(buildTerminalLiveMirrorPayload(vietnamese)).toBe('tiếng') + expect(vietnamese.heldText).toBe('') + }) + + it('Given Hangul code point ranges When checked Then jamo and syllables match and ASCII does not', () => { + expect(isTerminalLiveHangulCodePoint('ㅎ'.codePointAt(0) ?? 0)).toBe(true) + expect(isTerminalLiveHangulCodePoint('한'.codePointAt(0) ?? 0)).toBe(true) + expect(isTerminalLiveHangulCodePoint('a'.codePointAt(0) ?? 0)).toBe(false) + expect(isTerminalLiveHangulCodePoint('あ'.codePointAt(0) ?? 0)).toBe(false) + }) +}) diff --git a/mobile/src/terminal/terminal-live-hangul-mirror.ts b/mobile/src/terminal/terminal-live-hangul-mirror.ts new file mode 100644 index 00000000000..170d1779ffd --- /dev/null +++ b/mobile/src/terminal/terminal-live-hangul-mirror.ts @@ -0,0 +1,61 @@ +// Why: paused composition should still reach the PTY quickly; corrections make +// a premature commit safe, so this can be short without leaking jamo forever. +export const TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS = 300 + +const TERMINAL_DEL_BYTE = '\x7f' + +export function isTerminalLiveHangulCodePoint(codePoint: number): boolean { + return ( + (codePoint >= 0x1100 && codePoint <= 0x11ff) || + (codePoint >= 0x3130 && codePoint <= 0x318f) || + (codePoint >= 0xa960 && codePoint <= 0xa97f) || + (codePoint >= 0xac00 && codePoint <= 0xd7af) + ) +} + +export type TerminalLiveMirrorStep = { + readonly eraseCount: number + readonly appendText: string + readonly nextSentText: string + readonly heldText: string +} + +// Why: React Native exposes no composition events, but Hangul composition only +// mutates the trailing syllable. Holding just that code point keeps the PTY +// echo live while preedit jamo never leak; DEL corrections repair any commit +// that later turns out to be premature. +export function computeTerminalLiveMirrorStep( + sentText: string, + fieldText: string, + options: { readonly commitHeld: boolean } +): TerminalLiveMirrorStep { + const fieldCodePoints = Array.from(fieldText) + const lastCodePoint = fieldCodePoints.at(-1) + const holdLast = + !options.commitHeld && + lastCodePoint !== undefined && + isTerminalLiveHangulCodePoint(lastCodePoint.codePointAt(0) ?? 0) + const heldText = holdLast && lastCodePoint !== undefined ? lastCodePoint : '' + const targetCodePoints = holdLast ? fieldCodePoints.slice(0, -1) : fieldCodePoints + const sentCodePoints = Array.from(sentText) + + let commonPrefixLength = 0 + while ( + commonPrefixLength < sentCodePoints.length && + commonPrefixLength < targetCodePoints.length && + sentCodePoints[commonPrefixLength] === targetCodePoints[commonPrefixLength] + ) { + commonPrefixLength += 1 + } + + return { + eraseCount: sentCodePoints.length - commonPrefixLength, + appendText: targetCodePoints.slice(commonPrefixLength).join(''), + nextSentText: targetCodePoints.join(''), + heldText + } +} + +export function buildTerminalLiveMirrorPayload(step: TerminalLiveMirrorStep): string { + return TERMINAL_DEL_BYTE.repeat(step.eraseCount) + step.appendText +} diff --git a/mobile/src/terminal/terminal-live-input-affordance.test.ts b/mobile/src/terminal/terminal-live-input-affordance.test.ts new file mode 100644 index 00000000000..1ceab18ab60 --- /dev/null +++ b/mobile/src/terminal/terminal-live-input-affordance.test.ts @@ -0,0 +1,49 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +const sessionRouteSource = readFileSync( + new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url), + 'utf8' +) +const liveInputStatusSource = readFileSync( + new URL('../session/MobileTerminalLiveInputStatus.tsx', import.meta.url), + 'utf8' +) +const commandInputStylesSource = readFileSync( + new URL('../../app/h/[hostId]/session/mobile-session-command-input-styles.ts', import.meta.url), + 'utf8' +) + +function liveInputBarBlock(): string { + const start = sessionRouteSource.indexOf('{liveInputEnabled ? (') + expect(start).toBeGreaterThanOrEqual(0) + const end = sessionRouteSource.indexOf(') : (', start) + expect(end).toBeGreaterThan(start) + return sessionRouteSource.slice(start, end) +} + +describe('terminal live input affordance', () => { + it('keeps the live status row wired as the keyboard focus control', () => { + const block = liveInputBarBlock() + + expect(block).toContain('onPress={focusLiveInput}') + expect(block).toContain('accessibilityRole="button"') + expect(block).toContain('accessibilityLabel="Show keyboard for live terminal input"') + expect(block).toContain( + 'accessibilityHint="Typed text is sent directly to the active terminal"' + ) + expect(block).toContain('pressed && styles.liveInputFocusTargetPressed') + expect(block).toContain('!canSend && styles.liveInputFocusTargetDisabled') + expect(block).toContain('showSoftInputOnFocus') + expect(sessionRouteSource).toContain('focusTerminalLiveInputTarget(liveInputRef.current') + expect(sessionRouteSource).toContain('keyboardHeight') + expect(sessionRouteSource).toContain('scheduleTerminalLiveInputFocus(liveInputFocusTimerRef') + }) + + it('makes the live keyboard target visible instead of status-only chrome', () => { + expect(liveInputStatusSource).toContain("'Tap to show keyboard'") + expect(commandInputStylesSource).toContain('backgroundColor: colors.bgRaised') + expect(commandInputStylesSource).toContain('borderWidth: 1') + expect(commandInputStylesSource).toContain('liveInputFocusTargetPressed') + }) +}) diff --git a/mobile/src/terminal/terminal-live-input.test.ts b/mobile/src/terminal/terminal-live-input.test.ts index 95b4960f928..daca212d4a8 100644 --- a/mobile/src/terminal/terminal-live-input.test.ts +++ b/mobile/src/terminal/terminal-live-input.test.ts @@ -5,10 +5,12 @@ import { clearTerminalLiveInputFocusTimer, defaultTerminalLiveInputHandles, filterTerminalLiveInputDefaultCandidates, + focusTerminalLiveInputTarget, getTerminalLiveSpecialKeyBytes, isTerminalLiveInputWithinByteLimit, pruneTerminalLiveInputHandles, scheduleTerminalLiveInputFocus, + type TerminalLiveInputFocusTarget, type TerminalLiveInputFocusTimerRef } from './terminal-live-input' @@ -16,6 +18,14 @@ function createTimerRef(): TerminalLiveInputFocusTimerRef { return { current: null } } +function createFocusTarget(isFocused: () => boolean): TerminalLiveInputFocusTarget { + return { + blur: vi.fn(), + focus: vi.fn(), + isFocused + } +} + describe('terminal live input', () => { afterEach(() => { vi.useRealTimers() @@ -181,4 +191,37 @@ describe('terminal live input', () => { expect(focus).not.toHaveBeenCalled() expect(timerRef.current).toBeNull() }) + + it('refocuses an already-focused capture input when the keyboard is closed', () => { + const input = createFocusTarget(() => true) + const refocus = vi.fn() + + focusTerminalLiveInputTarget(input, { keyboardHeight: 0, refocus }) + + expect(input.blur).toHaveBeenCalledTimes(1) + expect(input.focus).not.toHaveBeenCalled() + expect(refocus).toHaveBeenCalledTimes(1) + }) + + it('focuses the capture input directly when the keyboard is open', () => { + const input = createFocusTarget(() => true) + const refocus = vi.fn() + + focusTerminalLiveInputTarget(input, { keyboardHeight: 240, refocus }) + + expect(input.blur).not.toHaveBeenCalled() + expect(input.focus).toHaveBeenCalledTimes(1) + expect(refocus).not.toHaveBeenCalled() + }) + + it('focuses the capture input directly when it is not already focused', () => { + const input = createFocusTarget(() => false) + const refocus = vi.fn() + + focusTerminalLiveInputTarget(input, { keyboardHeight: 0, refocus }) + + expect(input.blur).not.toHaveBeenCalled() + expect(input.focus).toHaveBeenCalledTimes(1) + expect(refocus).not.toHaveBeenCalled() + }) }) diff --git a/mobile/src/terminal/terminal-live-input.ts b/mobile/src/terminal/terminal-live-input.ts index 96f279a4855..321226c0c7d 100644 --- a/mobile/src/terminal/terminal-live-input.ts +++ b/mobile/src/terminal/terminal-live-input.ts @@ -66,6 +66,17 @@ export type TerminalLiveInputFocusTimerRef = { current: ReturnType | null } +export type TerminalLiveInputFocusTarget = { + readonly focus: () => void + readonly blur: () => void + readonly isFocused?: () => boolean +} + +type FocusTerminalLiveInputTargetOptions = { + readonly keyboardHeight: number + readonly refocus: () => void +} + export type TerminalLiveInputDefaultResult = { enabledHandles: ReadonlySet defaultedHandles: ReadonlySet @@ -217,4 +228,23 @@ export function scheduleTerminalLiveInputFocus( }, delayMs) } +export function focusTerminalLiveInputTarget( + input: TerminalLiveInputFocusTarget | null, + { keyboardHeight, refocus }: FocusTerminalLiveInputTargetOptions +): void { + if (!input) { + return + } + + if (keyboardHeight <= 0 && input.isFocused?.()) { + // Why: Android can keep a hidden TextInput focused after the IME is dismissed; + // focus() is then a no-op, so force a new focus session to reopen the keyboard. + input.blur() + refocus() + return + } + + input.focus() +} + export { TERMINAL_LIVE_INPUT_MAX_BYTES } diff --git a/mobile/src/terminal/terminal-live-pending-flush-state.test.ts b/mobile/src/terminal/terminal-live-pending-flush-state.test.ts index 27dd4147d09..88918c00681 100644 --- a/mobile/src/terminal/terminal-live-pending-flush-state.test.ts +++ b/mobile/src/terminal/terminal-live-pending-flush-state.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order' import { - queueTerminalLivePendingFlush, + queueTerminalLiveMirrorSend, waitForTerminalLivePendingFlush, type TerminalLivePendingFlushState } from './terminal-live-pending-flush-state' @@ -64,83 +64,54 @@ describe('terminal live pending flush state', () => { 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 () => { +describe('terminal live mirror send queue', () => { + it('Given a failed previous send When a mirror send queues Then it still runs in order', async () => { // Given - const resolvedState: TerminalLivePendingFlushState = { current: null } - const rejectedState: TerminalLivePendingFlushState = { current: null } + const state: TerminalLivePendingFlushState = { current: null } + const order: string[] = [] + const first = queueTerminalLiveMirrorSend(state, async () => { + order.push('first') + return false + }) // When - await expect(queueTerminalLivePendingFlush(resolvedState, async () => true)).resolves.toBe(true) - await expect( - queueTerminalLivePendingFlush(rejectedState, async () => { - throw new Error('send failed') - }) - ).resolves.toBe(false) + const second = queueTerminalLiveMirrorSend(state, async () => { + order.push('second') + return true + }) + + // Then + await expect(first).resolves.toBe(false) + await expect(second).resolves.toBe(true) + expect(order).toEqual(['first', 'second']) + }) + + it('Given a throwing send When a mirror send queues Then the promise resolves false and the chain continues', async () => { + // Given + const state: TerminalLivePendingFlushState = { current: null } + const first = queueTerminalLiveMirrorSend(state, async () => { + throw new Error('boom') + }) + + // When + const second = queueTerminalLiveMirrorSend(state, async () => true) + + // Then + await expect(first).resolves.toBe(false) + await expect(second).resolves.toBe(true) + }) + + it('Given a settled mirror send When it was the newest Then the state resets to null', async () => { + // Given + const state: TerminalLivePendingFlushState = { current: null } + + // When + await queueTerminalLiveMirrorSend(state, async () => true) await Promise.resolve() // Then - expect(resolvedState.current).toBeNull() - expect(rejectedState.current).toBeNull() - }) - - it('Given a current pending snapshot while another flush is in flight When queued Then sends current text after prior success', async () => { - // Given - const events: string[] = [] - let resolveFirstFlush: (value: boolean) => void = () => {} - const firstFlush = new Promise((resolve) => { - resolveFirstFlush = resolve - }) - const state: TerminalLivePendingFlushState = { current: firstFlush } - - // When - const secondFlush = queueTerminalLivePendingFlush(state, async () => { - events.push('second-flush') - return true - }) - const controlSend = sendTerminalLiveControlAfterPendingFlush( - () => waitForTerminalLivePendingFlush(state), - async () => { - events.push('control') - return true - } - ) - await Promise.resolve() - - // Then - expect(events).toEqual([]) - resolveFirstFlush(true) - await expect(secondFlush).resolves.toBe(true) - await expect(controlSend).resolves.toBe(true) - expect(events).toEqual(['second-flush', 'control']) - }) - - it('Given a prior pending flush fails When another snapshot is queued Then skips the current text and control', async () => { - // Given - const events: string[] = [] - let resolveFirstFlush: (value: boolean) => void = () => {} - const firstFlush = new Promise((resolve) => { - resolveFirstFlush = resolve - }) - const state: TerminalLivePendingFlushState = { current: firstFlush } - - // When - const secondFlush = queueTerminalLivePendingFlush(state, async () => { - events.push('second-flush') - return true - }) - const controlSend = sendTerminalLiveControlAfterPendingFlush( - () => waitForTerminalLivePendingFlush(state), - async () => { - events.push('control') - return true - } - ) - resolveFirstFlush(false) - - // Then - await expect(secondFlush).resolves.toBe(false) - await expect(controlSend).resolves.toBe(false) - expect(events).toEqual([]) + expect(state.current).toBeNull() }) }) diff --git a/mobile/src/terminal/terminal-live-pending-flush-state.ts b/mobile/src/terminal/terminal-live-pending-flush-state.ts index ea760fcee54..405198964f3 100644 --- a/mobile/src/terminal/terminal-live-pending-flush-state.ts +++ b/mobile/src/terminal/terminal-live-pending-flush-state.ts @@ -8,22 +8,26 @@ export function waitForTerminalLivePendingFlush( return state.current ?? Promise.resolve(true) } -export function queueTerminalLivePendingFlush( +// Why: mirror payloads are erase/append deltas against the PTY echo. A skipped +// delta desyncs every later diff, so this chain runs each send even when the +// previous one failed. state.current should never reject; the catch keeps a +// future raw assignment from skipping a delta. +export function queueTerminalLiveMirrorSend( state: TerminalLivePendingFlushState, - sendPendingText: () => Promise + sendMirrorPayload: () => Promise ): Promise { - const previousFlush = state.current - const flushPromise = (async () => { - if (previousFlush && !(await previousFlush)) { - return false + const previousSend = state.current + const sendPromise = (async () => { + if (previousSend) { + await previousSend.catch(() => false) } - return sendPendingText() + return sendMirrorPayload() })().catch(() => false) - state.current = flushPromise - void flushPromise.then(() => { - if (state.current === flushPromise) { + state.current = sendPromise + void sendPromise.then(() => { + if (state.current === sendPromise) { state.current = null } }) - return flushPromise + return sendPromise } diff --git a/mobile/src/terminal/terminal-live-text-commit.test.ts b/mobile/src/terminal/terminal-live-text-commit.test.ts index 00435fb2230..a1206645f25 100644 --- a/mobile/src/terminal/terminal-live-text-commit.test.ts +++ b/mobile/src/terminal/terminal-live-text-commit.test.ts @@ -1,234 +1,118 @@ import { describe, expect, it } from 'vitest' import { - TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS, getTerminalLiveAccessoryBytesDecision, getTerminalLiveAccessoryLocalEditText, - getTerminalLiveDeferredTextDelayMs, - getTerminalLiveSpecialKeyDecision, - getTerminalLiveSubmitSequence, - getTerminalLiveTextChangeDecision, - isTerminalLiveTextHangulCandidate, - isTerminalLiveTextImeCandidate + getTerminalLiveSpecialKeyDecision } 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 +describe('terminal live special key decision', () => { + it('Given an unmapped key Then ignores it', () => { + expect(getTerminalLiveSpecialKeyDecision({ key: 'ㅎ', heldText: '', sentText: '' })).toEqual({ + kind: 'ignore' }) - 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 Backspace with any field text Then edits locally so the mirror diff handles the PTY erase', () => { + // Held syllable present + expect( + getTerminalLiveSpecialKeyDecision({ key: 'Backspace', heldText: '한', sentText: '' }) + ).toEqual({ kind: 'local-edit' }) + // Only mirrored text present — native edit fires onChangeText and the diff erases + expect( + getTerminalLiveSpecialKeyDecision({ key: 'Backspace', heldText: '', sentText: 'abc' }) + ).toEqual({ kind: 'local-edit' }) }) - 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 + it('Given Backspace with an empty field Then sends terminal backspace bytes', () => { + const decision = getTerminalLiveSpecialKeyDecision({ + key: 'Backspace', + heldText: '', + sentText: '' }) - 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() + expect(decision.kind).toBe('send-now') }) - 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 control key with a held syllable Then commits the held text before the bytes', () => { + const decision = getTerminalLiveSpecialKeyDecision({ + key: 'Tab', + heldText: '글', + sentText: '한' + }) + expect(decision.kind).toBe('commit-held-then-send') }) - it('Given a non-special key When key decision is requested Then ignores it', () => { - // Given - const key = 'a' + it('Given a control key with no held syllable Then sends immediately', () => { + const decision = getTerminalLiveSpecialKeyDecision({ + key: 'ArrowUp', + heldText: '', + sentText: 'ls' + }) + expect(decision.kind).toBe('send-now') + }) +}) + +describe('terminal live accessory bytes decision', () => { + it('Given a local-edit accessory key with field text Then edits locally', () => { + expect( + getTerminalLiveAccessoryBytesDecision({ + bytes: '\x7f', + localEdit: 'backspace', + heldText: '한', + sentText: '' + }) + ).toEqual({ kind: 'local-edit', localEdit: 'backspace' }) + expect( + getTerminalLiveAccessoryBytesDecision({ + bytes: '\x7f', + localEdit: 'backspace', + heldText: '', + sentText: 'abc' + }) + ).toEqual({ kind: 'local-edit', localEdit: 'backspace' }) + }) - // When - const decision = getTerminalLiveSpecialKeyDecision({ key, pendingText: '한' }) + it('Given raw accessory bytes with a held syllable Then commits held text first', () => { + const decision = getTerminalLiveAccessoryBytesDecision({ + bytes: '\x1b', + heldText: '한', + sentText: '' + }) + expect(decision).toEqual({ kind: 'commit-held-then-send', bytes: '\x1b' }) + }) - // Then - expect(decision).toEqual({ kind: 'ignore' }) + it('Given raw accessory bytes with nothing held Then sends immediately', () => { + const decision = getTerminalLiveAccessoryBytesDecision({ + bytes: '\x1b', + heldText: '', + sentText: 'abc' + }) + expect(decision).toEqual({ kind: 'send-now', bytes: '\x1b' }) }) - it('Given no pending text When submit is requested Then sends only carriage return', () => { - // Given - const pendingText = '' + it('Given a local-edit accessory key with an empty field Then sends the raw bytes', () => { + const decision = getTerminalLiveAccessoryBytesDecision({ + bytes: '\x7f', + localEdit: 'backspace', + heldText: '', + sentText: '' + }) + expect(decision).toEqual({ kind: 'send-now', bytes: '\x7f' }) + }) +}) - // When - const sequence = getTerminalLiveSubmitSequence(pendingText) +describe('terminal live accessory local edit text', () => { + it('Given backspace Then drops the last code point of the field text', () => { + expect( + getTerminalLiveAccessoryLocalEditText({ localEdit: 'backspace', fieldText: '한글' }) + ).toBe('한') + expect(getTerminalLiveAccessoryLocalEditText({ localEdit: 'backspace', fieldText: '한' })).toBe( + '' + ) + }) - // Then - expect(sequence).toEqual(['\r']) + it('Given forward delete Then keeps the field text unchanged', () => { + expect(getTerminalLiveAccessoryLocalEditText({ localEdit: 'delete', fieldText: '한글' })).toBe( + '한글' + ) }) }) diff --git a/mobile/src/terminal/terminal-live-text-commit.ts b/mobile/src/terminal/terminal-live-text-commit.ts index aac74b8931b..77752e0775f 100644 --- a/mobile/src/terminal/terminal-live-text-commit.ts +++ b/mobile/src/terminal/terminal-live-text-commit.ts @@ -1,23 +1,15 @@ 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 } + | { readonly kind: 'commit-held-then-send'; readonly bytes: string } export type TerminalLiveSpecialKeyDecisionInput = { readonly key: string - readonly pendingText: string + readonly heldText: string + readonly sentText: string } export type TerminalLiveAccessoryLocalEdit = 'backspace' | 'delete' @@ -25,78 +17,33 @@ 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 } + | { readonly kind: 'commit-held-then-send'; 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 + readonly heldText: string + readonly sentText: string } export function getTerminalLiveSpecialKeyDecision({ key, - pendingText + heldText, + sentText }: TerminalLiveSpecialKeyDecisionInput): TerminalLiveSpecialKeyDecision { const bytes = getTerminalLiveSpecialKeyBytes(key) if (bytes === null) { return { kind: 'ignore' } } - if (pendingText.length > 0 && (key === 'Backspace' || key === 'Delete')) { + // Why: native field edits fire onChangeText and the mirror diff emits the + // matching PTY erase; sending raw DEL here as well would double-erase. + if ((key === 'Backspace' || key === 'Delete') && (heldText.length > 0 || sentText.length > 0)) { return { kind: 'local-edit' } } - if (pendingText.length > 0) { - return { kind: 'flush-then-send', pendingText, bytes } + if (heldText.length > 0) { + return { kind: 'commit-held-then-send', bytes } } return { kind: 'send-now', bytes } @@ -105,14 +52,15 @@ export function getTerminalLiveSpecialKeyDecision({ export function getTerminalLiveAccessoryBytesDecision({ bytes, localEdit, - pendingText + heldText, + sentText }: TerminalLiveAccessoryBytesDecisionInput): TerminalLiveAccessoryBytesDecision { - if (pendingText.length > 0 && localEdit) { + if (localEdit && (heldText.length > 0 || sentText.length > 0)) { return { kind: 'local-edit', localEdit } } - if (pendingText.length > 0) { - return { kind: 'flush-then-send', pendingText, bytes } + if (heldText.length > 0) { + return { kind: 'commit-held-then-send', bytes } } return { kind: 'send-now', bytes } @@ -120,26 +68,16 @@ export function getTerminalLiveAccessoryBytesDecision({ export function getTerminalLiveAccessoryLocalEditText({ localEdit, - pendingText + fieldText }: { readonly localEdit: TerminalLiveAccessoryLocalEdit - readonly pendingText: string + readonly fieldText: 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 + // it stays local but does not remove the field text. + return fieldText } - 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'] + return Array.from(fieldText).slice(0, -1).join('') } diff --git a/mobile/src/terminal/terminal-webview-contract.ts b/mobile/src/terminal/terminal-webview-contract.ts new file mode 100644 index 00000000000..aff86cecad2 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-contract.ts @@ -0,0 +1,64 @@ +import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types' +import type { TerminalOscLinkRange } from './terminal-osc-link-ranges' + +type TerminalMouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any' + +export type TerminalModes = { + bracketedPasteMode: boolean + altScreen: boolean + mouseTrackingMode: TerminalMouseTrackingMode + sgrMouseMode: boolean + sgrMousePixelsMode: boolean +} + +export type TerminalKeyboardAvoidanceMetrics = { + cursorY: number + rows: number + altScreen: boolean +} + +export type MobileTerminalTheme = RuntimeMobileTerminalTheme + +export type TerminalSelectionEvents = { + onSelectionMode?: (active: boolean) => void + onSelectionCopy?: (text: string) => void + onSelectionEvicted?: () => void + onModesChanged?: (modes: TerminalModes) => void + onKeyboardAvoidanceMetrics?: (metrics: TerminalKeyboardAvoidanceMetrics) => void + onHaptic?: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void + onTerminalInput?: (bytes: string) => void + onTerminalTap?: () => void + // Tap landed on a detected file path; RN resolves + opens it. + onFileTap?: (pathText: string, line: number | null, column: number | null) => void + // WebView-detected URL tap; RN chooses the mobile routing destination. + onOpenUrl?: (url: string) => void + // Why: pinch-to-zoom in the terminal snaps to a text-size preset and reports it + // here so the app persists it and keeps Settings + other panes in sync. + onTextScaleChange?: (scale: number) => void +} + +export type TerminalWebViewHandle = { + write: (data: string) => void + init: ( + cols: number, + rows: number, + initialData?: string, + preserveScroll?: boolean, + oscLinks?: TerminalOscLinkRange[] + ) => void + resize: (cols: number, rows: number) => void + // Why: reflow the local xterm buffer (scrollback included) to a new width + // after a server-side PTY reflow, so older wrapped lines rewrap to match the + // latest output. No-op on the alternate screen. + reflow: (cols: number, rows: number) => void + clear: () => void + measureFitDimensions: (containerHeight?: number) => Promise<{ cols: number; rows: number } | null> + resetZoom: () => void + cancelSelect: () => void + doSelectAll: () => void + // Why: lets callers await the WebView-side `init` rAF chain (term.open + // → renderService population → first paint) so a follow-up measure + // doesn't race ahead and find term=null or cellWidth=0. Resolves on + // the next 'ready' notify after the most recent init. + awaitReady: () => Promise +} diff --git a/mobile/src/terminal/terminal-webview-engine-error-state.tsx b/mobile/src/terminal/terminal-webview-engine-error-state.tsx new file mode 100644 index 00000000000..0ba3e79db8d --- /dev/null +++ b/mobile/src/terminal/terminal-webview-engine-error-state.tsx @@ -0,0 +1,130 @@ +import { useCallback, useState } from 'react' +import { RefreshCw } from 'lucide-react-native' +import { Pressable, StyleSheet, Text, View } from 'react-native' +import { colors } from '../theme/mobile-theme' + +export type NativeWebViewEngineEvent = { + readonly nativeEvent?: object +} + +type TerminalWebViewEngineErrorOverlayProps = { + readonly message: string + readonly onReload: () => void +} + +type NativeWebViewEngineFields = { + readonly description?: unknown + readonly code?: unknown + readonly statusCode?: unknown + readonly domain?: unknown + readonly didCrash?: unknown +} + +export function useTerminalWebViewEngineErrorState(onEngineError?: (message: string) => void) { + const [engineError, setEngineError] = useState(null) + const clearEngineError = useCallback(() => setEngineError(null), []) + const reportEngineError = useCallback( + (message: string, fatal: boolean) => { + onEngineError?.(message) + // eslint-disable-next-line no-console + console.warn('[terminal-webview] engine error', message) + if (fatal) { + // Why: the first fatal report is the root cause; later cascades (e.g. the + // web-ready watchdog firing after a process-crash report) must not + // overwrite its more specific diagnostics. clearEngineError resets. + setEngineError((previous) => previous ?? message) + } + }, + [onEngineError] + ) + const reportNativeEngineError = useCallback( + (context: string, event?: NativeWebViewEngineEvent) => { + reportEngineError(describeNativeWebViewEngineError(context, event), true) + }, + [reportEngineError] + ) + return { clearEngineError, engineError, reportEngineError, reportNativeEngineError } +} + +export function describeNativeWebViewEngineError( + context: string, + event?: NativeWebViewEngineEvent +): string { + const native = event?.nativeEvent as NativeWebViewEngineFields | undefined + const parts = [context] + const description = native?.description + const statusCode = native?.statusCode + const code = native?.code + const domain = native?.domain + if (typeof description === 'string') { + parts.push(description) + } + if (typeof statusCode === 'number') { + parts.push(`status ${statusCode}`) + } + if (typeof code === 'number') { + parts.push(`code ${code}`) + } + if (typeof domain === 'string') { + parts.push(domain) + } + if (native?.didCrash === true) { + parts.push('renderer crashed') + } + return parts.join(' - ') +} + +export function TerminalWebViewEngineErrorOverlay({ + message, + onReload +}: TerminalWebViewEngineErrorOverlayProps) { + return ( + + Terminal failed to load + + {message} + + + + Reload + + + ) +} + +const styles = StyleSheet.create({ + errorOverlay: { + ...StyleSheet.absoluteFillObject, + alignItems: 'center', + justifyContent: 'center', + gap: 12, + padding: 24, + backgroundColor: colors.terminalBg + }, + errorTitle: { + color: colors.textPrimary, + fontSize: 16, + fontWeight: '700', + textAlign: 'center' + }, + errorDetail: { + color: colors.textSecondary, + fontSize: 13, + lineHeight: 18, + textAlign: 'center' + }, + reloadButton: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + minHeight: 36, + paddingHorizontal: 14, + borderRadius: 6, + backgroundColor: colors.surfaceBright + }, + reloadButtonText: { + color: colors.terminalBg, + fontSize: 14, + fontWeight: '700' + } +}) diff --git a/mobile/src/terminal/terminal-webview-engine-error.test.ts b/mobile/src/terminal/terminal-webview-engine-error.test.ts new file mode 100644 index 00000000000..82267967b7c --- /dev/null +++ b/mobile/src/terminal/terminal-webview-engine-error.test.ts @@ -0,0 +1,178 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TerminalWebView } from './TerminalWebView' + +vi.mock('react-native', () => ({ + AppState: { currentState: 'active' }, + Pressable: 'Pressable', + StyleSheet: { + absoluteFillObject: { + bottom: 0, + left: 0, + position: 'absolute', + right: 0, + top: 0 + }, + create: (styles: unknown) => styles + }, + Text: 'Text', + View: 'View' +})) + +vi.mock('react-native-webview', () => ({ + WebView: 'WebView', + default: 'WebView' +})) + +vi.mock('lucide-react-native', () => ({ + RefreshCw: 'RefreshCw' +})) + +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() +} + +// Why: mounting TerminalWebView arms the web-ready watchdog; tests must +// unmount so the timer can't survive into later tests and fire in teardown. +let activeRenderer: ReactTestRenderer | null = null + +function createTerminalWebViewRenderer(onEngineError = vi.fn()) { + let renderer: ReactTestRenderer | null = null + const restoreConsoleError = suppressReactTestRendererDeprecationWarning() + try { + act(() => { + renderer = create(createElement(TerminalWebView, { onEngineError })) + }) + } finally { + restoreConsoleError() + } + if (!renderer) { + throw new Error('TerminalWebView did not render') + } + activeRenderer = renderer + return { onEngineError, renderer } +} + +function postWebViewMessage(renderer: ReactTestRenderer, payload: Record) { + const webView = renderer.root.findByType('WebView') + act(() => { + webView.props.onMessage({ nativeEvent: { data: JSON.stringify(payload) } }) + }) +} + +function renderedText(renderer: ReactTestRenderer): string { + return renderer.root + .findAllByType('Text') + .flatMap((node) => node.props.children) + .join(' ') +} + +describe('TerminalWebView engine errors', () => { + afterEach(() => { + if (activeRenderer) { + act(() => { + activeRenderer?.unmount() + }) + activeRenderer = null + } + vi.restoreAllMocks() + }) + + it('renders the reload overlay for fatal engine errors from the WebView', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { onEngineError, renderer } = createTerminalWebViewRenderer() + + postWebViewMessage(renderer, { + fatal: true, + message: 'terminal engine missing - SyntaxError - Chrome 74', + type: 'error' + }) + + expect(onEngineError).toHaveBeenCalledWith('terminal engine missing - SyntaxError - Chrome 74') + expect(renderedText(renderer)).toContain('Terminal failed to load') + expect(renderedText(renderer)).toContain('terminal engine missing - SyntaxError - Chrome 74') + expect(renderedText(renderer)).toContain('Reload') + }) + + it('reports non-fatal engine errors without covering a live terminal', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { onEngineError, renderer } = createTerminalWebViewRenderer() + + postWebViewMessage(renderer, { + fatal: false, + message: 'terminal message failed - malformed chunk', + type: 'error' + }) + + expect(onEngineError).toHaveBeenCalledWith('terminal message failed - malformed chunk') + expect(renderedText(renderer)).not.toContain('Terminal failed to load') + }) + + it('keeps the first fatal diagnostics when later fatal reports cascade', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { renderer } = createTerminalWebViewRenderer() + + postWebViewMessage(renderer, { + fatal: true, + message: 'terminal engine missing - root cause', + type: 'error' + }) + postWebViewMessage(renderer, { + fatal: true, + message: 'Terminal did not initialize - watchdog cascade', + type: 'error' + }) + + expect(renderedText(renderer)).toContain('terminal engine missing - root cause') + expect(renderedText(renderer)).not.toContain('watchdog cascade') + }) + + // Why: if the document dies before the glue can post anything (or the RN + // bridge never comes up), no message and no native error handler fires — + // the watchdog must convert that silence into the visible fatal overlay. + it('paints the fatal overlay when web-ready never arrives', () => { + vi.useFakeTimers() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const { onEngineError, renderer } = createTerminalWebViewRenderer() + + act(() => { + vi.advanceTimersByTime(15000) + }) + + expect(onEngineError).toHaveBeenCalledWith( + 'Terminal did not initialize - no ready signal from the terminal view' + ) + expect(renderedText(renderer)).toContain('Terminal failed to load') + } finally { + vi.useRealTimers() + } + }) + + it('does not fire the watchdog once web-ready has arrived', () => { + vi.useFakeTimers() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const { onEngineError, renderer } = createTerminalWebViewRenderer() + + postWebViewMessage(renderer, { type: 'web-ready' }) + act(() => { + vi.advanceTimersByTime(60000) + }) + + expect(onEngineError).not.toHaveBeenCalled() + expect(renderedText(renderer)).not.toContain('Terminal failed to load') + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/mobile/src/terminal/terminal-webview-engine.test.ts b/mobile/src/terminal/terminal-webview-engine.test.ts new file mode 100644 index 00000000000..36c8c039493 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-engine.test.ts @@ -0,0 +1,109 @@ +import { readFileSync } from 'node:fs' +import { Script } from 'node:vm' +import { parse } from 'acorn' +import { describe, expect, it } from 'vitest' +import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated' +import { XTERM_HTML } from './terminal-webview-html' + +const terminalHtmlSource = readFileSync( + new URL('./terminal-webview-html.ts', import.meta.url), + 'utf8' +) + +describe('terminal WebView bundled engine', () => { + it('keeps the assembled terminal HTML free of external engine URLs', () => { + expect(XTERM_HTML).not.toMatch(/\bhttps?:\/\//) + expect(XTERM_HTML).not.toContain('cdn.jsdelivr.net') + expect(XTERM_HTML).not.toContain(' - + +