Merge remote-tracking branch 'origin/main' into nwparker/codex-broken-status

This commit is contained in:
Neil
2026-07-03 12:12:19 -07:00
342 changed files with 31996 additions and 2300 deletions
+3
View File
@@ -9,6 +9,9 @@ on:
- ready_for_review
paths:
- 'mobile/**'
# Why: the mobile terminal link parsers are conformance-tested against
# these shared fixtures; desktop-side fixture edits must re-run this suite.
- 'src/shared/terminal-file-link-conformance.ts'
jobs:
verify:
+9 -10
View File
@@ -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,10 @@ 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 * * *'
permissions:
contents: write
@@ -182,8 +176,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 }}
@@ -889,6 +883,11 @@ jobs:
if: matrix.platform == 'win'
shell: pwsh
run: |
# Why: Some hosted Windows images start without PSGallery registered.
if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue)) {
Register-PSRepository -Default -InstallationPolicy Trusted
}
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
$trimChars = [char[]]@([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)
+2 -2
View File
@@ -36,7 +36,7 @@
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.18](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.21](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
Pair with your desktop app to monitor and steer your agents from your phone.
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [Download APK 0.0.18](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk)
- **Android:** [Download APK 0.0.21](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk)
---
@@ -194,6 +194,10 @@ describe('Electron runtime package contract', () => {
expect(installStep.if).toBe("matrix.platform == 'win'")
expect(installStep.shell).toBe('pwsh')
expect(installRun).toContain(
'if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue))'
)
expect(installRun).toContain('Register-PSRepository -Default -InstallationPolicy Trusted')
expect(installRun).toContain('Set-PSRepository -Name PSGallery -InstallationPolicy Trusted')
expect(installRun).toMatch(/\$env:PSModulePath -split \[System\.IO\.Path\]::PathSeparator/)
expect(installRun).toContain(
@@ -217,6 +221,23 @@ describe('Electron runtime package contract', () => {
expect(installRun).not.toMatch(/throw\s+\$_/)
})
it('temporarily allows publishing Windows after verifying the signed installer only', () => {
const releaseWorkflow = readFileSync(
join(projectDir, '.github/workflows/release-cut.yml'),
'utf8'
)
const parsedWorkflow = parse(releaseWorkflow)
const steps = parsedWorkflow.jobs.build.steps
const stepNames = steps.map((step) => step.name)
const outerVerifyIndex = stepNames.indexOf('Verify signed Windows installer')
const innerVerifyIndex = stepNames.indexOf('Verify signed Windows inner executable')
const publishIndex = stepNames.indexOf('Publish signed Windows release artifacts')
expect(outerVerifyIndex).toBeGreaterThan(-1)
expect(innerVerifyIndex).toBe(-1)
expect(publishIndex).toBe(outerVerifyIndex + 1)
})
it('publishes both Linux release matrix entries', () => {
const releaseWorkflow = readFileSync(
join(projectDir, '.github/workflows/release-cut.yml'),
@@ -0,0 +1,196 @@
#!/usr/bin/env node
import { mkdirSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import {
createWslFixture,
listWslDistros,
removeWslFixture
} from './windows-apphang-repro/wsl-workspace-fixture.mjs'
import { runGpuMode } from './windows-apphang-repro/terminal-activation-scenario.mjs'
import { summarizeResult } from './windows-apphang-repro/apphang-report-summary.mjs'
const defaultCycles = 14
const defaultOutputLines = 1_600
function parseArgs() {
const args = {
cycles: defaultCycles,
distro: null,
expect: 'none',
gpuModes: ['on'],
keep: false,
outputLines: defaultOutputLines,
reportPath: null,
sourceControl: true,
deadPtyReactivate: true
}
for (const arg of process.argv.slice(2)) {
if (arg === '--help' || arg === '-h') {
printHelp()
process.exit(0)
}
if (arg === '--keep') {
args.keep = true
continue
}
if (arg === '--no-source-control') {
args.sourceControl = false
continue
}
if (arg === '--no-dead-pty-reactivate') {
args.deadPtyReactivate = false
continue
}
const [name, value] = arg.split('=', 2)
if (name === '--cycles') {
args.cycles = parsePositiveInt(name, value)
continue
}
if (name === '--distro') {
args.distro = value?.trim() || null
continue
}
if (name === '--expect') {
if (!['none', 'repro', 'pass'].includes(value)) {
throw new Error(`Unsupported --expect=${value}. Use none, repro, or pass.`)
}
args.expect = value
continue
}
if (name === '--gpu') {
const modes = (value ?? '')
.split(',')
.map((entry) => entry.trim())
.filter(Boolean)
if (modes.length === 0 || modes.some((mode) => !['on', 'off', 'auto'].includes(mode))) {
throw new Error(`Unsupported --gpu=${value}. Use on, off, auto, or a comma-list.`)
}
args.gpuModes = modes
continue
}
if (name === '--output-lines') {
args.outputLines = parsePositiveInt(name, value)
continue
}
if (name === '--report') {
args.reportPath = value?.trim() || null
if (!args.reportPath) {
throw new Error('--report requires a file path.')
}
continue
}
throw new Error(`Unknown argument: ${arg}`)
}
return args
}
function printHelp() {
console.log(`Usage:
node config/scripts/repro-windows-apphang-terminal-activation.mjs [options]
Options:
--expect=none|repro|pass none prints measurements, repro exits 0 only when hang evidence is observed,
pass exits 1 if hang evidence is observed. Default: none.
--gpu=on|off|auto[,mode...] Terminal GPU setting(s) to run. Default: on.
--cycles=N Activation/output cycles per GPU mode. Default: ${defaultCycles}.
--output-lines=N Lines emitted by each terminal stress command. Default: ${defaultOutputLines}.
--report=PATH Write full JSON evidence to PATH and print a compact summary to stdout.
--distro=NAME WSL distro to use. Default: first non docker-desktop distro.
--no-source-control Do not open Source Control during the stress loop.
--no-dead-pty-reactivate Do not kill PTYs and revisit workspaces after initial activation.
--keep Keep disposable WSL/userData fixtures after the run.`)
}
function parsePositiveInt(name, value) {
const parsed = Number.parseInt(value ?? '', 10)
if (!Number.isInteger(parsed) || parsed <= 0 || String(parsed) !== value) {
throw new Error(`${name} requires a positive integer.`)
}
return parsed
}
async function main() {
if (process.platform !== 'win32') {
throw new Error('This repro harness is intentionally Windows-only.')
}
const args = parseArgs()
const distros = listWslDistros()
const distro = args.distro ?? distros[0]
if (!distro) {
throw new Error('No user WSL distro found. Install/enable WSL or pass --distro=NAME.')
}
console.log(
`[apphang-repro] issue=https://github.com/stablyai/orca/issues/6874 distro=${distro} gpuModes=${args.gpuModes.join(',')} cycles=${args.cycles}`
)
const fixture = createWslFixture(distro)
console.log(
`[apphang-repro] fixture repo=${fixture.repoUncPath} plain=${fixture.plainUncPath} base=${fixture.baseLinuxPath}`
)
const results = []
try {
for (const gpuMode of args.gpuModes) {
results.push(await runGpuMode(gpuMode, args, fixture))
if (args.expect === 'repro' && results.at(-1)?.reproduced) {
break
}
}
} finally {
if (!args.keep) {
removeWslFixture(fixture)
}
}
const payload = {
issue: 'https://github.com/stablyai/orca/issues/6874',
expect: args.expect,
distro,
fixture: args.keep ? fixture : { removed: true, baseLinuxPath: fixture.baseLinuxPath },
summary: results.map(summarizeResult),
results
}
if (args.reportPath) {
const reportPath = path.resolve(args.reportPath)
mkdirSync(path.dirname(reportPath), { recursive: true })
writeFileSync(reportPath, `${JSON.stringify(payload, null, 2)}\n`)
console.log(JSON.stringify({ reportPath, summary: payload.summary }, null, 2))
} else {
console.log(JSON.stringify(payload, null, 2))
}
// Why: a broken harness run (CDP/setup/selector failure) proves nothing —
// it must fail both --expect=repro and --expect=pass rather than being
// miscounted as hang evidence or as a clean pass.
const harnessErrors = results.filter((result) => result.harnessError)
if (harnessErrors.length > 0) {
const harnessErrorLines = harnessErrors
.map((result) => ` gpu=${result.gpuMode}: ${result.harnessError}`)
.join('\n')
console.error(
`[apphang-repro] Harness failed in ${harnessErrors.length} run(s); result inconclusive:\n${harnessErrorLines}`
)
if (args.expect !== 'none') {
process.exit(1)
}
}
const reproduced = results.some((result) => result.reproduced)
if (args.expect === 'repro' && !reproduced) {
console.error(
'[apphang-repro] Expected to reproduce the hang, but no hang evidence was observed.'
)
process.exit(1)
}
if (args.expect === 'pass' && reproduced) {
console.error('[apphang-repro] Expected a clean pass, but hang evidence was observed.')
process.exit(1)
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error))
process.exit(1)
})
@@ -0,0 +1,206 @@
import { spawnSync } from 'node:child_process'
import { existsSync, statSync } from 'node:fs'
import { pathToFileURL } from 'node:url'
export const DEFAULT_EXPECTED_SIGNER =
'CN=SignPath Foundation, O=SignPath Foundation, L=Lewes, S=Delaware, C=US'
const POWERSHELL_SIGNATURE_SCRIPT = String.raw`
$ErrorActionPreference = 'Stop'
$signature = Get-AuthenticodeSignature -FilePath $env:ORCA_WINDOWS_INNER_EXECUTABLE
$certificate = $signature.SignerCertificate
[pscustomobject]@{
status = $signature.Status.ToString()
statusMessage = $signature.StatusMessage
signerSubject = if ($null -eq $certificate) { $null } else { $certificate.Subject }
signerIssuer = if ($null -eq $certificate) { $null } else { $certificate.Issuer }
signerThumbprint = if ($null -eq $certificate) { $null } else { $certificate.Thumbprint }
notBefore = if ($null -eq $certificate) { $null } else { $certificate.NotBefore.ToString('o') }
notAfter = if ($null -eq $certificate) { $null } else { $certificate.NotAfter.ToString('o') }
} | ConvertTo-Json -Compress
`
export function normalizeSignerSubject(subject) {
if (typeof subject !== 'string') {
return ''
}
return subject
.split(',')
.map((part) => part.trim().replace(/\s*=\s*/u, '='))
.filter(Boolean)
.join(', ')
}
export function normalizeThumbprint(thumbprint) {
if (typeof thumbprint !== 'string') {
return ''
}
return thumbprint.replace(/[^0-9a-f]/giu, '').toUpperCase()
}
export function parseExpectedSigners(value = process.env.ORCA_WINDOWS_EXPECTED_SIGNERS) {
const source = typeof value === 'string' && value.trim() !== '' ? value : DEFAULT_EXPECTED_SIGNER
return source
.split(/[\r\n;]+/u)
.map(normalizeSignerSubject)
.filter(Boolean)
}
export function parseExpectedThumbprints(value = process.env.ORCA_WINDOWS_EXPECTED_THUMBPRINTS) {
if (typeof value !== 'string' || value.trim() === '') {
return []
}
return value
.split(/[\r\n,;]+/u)
.map(normalizeThumbprint)
.filter(Boolean)
}
export function parseSignatureJson(stdout) {
const trimmed = typeof stdout === 'string' ? stdout.trim() : ''
if (trimmed === '') {
throw new Error('PowerShell did not return signature JSON.')
}
try {
return JSON.parse(trimmed)
} catch (error) {
throw new Error(`PowerShell returned malformed signature JSON: ${error.message}`)
}
}
export function classifySignature(signature, options = {}) {
const expectedSigners = options.expectedSigners ?? parseExpectedSigners()
const expectedThumbprints = options.expectedThumbprints ?? parseExpectedThumbprints()
const status = typeof signature?.status === 'string' ? signature.status : ''
const signerSubject = normalizeSignerSubject(signature?.signerSubject)
const signerThumbprint = normalizeThumbprint(signature?.signerThumbprint)
const subjectAllowed = expectedSigners.includes(signerSubject)
const thumbprintAllowed =
expectedThumbprints.length > 0 &&
signerThumbprint !== '' &&
expectedThumbprints.includes(signerThumbprint)
if (status !== 'Valid') {
return {
ok: false,
message: `Windows inner executable signature status is ${status || '<missing>'}.`,
signature
}
}
if (!subjectAllowed && !thumbprintAllowed) {
return {
ok: false,
message: `Unexpected Windows inner executable signer: ${signerSubject || '<missing>'}.`,
signature
}
}
return { ok: true, signature }
}
export function formatSignatureSummary(signature) {
return [
`Status: ${signature.status ?? '<missing>'}`,
`Subject: ${normalizeSignerSubject(signature.signerSubject) || '<missing>'}`,
`Issuer: ${signature.signerIssuer ?? '<missing>'}`,
`Thumbprint: ${normalizeThumbprint(signature.signerThumbprint) || '<missing>'}`,
`NotBefore: ${signature.notBefore ?? '<missing>'}`,
`NotAfter: ${signature.notAfter ?? '<missing>'}`
].join('\n')
}
export function validateExecutablePath(executablePath) {
if (typeof executablePath !== 'string' || executablePath.trim() === '') {
throw new Error('Usage: node config/scripts/verify-windows-inner-signature.mjs <Orca.exe>')
}
if (!existsSync(executablePath)) {
throw new Error(`Windows inner executable does not exist: ${executablePath}`)
}
if (!statSync(executablePath).isFile()) {
throw new Error(`Windows inner executable path is not a file: ${executablePath}`)
}
}
export function getPowerShellSignatureJson(executablePath, spawnSyncImpl = spawnSync) {
// Why: pwsh -Command does not reliably expose trailing process args to string commands.
const result = spawnSyncImpl(
'pwsh',
[
'-NoLogo',
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-Command',
POWERSHELL_SIGNATURE_SCRIPT
],
{
encoding: 'utf8',
env: {
...process.env,
ORCA_WINDOWS_INNER_EXECUTABLE: executablePath
}
}
)
if (result.error) {
throw result.error
}
if (result.stderr?.trim()) {
throw new Error(`PowerShell wrote to stderr while checking signature:\n${result.stderr.trim()}`)
}
if (result.status !== 0) {
throw new Error(
`PowerShell signature check failed with exit code ${result.status ?? '<unknown>'}.`
)
}
return result.stdout
}
export function verifyWindowsInnerSignature({
executablePath,
platform = process.platform,
spawnSyncImpl = spawnSync,
expectedSigners = parseExpectedSigners(),
expectedThumbprints = parseExpectedThumbprints()
}) {
validateExecutablePath(executablePath)
if (platform !== 'win32') {
throw new Error('Windows inner executable signature verification requires Windows.')
}
const signature = parseSignatureJson(getPowerShellSignatureJson(executablePath, spawnSyncImpl))
const classification = classifySignature(signature, { expectedSigners, expectedThumbprints })
if (!classification.ok) {
throw new Error(`${classification.message}\n${formatSignatureSummary(signature)}`)
}
return signature
}
export function main(argv = process.argv.slice(2)) {
try {
const signature = verifyWindowsInnerSignature({ executablePath: argv[0] })
console.log('Verified Windows inner executable signature.')
console.log(formatSignatureSummary(signature))
} catch (error) {
console.error(error.message)
process.exitCode = 1
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main()
}
@@ -0,0 +1,218 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
DEFAULT_EXPECTED_SIGNER,
classifySignature,
getPowerShellSignatureJson,
normalizeSignerSubject,
normalizeThumbprint,
parseExpectedSigners,
parseExpectedThumbprints,
parseSignatureJson,
validateExecutablePath,
verifyWindowsInnerSignature
} from './verify-windows-inner-signature.mjs'
const validSignature = {
status: 'Valid',
statusMessage: 'Signature verified.',
signerSubject: DEFAULT_EXPECTED_SIGNER,
signerIssuer: 'CN=SignPath Foundation Root',
signerThumbprint: 'aa bb cc dd',
notBefore: '2026-01-01T00:00:00.0000000Z',
notAfter: '2027-01-01T00:00:00.0000000Z'
}
function withTempFile(callback) {
const dir = mkdtempSync(join(tmpdir(), 'orca-inner-signature-'))
const filePath = join(dir, 'Orca.exe')
writeFileSync(filePath, 'placeholder executable')
try {
return callback(filePath, dir)
} finally {
rmSync(dir, { force: true, recursive: true })
}
}
describe('verify-windows-inner-signature', () => {
const originalExpectedSigners = process.env.ORCA_WINDOWS_EXPECTED_SIGNERS
const originalExpectedThumbprints = process.env.ORCA_WINDOWS_EXPECTED_THUMBPRINTS
beforeEach(() => {
delete process.env.ORCA_WINDOWS_EXPECTED_SIGNERS
delete process.env.ORCA_WINDOWS_EXPECTED_THUMBPRINTS
})
afterEach(() => {
if (originalExpectedSigners === undefined) {
delete process.env.ORCA_WINDOWS_EXPECTED_SIGNERS
} else {
process.env.ORCA_WINDOWS_EXPECTED_SIGNERS = originalExpectedSigners
}
if (originalExpectedThumbprints === undefined) {
delete process.env.ORCA_WINDOWS_EXPECTED_THUMBPRINTS
} else {
process.env.ORCA_WINDOWS_EXPECTED_THUMBPRINTS = originalExpectedThumbprints
}
})
it('normalizes signer subjects without widening exact matching', () => {
expect(
normalizeSignerSubject(
' CN = SignPath Foundation , O=SignPath Foundation,L=Lewes,S=Delaware,C=US '
)
).toBe(DEFAULT_EXPECTED_SIGNER)
expect(
normalizeSignerSubject(
'CN=Different Signer, O=SignPath Foundation, L=Lewes, S=Delaware, C=US'
)
).not.toBe(DEFAULT_EXPECTED_SIGNER)
})
it('defaults the expected signer allowlist to SignPath Foundation', () => {
expect(parseExpectedSigners('')).toEqual([DEFAULT_EXPECTED_SIGNER])
expect(parseExpectedSigners(' ')).toEqual([DEFAULT_EXPECTED_SIGNER])
})
it('parses semicolon and newline separated signer allowlists', () => {
expect(parseExpectedSigners(`${DEFAULT_EXPECTED_SIGNER};\nCN=Backup, O=Backup`)).toEqual([
DEFAULT_EXPECTED_SIGNER,
'CN=Backup, O=Backup'
])
})
it('normalizes optional thumbprint allowlists', () => {
expect(normalizeThumbprint('aa bb:cc')).toBe('AABBCC')
expect(parseExpectedThumbprints('aa bb cc,11:22:33')).toEqual(['AABBCC', '112233'])
})
it('rejects missing, nonexistent, and directory executable paths before PowerShell', () => {
expect(() => validateExecutablePath('')).toThrow(/Usage:/)
expect(() => validateExecutablePath(join(tmpdir(), 'missing-Orca.exe'))).toThrow(
/does not exist/
)
withTempFile((filePath, dir) => {
expect(() => validateExecutablePath(dir)).toThrow(/not a file/)
expect(() => validateExecutablePath(filePath)).not.toThrow()
})
})
it('parses the exact JSON emitted by PowerShell', () => {
expect(parseSignatureJson(JSON.stringify(validSignature))).toEqual(validSignature)
expect(() => parseSignatureJson('')).toThrow(/did not return/)
expect(() => parseSignatureJson(`${JSON.stringify(validSignature)}\nextra`)).toThrow(
/malformed/
)
})
it('accepts a valid signature with an exact normalized signer subject', () => {
const result = classifySignature({
...validSignature,
signerSubject: ' CN=SignPath Foundation, O=SignPath Foundation, L=Lewes, S=Delaware, C=US '
})
expect(result.ok).toBe(true)
})
it('rejects invalid status and unexpected signer subjects', () => {
expect(classifySignature({ ...validSignature, status: 'NotSigned' }).message).toMatch(
/status is NotSigned/
)
expect(
classifySignature({
...validSignature,
signerSubject: 'CN=SignPath Foundation Test, O=SignPath Foundation, L=Vienna, C=AT'
}).message
).toMatch(/Unexpected Windows inner executable signer/)
})
it('accepts an expected thumbprint as an alternate explicit allowlist', () => {
expect(classifySignature({ ...validSignature, signerThumbprint: '00' }).ok).toBe(true)
expect(
classifySignature(
{ ...validSignature, signerSubject: 'CN=Rotated Signer, O=Rotated' },
{
expectedSigners: [DEFAULT_EXPECTED_SIGNER],
expectedThumbprints: ['AABBCCDD']
}
).ok
).toBe(true)
expect(
classifySignature(
{ ...validSignature, signerSubject: 'CN=Rotated Signer, O=Rotated' },
{
expectedSigners: [DEFAULT_EXPECTED_SIGNER],
expectedThumbprints: ['001122']
}
).message
).toMatch(/Unexpected Windows inner executable signer/)
expect(
classifySignature(validSignature, {
expectedSigners: [DEFAULT_EXPECTED_SIGNER],
expectedThumbprints: ['001122']
}).ok
).toBe(true)
})
it('runs PowerShell with an argument array and fails on stderr or nonzero exit', () => {
const calls = []
const spawnSyncImpl = (command, args, options) => {
calls.push({ command, args, options })
return { status: 0, stdout: JSON.stringify(validSignature), stderr: '' }
}
expect(getPowerShellSignatureJson('C:\\Path With Spaces\\Orca.exe', spawnSyncImpl)).toBe(
JSON.stringify(validSignature)
)
expect(calls[0].command).toBe('pwsh')
expect(calls[0].args).toContain('-Command')
expect(calls[0].args.at(-1)).not.toBe('C:\\Path With Spaces\\Orca.exe')
expect(calls[0].options).toEqual(
expect.objectContaining({
encoding: 'utf8',
env: expect.objectContaining({
ORCA_WINDOWS_INNER_EXECUTABLE: 'C:\\Path With Spaces\\Orca.exe'
})
})
)
expect(() =>
getPowerShellSignatureJson('Orca.exe', () => ({ status: 0, stdout: '{}', stderr: 'warning' }))
).toThrow(/stderr/)
expect(() =>
getPowerShellSignatureJson('Orca.exe', () => ({ status: 7, stdout: '', stderr: '' }))
).toThrow(/exit code 7/)
})
it('verifies with injected Windows platform and spawn implementation', () => {
withTempFile((filePath) => {
const signature = verifyWindowsInnerSignature({
executablePath: filePath,
platform: 'win32',
spawnSyncImpl: () => ({ status: 0, stdout: JSON.stringify(validSignature), stderr: '' })
})
expect(signature).toEqual(validSignature)
})
})
it('does not attempt real Authenticode verification outside Windows', () => {
withTempFile((filePath) => {
expect(() =>
verifyWindowsInnerSignature({
executablePath: filePath,
platform: 'linux',
spawnSyncImpl: () => {
throw new Error('should not spawn')
}
})
).toThrow(/requires Windows/)
})
})
})
@@ -0,0 +1,77 @@
import { existsSync, readFileSync } from 'node:fs'
import path from 'node:path'
export function summarizeTrace(userDataDir) {
const tracePath = path.join(userDataDir, 'logs', 'main.trace.ndjson')
if (!existsSync(tracePath)) {
return { tracePath, exists: false }
}
const lines = readFileSync(tracePath, 'utf8').trim().split(/\r?\n/).filter(Boolean)
const parsed = []
for (const line of lines.slice(-400)) {
try {
parsed.push(JSON.parse(line))
} catch {
parsed.push({ raw: line })
}
}
const interesting = parsed.filter((entry) => {
const name = String(entry.name ?? entry.event ?? entry.type ?? '')
const text = JSON.stringify(entry)
return (
name.includes('git.exec') ||
name.includes('renderer_memory') ||
name.includes('sidebar_worktree_activate') ||
text.includes('git.exec remote') ||
text.includes('git.exec worktree') ||
text.includes('sidebar_worktree_activate')
)
})
return {
tracePath,
exists: true,
totalLines: lines.length,
interestingTail: interesting.slice(-40)
}
}
export function summarizeAppLogs(logs) {
const webglContextWarnings = logs.filter((entry) =>
entry.line.includes('Too many active WebGL contexts')
)
const webglContextLosses = logs.filter((entry) =>
entry.line.toLowerCase().includes('webgl context lost')
)
const gpuProcessEvents = logs.filter((entry) => /gpu|d3d|angle/i.test(entry.line))
return {
lineCount: logs.length,
webglContextWarningCount: webglContextWarnings.length,
webglContextWarningsTail: webglContextWarnings.slice(-10),
webglContextLossCount: webglContextLosses.length,
webglContextLossesTail: webglContextLosses.slice(-10),
gpuProcessEventCount: gpuProcessEvents.length,
gpuProcessEventsTail: gpuProcessEvents.slice(-20)
}
}
export function summarizeResult(result) {
const samples = result.samples.filter((sample) => sample && typeof sample === 'object')
const maxActivationMs = Math.max(0, ...samples.map((sample) => sample.activationMs ?? 0))
const maxPtyWaitMs = Math.max(0, ...samples.map((sample) => sample.ptyWaitMs ?? 0))
const maxMainIpcMs = Math.max(0, ...samples.map((sample) => sample.mainIpcMs ?? 0))
const maxRendererDriftMs = Math.max(0, ...samples.map((sample) => sample.rendererMaxDriftMs ?? 0))
return {
gpuMode: result.gpuMode,
reproduced: result.reproduced,
reproductionReason: result.reproductionReason,
harnessError: result.harnessError ?? null,
elapsedMs: result.elapsedMs,
sampleCount: samples.length,
maxActivationMs,
maxPtyWaitMs,
maxMainIpcMs,
maxRendererDriftMs,
finalWebglContextCounts: result.finalDiagnostics?.webglContextCounts ?? null,
appLogSummary: result.appLogSummary
}
}
@@ -0,0 +1,331 @@
import { chromium } from '@playwright/test'
import { spawn } from 'node:child_process'
import { mkdtempSync } from 'node:fs'
import net from 'node:net'
import os from 'node:os'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import {
appShutdownTimeoutMs,
cdpPollTimeoutMs,
delay,
pollUntil,
rendererActionTimeoutMs,
runWithTimeout
} from './repro-timing.mjs'
import { createCompletedOnboardingProfile } from './wsl-workspace-fixture.mjs'
const rootDir = path.resolve(fileURLToPath(new URL('../../..', import.meta.url)))
function isPortFree(port) {
return new Promise((resolve) => {
const server = net.createServer()
server.once('error', () => {
try {
server.close()
} catch {}
resolve(false)
})
server.once('listening', () => server.close(() => resolve(true)))
server.listen(port, '127.0.0.1')
})
}
export async function pickFreePort() {
for (let port = 9533; port < 9633; port += 1) {
if (await isPortFree(port)) {
return port
}
}
throw new Error('Could not find a free CDP port in 9533..9632.')
}
export function createGpuUserDataDirectory(gpuMode) {
const userDataDir = mkdtempSync(path.join(os.tmpdir(), `orca-apphang-${gpuMode}-userdata-`))
createCompletedOnboardingProfile(userDataDir)
return userDataDir
}
export function launchDevApp({ cdpPort, userDataDir }) {
const env = { ...process.env }
delete env.ELECTRON_RUN_AS_NODE
Object.assign(env, {
ELECTRON_ENABLE_LOGGING: '1',
ELECTRON_ENABLE_STACK_DUMPING: '1',
NODE_ENV: 'development',
ORCA_DEV_USER_DATA_PATH: userDataDir,
ORCA_SKIP_DEV_WEB_PREPARE: '1',
ORCA_STARTUP_DIAGNOSTICS: '1',
REMOTE_DEBUGGING_PORT: String(cdpPort),
VITE_EXPOSE_STORE: 'true'
})
const child = spawn(
process.execPath,
[path.join('config', 'scripts', 'run-electron-vite-dev.mjs')],
{
cwd: rootDir,
env,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
}
)
const logs = []
const collect = (source) => (chunk) => {
const text = chunk.toString()
for (const line of text.split(/\r?\n/).filter(Boolean)) {
logs.push({ source, line, at: Date.now() })
console.log(`[apphang-repro:${source}] ${line}`)
}
}
child.stdout?.on('data', collect('stdout'))
child.stderr?.on('data', collect('stderr'))
return { child, logs }
}
export async function stopDevApp(child) {
if (!child?.pid || child.exitCode !== null || child.signalCode !== null) {
return
}
await new Promise((resolve) => {
const timer = setTimeout(resolve, appShutdownTimeoutMs)
child.once('exit', () => {
clearTimeout(timer)
resolve()
})
if (process.platform === 'win32') {
const killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
stdio: 'ignore',
windowsHide: true
})
killer.once('exit', () => undefined)
return
}
child.kill('SIGTERM')
})
}
async function waitForCdp(port) {
const url = `http://127.0.0.1:${port}/json`
const startedAt = Date.now()
while (Date.now() - startedAt < cdpPollTimeoutMs) {
try {
const response = await fetch(url)
if (response.ok) {
const targets = await response.json()
if (Array.isArray(targets) && targets.some((target) => target.type)) {
return targets
}
}
} catch {}
await delay(500)
}
throw new Error(`Timed out waiting for CDP targets on ${url}`)
}
async function getMainPage(browser) {
const startedAt = Date.now()
while (Date.now() - startedAt < cdpPollTimeoutMs) {
for (const context of browser.contexts()) {
const pages = context.pages()
const page =
pages.find((candidate) =>
/^https?:\/\/127\.0\.0\.1:|^https?:\/\/localhost:/.test(candidate.url())
) ?? pages[0]
if (page) {
return page
}
}
await delay(250)
}
throw new Error('Timed out waiting for the Electron renderer page.')
}
export async function connectToApp(cdpPort) {
await waitForCdp(cdpPort)
const browser = await chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`)
const page = await getMainPage(browser)
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 })
return { browser, page }
}
export async function installRendererProbe(page) {
await page.evaluate(() => {
if (globalThis.__orcaApphangProbe) {
return
}
const probe = {
intervalMs: 50,
last: performance.now(),
maxDriftMs: 0,
samples: 0,
startedAt: performance.now(),
lastTickAt: performance.now()
}
const timer = setInterval(() => {
const now = performance.now()
const drift = Math.max(0, now - probe.last - probe.intervalMs)
probe.maxDriftMs = Math.max(probe.maxDriftMs, drift)
probe.last = now
probe.lastTickAt = now
probe.samples += 1
}, probe.intervalMs)
globalThis.__orcaApphangProbe = { probe, timer }
})
}
export async function waitForStoreReady(page) {
await pollUntil(
'renderer store exposure',
() => page.evaluate(() => Boolean(window.__store && window.api)),
Boolean,
30_000
)
await pollUntil(
'workspace session hydration',
() =>
page.evaluate(() => {
const state = window.__store?.getState?.()
return Boolean(state?.workspaceSessionReady && state?.hydrationSucceeded)
}),
Boolean,
45_000
)
}
export async function collectRendererDiagnostics(page) {
if (!page) {
return null
}
try {
return await runWithTimeout(
'renderer diagnostics',
() =>
page.evaluate(async () => {
const timed = (label, promise) =>
Promise.race([
Promise.resolve(promise).then(
(value) => ({ value }),
(error) => ({ error: error instanceof Error ? error.message : String(error) })
),
new Promise((resolve) =>
setTimeout(() => resolve({ error: `Timed out collecting ${label}` }), 1_000)
)
])
const readWebglIdentity = () => {
const canvas = document.createElement('canvas')
let gl = null
try {
gl = canvas.getContext('webgl2') ?? canvas.getContext('webgl')
if (!gl) {
return { available: false, vendor: null, renderer: null }
}
const debugInfo = gl.getExtension('WEBGL_debug_renderer_info')
if (!debugInfo) {
return { available: true, vendor: null, renderer: null }
}
return {
available: true,
vendor: String(gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL) ?? '') || null,
renderer: String(gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) ?? '') || null
}
} catch (error) {
return {
available: false,
error: error instanceof Error ? error.message : String(error)
}
} finally {
try {
gl?.getExtension('WEBGL_lose_context')?.loseContext()
} catch {}
canvas.width = 0
canvas.height = 0
}
}
const allPaneManagersDiagnostics = Array.from(
window.__paneManagers?.entries?.() ?? []
).map(([managerTabId, paneManager]) => ({
tabId: managerTabId,
diagnostics: paneManager?.getRenderingDiagnostics?.() ?? []
}))
const webglContextCounts = allPaneManagersDiagnostics.reduce(
(acc, entry) => {
acc.managerCount += 1
acc.paneCount += entry.diagnostics.length
for (const diagnostic of entry.diagnostics) {
if (diagnostic.hasWebgl) {
acc.attachedWebglCount += 1
}
if (diagnostic.webglAttachmentDeferred) {
acc.deferredWebglCount += 1
}
}
return acc
},
{ attachedWebglCount: 0, deferredWebglCount: 0, managerCount: 0, paneCount: 0 }
)
const state = window.__store?.getState?.()
const worktreeId = state?.activeWorktreeId ?? null
const tabId =
state?.activeTabType === 'terminal'
? state.activeTabId
: worktreeId
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
: null
const manager = tabId ? window.__paneManagers?.get(tabId) : null
const activePane = manager?.getActivePane?.() ?? manager?.getPanes?.()?.[0] ?? null
const buffer = activePane?.terminal?.buffer?.active ?? null
return {
hasStore: Boolean(window.__store),
workspaceSessionReady: state?.workspaceSessionReady ?? null,
hydrationSucceeded: state?.hydrationSucceeded ?? null,
activeView: state?.activeView ?? null,
activeRepoId: state?.activeRepoId ?? null,
activeWorktreeId: worktreeId,
activeTabType: state?.activeTabType ?? null,
activeTabId: tabId,
terminalGpuAcceleration: state?.settings?.terminalGpuAcceleration ?? null,
repoCount: state?.repos?.length ?? null,
worktreeCountsByRepo: Object.fromEntries(
Object.entries(state?.worktreesByRepo ?? {}).map(([repoId, worktrees]) => [
repoId,
worktrees.length
])
),
ptyIdsByTabId: tabId ? (state?.ptyIdsByTabId?.[tabId] ?? []) : [],
paneManagerCount: window.__paneManagers?.size ?? null,
activePane: activePane
? {
id: activePane.id ?? null,
leafId: activePane.leafId ?? null,
ptyId: activePane.container?.dataset?.ptyId ?? null,
cols: activePane.terminal?.cols ?? null,
rows: activePane.terminal?.rows ?? null,
buffer: buffer
? {
baseY: buffer.baseY,
viewportY: buffer.viewportY,
cursorY: buffer.cursorY,
length: buffer.length
}
: null
}
: null,
renderingDiagnostics: manager?.getRenderingDiagnostics?.() ?? null,
allPaneManagersDiagnostics,
webglContextCounts,
webglIdentity: readWebglIdentity(),
rendererProbe: globalThis.__orcaApphangProbe?.probe ?? null,
ptySessions: await timed('PTY sessions', window.api?.pty?.listSessions?.()),
rendererDeliveryDebug: await timed(
'renderer delivery debug',
window.api?.pty?.getRendererDeliveryDebugSnapshot?.()
)
}
}),
rendererActionTimeoutMs
)
} catch (error) {
return { error: error instanceof Error ? error.message : String(error) }
}
}
@@ -0,0 +1,47 @@
export const cdpPollTimeoutMs = 90_000
export const rendererActionTimeoutMs = 5_000
export const activationTimeoutMs = 15_000
export const ptyWaitTimeoutMs = 20_000
export const terminalMarkerTimeoutMs = 45_000
export const setupTimeoutMs = 90_000
export const appShutdownTimeoutMs = 8_000
export const severeRendererDriftMs = 2_000
export const severeActivationMs = 5_000
export const severeMainIpcMs = 2_000
export const severePtyWaitMs = 10_000
export function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
export async function runWithTimeout(label, action, timeoutMs) {
const actionPromise = Promise.resolve().then(action)
actionPromise.catch(() => undefined)
const result = await Promise.race([
actionPromise.then(
(value) => ({ timedOut: false, value }),
(error) => ({ timedOut: false, error })
),
delay(timeoutMs).then(() => ({ timedOut: true }))
])
if (result.timedOut) {
throw new Error(`Timed out during ${label} after ${timeoutMs}ms.`)
}
if ('error' in result) {
throw result.error
}
return result.value
}
export async function pollUntil(label, read, predicate, timeoutMs, intervalMs = 100) {
const startedAt = Date.now()
let lastValue = null
while (Date.now() - startedAt < timeoutMs) {
lastValue = await runWithTimeout(label, read, rendererActionTimeoutMs)
if (predicate(lastValue)) {
return lastValue
}
await delay(intervalMs)
}
throw new Error(`Timed out waiting for ${label}; last value: ${JSON.stringify(lastValue)}`)
}
@@ -0,0 +1,486 @@
import {
collectRendererDiagnostics,
connectToApp,
createGpuUserDataDirectory,
installRendererProbe,
launchDevApp,
pickFreePort,
stopDevApp,
waitForStoreReady
} from './electron-dev-session.mjs'
import { summarizeAppLogs, summarizeTrace } from './apphang-report-summary.mjs'
import {
activationTimeoutMs,
pollUntil,
ptyWaitTimeoutMs,
rendererActionTimeoutMs,
runWithTimeout,
setupTimeoutMs,
severeActivationMs,
severeMainIpcMs,
severePtyWaitMs,
severeRendererDriftMs,
terminalMarkerTimeoutMs
} from './repro-timing.mjs'
import { safeRemoveLocalDirectory } from './wsl-workspace-fixture.mjs'
class ReproductionObservedError extends Error {
constructor(message, evidence) {
super(message)
this.name = 'ReproductionObservedError'
this.evidence = evidence
}
}
async function setupAppFixture(page, fixture, gpuMode, sourceControl) {
return await runWithTimeout(
'fixture registration in Orca',
() =>
page.evaluate(
async ({ repoPath, plainPath, importedWorktreePaths, mode, openSourceControl }) => {
const store = window.__store
if (!store) {
throw new Error('window.__store is unavailable.')
}
const state = store.getState()
await state.fetchSettings?.()
await store.getState().updateSettings({ terminalGpuAcceleration: mode })
const addResult = await window.api.repos.add({ path: repoPath, kind: 'git' })
if ('error' in addResult) {
throw new Error(addResult.error)
}
await store.getState().fetchRepos()
let nextState = store.getState()
const repo =
nextState.repos.find((candidate) => candidate.path === repoPath) ?? addResult.repo
await nextState.updateRepo(repo.id, {
externalWorktreeVisibility: 'show',
externalWorktreeVisibilityPromptDismissedAt: Date.now(),
importedExternalWorktreePaths: importedWorktreePaths,
externalWorktreeInboxBaselinePaths: importedWorktreePaths
})
await store.getState().fetchWorktrees(repo.id, { requireAuthoritative: true })
const plainRepo = await store.getState().addNonGitFolder(plainPath)
if (!plainRepo) {
throw new Error('addNonGitFolder returned null.')
}
await store.getState().fetchWorktrees(plainRepo.id, { requireAuthoritative: true })
nextState = store.getState()
nextState.setSidebarOpen(true)
nextState.setGroupBy('none')
nextState.setSortBy('recent')
nextState.setShowActiveOnly(false)
nextState.setActiveView('terminal')
if (openSourceControl) {
nextState.setRightSidebarOpen(true)
nextState.setRightSidebarTab('source-control')
}
const gitWorktrees = nextState.worktreesByRepo[repo.id] ?? []
const plainWorktrees = nextState.worktreesByRepo[plainRepo.id] ?? []
return {
repoId: repo.id,
repoPath: repo.path,
plainRepoId: plainRepo.id,
gitWorktrees: gitWorktrees.map((worktree) => ({
id: worktree.id,
path: worktree.path,
displayName: worktree.displayName,
branch: worktree.branch,
isMainWorktree: worktree.isMainWorktree
})),
plainWorktrees: plainWorktrees.map((worktree) => ({
id: worktree.id,
path: worktree.path,
displayName: worktree.displayName,
branch: worktree.branch,
isMainWorktree: worktree.isMainWorktree
}))
}
},
{
repoPath: fixture.repoUncPath,
plainPath: fixture.plainUncPath,
importedWorktreePaths: fixture.worktreeUncPaths,
mode: gpuMode,
openSourceControl: sourceControl
}
),
setupTimeoutMs
)
}
async function clickWorktreeCard(page, worktreeId) {
const rect = await runWithTimeout(
`locate worktree card ${worktreeId}`,
() =>
page.evaluate((id) => {
const rows = Array.from(document.querySelectorAll('[data-worktree-id]'))
const row = rows.find((candidate) => candidate.getAttribute('data-worktree-id') === id)
if (!row) {
return null
}
row.scrollIntoView({ block: 'center', inline: 'nearest' })
const surface = row.querySelector('[data-worktree-card-surface="true"]') ?? row
const bounds = surface.getBoundingClientRect()
if (bounds.width <= 0 || bounds.height <= 0) {
return null
}
return {
x: bounds.left + bounds.width / 2,
y: bounds.top + bounds.height / 2,
width: bounds.width,
height: bounds.height
}
}, worktreeId),
rendererActionTimeoutMs
)
if (!rect) {
throw new Error(`Could not find rendered worktree card for ${worktreeId}`)
}
await runWithTimeout(
`click worktree card ${worktreeId}`,
() => page.mouse.click(rect.x, rect.y),
rendererActionTimeoutMs
)
}
async function waitForActiveWorktree(page, worktreeId) {
return await pollUntil(
`active worktree ${worktreeId}`,
() =>
page.evaluate((id) => {
const state = window.__store?.getState?.()
return {
activeWorktreeId: state?.activeWorktreeId ?? null,
activeTabId: state?.activeTabId ?? null,
activeTabType: state?.activeTabType ?? null,
tabs: state?.tabsByWorktree?.[id]?.map((tab) => tab.id) ?? []
}
}, worktreeId),
(value) => value?.activeWorktreeId === worktreeId && value.activeTabType === 'terminal',
activationTimeoutMs
)
}
async function waitForActivePty(page, worktreeId) {
const startedAt = Date.now()
const value = await pollUntil(
`active PTY for ${worktreeId}`,
() =>
page.evaluate((id) => {
const state = window.__store?.getState?.()
const tabId =
state?.activeWorktreeId === id && state.activeTabType === 'terminal'
? state.activeTabId
: (state?.activeTabIdByWorktree?.[id] ?? null)
const manager = tabId ? window.__paneManagers?.get(tabId) : null
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()?.[0] ?? null
const ptyId = pane?.container?.dataset?.ptyId ?? null
return {
tabId,
ptyId,
hasManager: Boolean(manager),
paneCount: manager?.getPanes?.()?.length ?? 0,
ptyIdsByTab: tabId ? (state?.ptyIdsByTabId?.[tabId] ?? []) : []
}
}, worktreeId),
(value) => Boolean(value?.ptyId),
ptyWaitTimeoutMs
)
return { ...value, waitMs: Date.now() - startedAt }
}
function makeOutputCommand(marker, outputLines) {
const payload = 'x'.repeat(180)
return `printf '${marker}_START\\n'; i=1; while [ "$i" -le ${outputLines} ]; do printf '${marker}_%05d ${payload}\\n' "$i"; i=$((i+1)); done; printf '${marker}_DONE\\n'\r`
}
async function getTerminalContent(page, charLimit = 20_000) {
return await runWithTimeout(
'terminal content',
() =>
page.evaluate((limit) => {
const state = window.__store?.getState?.()
const worktreeId = state?.activeWorktreeId
const tabId =
state?.activeTabType === 'terminal'
? state.activeTabId
: worktreeId
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
: null
const manager = tabId ? window.__paneManagers?.get(tabId) : null
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()?.[0] ?? null
return (pane?.serializeAddon?.serialize?.() ?? '').slice(-limit)
}, charLimit),
rendererActionTimeoutMs
)
}
async function sendOutputStress(page, ptyId, marker, outputLines) {
await runWithTimeout(
`write terminal stress ${marker}`,
() =>
page.evaluate(
({ id, command }) => {
window.api.pty.write(id, command)
},
{ id: ptyId, command: makeOutputCommand(marker, outputLines) }
),
rendererActionTimeoutMs
)
await pollUntil(
`terminal stress marker ${marker}`,
async () => (await getTerminalContent(page)).includes(`${marker}_DONE`),
Boolean,
terminalMarkerTimeoutMs,
200
)
}
async function pingMainIpc(page) {
const startedAt = Date.now()
await runWithTimeout(
'main IPC ping',
() => page.evaluate(() => window.api.pty.listSessions()),
rendererActionTimeoutMs
)
return Date.now() - startedAt
}
async function readRendererProbe(page) {
return await runWithTimeout(
'renderer probe read',
() => page.evaluate(() => globalThis.__orcaApphangProbe?.probe ?? null),
rendererActionTimeoutMs
)
}
async function killActivePty(page) {
return await runWithTimeout(
'kill active PTY',
() =>
page.evaluate(async () => {
const state = window.__store?.getState?.()
const tabId = state?.activeTabId ?? null
const ptyIds = tabId ? (state?.ptyIdsByTabId?.[tabId] ?? []) : []
const ptyId = ptyIds[0] ?? null
if (!ptyId) {
return null
}
await window.api.pty.kill(ptyId)
return ptyId
}),
rendererActionTimeoutMs
)
}
async function resetRendererProbe(page) {
// Why: probe drift is cumulative since installation; without a per-cycle
// reset, one startup/setup stall gets re-reported as a hang in every
// later cycle's rendererMaxDriftMs check.
await runWithTimeout(
'renderer probe reset',
() =>
page.evaluate(() => {
const probe = globalThis.__orcaApphangProbe?.probe
if (!probe) {
return
}
const now = performance.now()
probe.last = now
probe.lastTickAt = now
probe.startedAt = now
probe.maxDriftMs = 0
probe.samples = 0
}),
rendererActionTimeoutMs
)
}
async function runActivationCycle(page, target, args) {
await resetRendererProbe(page)
const marker = `ORCA_APPHANG_${target.index}_${Date.now()}`
const cycleStartedAt = Date.now()
const activationStartedAt = Date.now()
await clickWorktreeCard(page, target.id)
const activation = await waitForActiveWorktree(page, target.id)
const activationMs = Date.now() - activationStartedAt
const activePty = await waitForActivePty(page, target.id)
const diagnosticsBeforeOutput = await collectRendererDiagnostics(page)
const outputStartedAt = Date.now()
await sendOutputStress(page, activePty.ptyId, marker, args.outputLines)
const outputMs = Date.now() - outputStartedAt
const mainIpcMs = await pingMainIpc(page)
const rendererProbe = await readRendererProbe(page)
const diagnosticsAfterOutput = await collectRendererDiagnostics(page)
const elapsedMs = Date.now() - cycleStartedAt
const sample = {
index: target.index,
kind: target.kind,
worktreeId: target.id,
displayName: target.displayName,
elapsedMs,
activationMs,
outputMs,
activation,
ptyWaitMs: activePty.waitMs,
ptyId: activePty.ptyId,
tabId: activePty.tabId,
mainIpcMs,
rendererMaxDriftMs: rendererProbe?.maxDriftMs ?? null,
renderingDiagnostics: diagnosticsAfterOutput?.renderingDiagnostics ?? null,
webglIdentity: diagnosticsAfterOutput?.webglIdentity ?? null,
diagnosticsBeforeOutput,
diagnosticsAfterOutput
}
const reproducedReasons = []
if (activationMs > severeActivationMs) {
reproducedReasons.push(`worktree activation took ${activationMs}ms`)
}
if (activePty.waitMs > severePtyWaitMs) {
reproducedReasons.push(`PTY binding took ${activePty.waitMs}ms`)
}
if (mainIpcMs > severeMainIpcMs) {
reproducedReasons.push(`main IPC ping took ${mainIpcMs}ms`)
}
if ((rendererProbe?.maxDriftMs ?? 0) > severeRendererDriftMs) {
reproducedReasons.push(`renderer timer drift reached ${Math.round(rendererProbe.maxDriftMs)}ms`)
}
if (reproducedReasons.length > 0) {
throw new ReproductionObservedError(reproducedReasons.join('; '), sample)
}
return sample
}
async function runDeadPtyReactivation(page, targets, args) {
const samples = []
for (const target of targets) {
await clickWorktreeCard(page, target.id)
await waitForActiveWorktree(page, target.id)
await waitForActivePty(page, target.id)
const killedPtyId = await killActivePty(page)
samples.push({ worktreeId: target.id, killedPtyId })
}
for (const target of targets) {
samples.push(
await runActivationCycle(page, { ...target, kind: `${target.kind}:dead-pty` }, args)
)
}
return samples
}
function selectTargets(setupResult, cycles) {
const gitWorktrees = setupResult.gitWorktrees
.filter((worktree) => worktree.path)
.sort((a, b) => Number(b.isMainWorktree) - Number(a.isMainWorktree))
.slice(0, 5)
.map((worktree) => ({ ...worktree, kind: 'git-wsl' }))
const plainWorktrees = setupResult.plainWorktrees.map((worktree) => ({
...worktree,
kind: 'plain-folder-wsl'
}))
const baseTargets = [...gitWorktrees, ...plainWorktrees]
if (baseTargets.length === 0) {
throw new Error('No worktrees were discovered for the repro fixture.')
}
return Array.from({ length: cycles }, (_, index) => ({
...baseTargets[index % baseTargets.length],
index: index + 1
}))
}
export async function runGpuMode(gpuMode, args, fixture) {
const cdpPort = await pickFreePort()
const userDataDir = createGpuUserDataDirectory(gpuMode)
const launched = launchDevApp({ cdpPort, userDataDir })
let browser = null
let page = null
const startedAt = Date.now()
const result = {
gpuMode,
reproduced: false,
reproductionReason: null,
harnessError: null,
startedAt,
elapsedMs: null,
cdpPort,
userDataDir,
appPid: launched.child.pid ?? null,
setup: null,
samples: [],
finalDiagnostics: null,
trace: null,
appLogSummary: null,
appLogsTail: null,
cleanupErrors: []
}
try {
const connected = await connectToApp(cdpPort)
browser = connected.browser
page = connected.page
await waitForStoreReady(page)
await installRendererProbe(page)
const identity = await runWithTimeout(
'app identity',
() => page.evaluate(() => window.api.app.getIdentity?.()),
rendererActionTimeoutMs
).catch(() => null)
console.log(
`[apphang-repro] connected gpu=${gpuMode} pid=${result.appPid} identity=${JSON.stringify(identity)}`
)
result.setup = await setupAppFixture(page, fixture, gpuMode, args.sourceControl)
const targets = selectTargets(result.setup, args.cycles)
console.log(
`[apphang-repro] targets gpu=${gpuMode}: ${targets
.map((target) => `${target.kind}:${target.displayName}`)
.join(', ')}`
)
for (const target of targets) {
console.log(`[apphang-repro] cycle=${target.index} gpu=${gpuMode} kind=${target.kind}`)
result.samples.push(await runActivationCycle(page, target, args))
}
if (args.deadPtyReactivate) {
const uniqueTargets = []
const seen = new Set()
for (const target of targets) {
if (!seen.has(target.id)) {
seen.add(target.id)
uniqueTargets.push(target)
}
}
console.log(`[apphang-repro] dead-pty-reactivation gpu=${gpuMode}`)
result.samples.push(...(await runDeadPtyReactivation(page, uniqueTargets, args)))
}
} catch (error) {
if (error instanceof ReproductionObservedError) {
result.reproduced = true
result.reproductionReason = error.message
result.samples.push(error.evidence)
} else {
// Why: setup/CDP/selector failures are inconclusive, not hang evidence.
// Conflating them with `reproduced` lets --expect=repro pass on a broken
// harness; callers treat harnessError as a hard failure instead.
result.harnessError = error instanceof Error ? error.stack || error.message : String(error)
}
} finally {
result.elapsedMs = Date.now() - startedAt
result.finalDiagnostics = await collectRendererDiagnostics(page)
result.trace = summarizeTrace(userDataDir)
result.appLogSummary = summarizeAppLogs(launched.logs)
result.appLogsTail = launched.logs.slice(-120)
if (browser) {
await browser.close().catch(() => undefined)
}
await stopDevApp(launched.child)
if (!args.keep) {
safeRemoveLocalDirectory(userDataDir, result.cleanupErrors)
}
}
return result
}
@@ -0,0 +1,135 @@
import { execFileSync } from 'node:child_process'
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const rootDir = path.resolve(fileURLToPath(new URL('../../..', import.meta.url)))
function runWsl(distro, script, options = {}) {
return execFileSync('wsl.exe', ['-d', distro, '--', 'bash', '-se'], {
cwd: rootDir,
encoding: 'utf8',
input: script,
timeout: options.timeoutMs ?? 120_000,
stdio: ['pipe', 'pipe', 'pipe']
})
}
export function listWslDistros() {
const output = execFileSync('wsl.exe', ['--list', '--quiet'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 10_000
})
return output
.replaceAll(String.fromCharCode(0), '')
.split(/\r?\n/)
.map((line) => line.trim().replace(/^\*\s*/, ''))
.filter((line) => line && !line.toLowerCase().startsWith('docker-desktop'))
}
function linuxPathToWslUnc(distro, linuxPath) {
if (!linuxPath.startsWith('/')) {
throw new Error(`Expected absolute Linux path, got ${linuxPath}`)
}
return `\\\\wsl.localhost\\${distro}${linuxPath.replace(/\//g, '\\')}`
}
export function createWslFixture(distro) {
const script = String.raw`
set -euo pipefail
base="$(mktemp -d /tmp/orca-apphang-repro.XXXXXX)"
repo="$base/repo"
mkdir -p "$repo"
cd "$repo"
git init -q
git config user.email apphang-repro@test.local
git config user.name "AppHang Repro"
mkdir -p src docs
printf '# Orca Windows AppHang repro\n' > README.md
for n in $(seq 1 25); do
printf 'line %03d %s\n' "$n" "abcdefghijklmnopqrstuvwxyz0123456789" >> src/payload.txt
done
git add -A
git commit -q -m "Initial repro fixture"
for n in 1 2 3 4; do
git branch "repro-wt-$n"
git worktree add -q "$base/wt-$n" "repro-wt-$n"
mkdir -p "$base/wt-$n/docs"
printf 'worktree %s\n' "$n" > "$base/wt-$n/docs/wt.txt"
done
plain="$base/plain-folder"
mkdir -p "$plain/subdir"
printf 'plain folder for Orca AppHang repro\n' > "$plain/README.txt"
printf '%s\n' "$base" "$repo" "$base/wt-1" "$base/wt-2" "$base/wt-3" "$base/wt-4" "$plain"
`
const lines = runWsl(distro, script, { timeoutMs: 120_000 }).trim().split(/\r?\n/).filter(Boolean)
if (lines.length < 7) {
throw new Error(`WSL fixture creation returned unexpected output: ${JSON.stringify(lines)}`)
}
const [base, repo, ...rest] = lines
const plain = rest.at(-1)
const worktrees = rest.slice(0, -1)
return {
distro,
baseLinuxPath: base,
repoLinuxPath: repo,
plainLinuxPath: plain,
worktreeLinuxPaths: worktrees,
repoUncPath: linuxPathToWslUnc(distro, repo),
plainUncPath: linuxPathToWslUnc(distro, plain),
worktreeUncPaths: worktrees.map((entry) => linuxPathToWslUnc(distro, entry))
}
}
export function removeWslFixture(fixture) {
if (!fixture?.baseLinuxPath) {
return
}
const quoted = fixture.baseLinuxPath.replaceAll("'", "'\\''")
runWsl(fixture.distro, `rm -rf '${quoted}'`, { timeoutMs: 30_000 })
}
export function createCompletedOnboardingProfile(userDataDir) {
mkdirSync(userDataDir, { recursive: true })
const profile = {
settings: {
telemetry: {
// Why: synthetic harness/benchmark activity must not send telemetry.
// Explicit false with existedBeforeTelemetryRelease=false also keeps
// the first-launch consent surface from blocking automation.
optedIn: false,
installId: '00000000-0000-4000-8000-000000000000',
existedBeforeTelemetryRelease: false
}
},
onboarding: {
flowVersion: 4,
closedAt: 1,
outcome: 'completed',
lastCompletedStep: 5
},
ui: {
contextualToursAutoEligible: false,
contextualToursSeenIds: [
'workspace-board',
'browser',
'tasks',
'automations',
'workspace-creation'
],
featureTipsSeenIds: [],
featureInteractions: {},
projectOrderManualDefaultNoticeDismissed: true
}
}
writeFileSync(path.join(userDataDir, 'orca-data.json'), `${JSON.stringify(profile, null, 2)}\n`)
}
export function safeRemoveLocalDirectory(dir, cleanupErrors) {
try {
rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
} catch (error) {
cleanupErrors.push(error instanceof Error ? error.message : String(error))
}
}
+2
View File
@@ -11,7 +11,9 @@
"../src/main/claude/hook-settings.ts",
"../src/main/claude/hook-service.ts",
"../src/main/codex/codex-config-mirror.ts",
"../src/main/codex/codex-config-path-reference-rewrite.ts",
"../src/main/codex/codex-home-paths.ts",
"../src/main/codex/config-toml-line-scan.ts",
"../src/main/codex/config-toml-trust.ts",
"../src/main/codex/hook-service.ts",
"../src/main/codex-accounts/fs-utils.ts",
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 3.2m">
<title>downloads: 3.2m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 3.4m">
<title>downloads: 3.4m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">3.2m</text>
<text x="93.5" y="14">3.2m</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">3.4m</text>
<text x="93.5" y="14">3.4m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 943 B

After

Width:  |  Height:  |  Size: 943 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 434 KiB

+1 -1
View File
@@ -2,7 +2,7 @@
"expo": {
"name": "Orca",
"slug": "orca-mobile",
"version": "0.0.18",
"version": "0.0.20",
"orientation": "default",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
@@ -7,6 +7,11 @@ export default function MobileFilePreviewRoute() {
hostId?: string | string[]
worktreeId?: string | string[]
relativePath?: string | string[]
source?: string | string[]
absolutePath?: string | string[]
grantId?: string | string[]
line?: string | string[]
column?: string | string[]
name?: string | string[]
worktreeName?: string | string[]
}>()
+180 -350
View File
@@ -1,8 +1,6 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { Animated, AppState, Linking, type AppStateStatus } from 'react-native'
import * as Clipboard from 'expo-clipboard'
import { ImageManipulator, SaveFormat } from 'expo-image-manipulator'
import { File as FsFile, Paths } from 'expo-file-system'
import {
BackHandler,
FlatList,
@@ -48,7 +46,6 @@ import {
X
} from 'lucide-react-native'
import type { RpcClient } from '../../../../src/transport/rpc-client'
import type { RuntimeTerminalPathResolution } from '../../../../../src/shared/runtime-types'
import { loadHosts } from '../../../../src/transport/host-store'
import {
loadTerminalAutocompleteEnabled,
@@ -95,14 +92,16 @@ import {
getVisibleTerminalAccessoryKeys,
loadTerminalAccessoryLayout
} from '../../../../src/terminal/terminal-accessory-layout'
import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/terminal-live-accessory-input'
import { getTerminalLiveAccessoryRawSendTarget } from '../../../../src/terminal/terminal-live-accessory-raw-send-target'
import {
clearTerminalLiveInputFocusTimer,
defaultTerminalLiveInputHandles,
getTerminalLiveSpecialKeyBytes,
isTerminalLiveInputWithinByteLimit,
pruneTerminalLiveInputHandles,
scheduleTerminalLiveInputFocus
} from '../../../../src/terminal/terminal-live-input'
import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-live-input-sender'
import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-send-rpc-response'
import { useTerminalLiveInputCommit } from '../../../../src/terminal/use-terminal-live-input-commit'
import {
getTerminalCommandKeyboardType,
getTerminalLiveInputKeyboardType
@@ -162,16 +161,13 @@ import {
type MobileNewTabAgentOption,
type MobileNewTabAgentSettings
} from '../../../../src/session/mobile-new-tab-agent-options'
import {
buildMobileImagePastePayload,
prepareMobileClipboardImageBase64,
saveMobileClipboardImageAsTempFile,
type MobileClipboardImageResizer
} from '../../../../src/session/mobile-clipboard-image'
import { useMobileImageAttachment } from '../../../../src/session/use-mobile-image-attachment'
import { useMobileTerminalPaste } from '../../../../src/session/use-mobile-terminal-paste'
import { useTerminalLiveInputModePreference } from '../../../../src/session/use-terminal-live-input-mode-preference'
import { MobileTerminalLiveInputStatus } from '../../../../src/session/MobileTerminalLiveInputStatus'
import { MobileTerminalInputActions } from '../../../../src/session/MobileTerminalInputActions'
import { classifyMobileArtifact } from '../../../../src/session/mobile-artifact-kind'
import { openMobileTerminalFileTap } from '../../../../src/session/mobile-terminal-file-tap-open'
import { useLiveWorktreeName } from '../../../../src/session/use-live-worktree-name'
import {
acceptSessionSnapshot,
@@ -233,52 +229,9 @@ import type {
TerminalGestureInputQueue
} from './mobile-session-route-types'
const CLIPBOARD_IMAGE_DATA_URL_PREFIX_RE = /^data:image\/[a-z0-9.+-]+;base64,/i
const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450
type TerminalLiveAccessoryInput = ReturnType<typeof createTerminalLiveAccessoryInput>
// Why: clipboard images are re-encoded as lossless PNG, so high-res screenshots and
// photos can exceed the upload byte budget; resize the raster down to fit before upload.
// The image is staged to a temp file first because the iOS ImageManipulator loader
// (Data(contentsOf:)) cannot decode large base64 data URIs — it needs a file:// URI.
const resizeMobileClipboardImage: MobileClipboardImageResizer = async (source, target) => {
const base64 = source.replace(CLIPBOARD_IMAGE_DATA_URL_PREFIX_RE, '')
const file = new FsFile(Paths.cache, `orca-clip-resize-${Date.now()}.png`)
let context: ReturnType<typeof ImageManipulator.manipulate> | null = null
let rendered: Awaited<
ReturnType<ReturnType<typeof ImageManipulator.manipulate>['renderAsync']>
> | null = null
let resultUri: string | null = null
try {
file.create({ overwrite: true })
file.write(base64, { encoding: 'base64' })
context = ImageManipulator.manipulate(file.uri)
context.resize({ width: target.width, height: target.height })
rendered = await context.renderAsync()
const result = await rendered.saveAsync({ format: SaveFormat.PNG, base64: true })
resultUri = result.uri
// Why: empty base64 would pass the downstream base64 check and upload a corrupt
// image, so fail loudly here instead of silently sending an invalid payload.
if (!result.base64) {
throw new Error('Failed to encode resized clipboard image')
}
return { data: result.base64, width: result.width, height: result.height }
} finally {
rendered?.release()
context?.release()
if (resultUri) {
try {
new FsFile(resultUri).delete()
} catch {
// Best-effort cleanup; ImageManipulator saves into cache for every retry.
}
}
try {
file.delete()
} catch {
// Best-effort cleanup; the OS reclaims the cache directory regardless.
}
}
}
const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450
function getActiveTabIdForHandle(
tabs: MobileSessionTab[],
@@ -835,6 +788,21 @@ function FileReader({
return renderSourceText(doc.content)
}
function updateTerminalCwdFromStreamEvent(
handle: string,
data: Record<string, unknown>,
terminalCwd: Map<string, string>
): void {
if (!('cwd' in data)) {
return
}
if (typeof data.cwd === 'string' && data.cwd.trim().length > 0) {
terminalCwd.set(handle, data.cwd)
return
}
terminalCwd.delete(handle)
}
export default function SessionScreen() {
const {
hostId,
@@ -927,11 +895,14 @@ export default function SessionScreen() {
const [terminalLinkOpenMode, setTerminalLinkOpenMode] =
useState<MobileTerminalLinkOpenMode>('orca-browser')
const [liveInputCapture, setLiveInputCapture] = useState('')
const [liveInputTerminalHandles, setLiveInputTerminalHandles] = useState<Set<string>>(
() => new Set()
)
const liveInputTerminalHandlesRef = useRef<Set<string>>(new Set())
const defaultedLiveInputTerminalHandlesRef = useRef<Set<string>>(new Set())
const {
clearTerminalLiveInputDefault,
defaultTerminalHandlesToLiveInput,
liveInputTerminalHandles,
liveInputTerminalHandlesRef,
pruneTerminalHandlesFromLiveInput,
toggleTerminalLiveInput
} = useTerminalLiveInputModePreference({ hostId, worktreeId })
const [activeHandle, setActiveHandle] = useState<string | null>(null)
const [activeSessionTabId, setActiveSessionTabId] = useState<string | null>(null)
const activeSessionTabIdRef = useRef<string | null>(null)
@@ -1023,6 +994,7 @@ export default function SessionScreen() {
const terminalGestureInputBucketsRef = useRef<Map<string, TerminalGestureInputBucket>>(new Map())
const terminalGestureInputQueuesRef = useRef<Map<string, TerminalGestureInputQueue>>(new Map())
const terminalGestureInputInFlightRef = useRef<Set<string>>(new Set())
const terminalCwdRef = useRef<Map<string, string>>(new Map())
const initialModesSeenRef = useRef<Set<string>>(new Set())
const deviceTokenRef = useRef<string | null>(null)
const clientRef = useRef<RpcClient | null>(null)
@@ -1034,6 +1006,7 @@ export default function SessionScreen() {
const terminalRefs = useRef<Map<string, TerminalWebViewHandle>>(new Map())
const liveInputRef = useRef<TextInput>(null)
const liveInputFocusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const sendLiveTerminalInputRef = useRef<TerminalLiveInputSender>(async () => false)
const sessionTabActionSheetKeyboardHideSubRef = useRef<ReturnType<
typeof Keyboard.addListener
> | null>(null)
@@ -1068,6 +1041,7 @@ export default function SessionScreen() {
// render where client was still null/connecting, silently no-opping the
// in-app-browser open). Route through a ref kept current every render.
const handleCreateBrowserRef = useRef<((rawUrl?: string) => Promise<boolean>) | null>(null)
const initialEmptySessionAutoCreateRef = useRef<string | null>(null)
const markdownSaveSeqRef = useRef<Map<string, number>>(new Map())
const markdownSaveInFlightRef = useRef<Set<string>>(new Set())
@@ -1094,6 +1068,24 @@ export default function SessionScreen() {
const [terminalFrameWidth, setTerminalFrameWidth] = useState(0)
const activeSessionTab = sessionTabs.find((tab) => tab.id === activeSessionTabId) ?? null
const {
clearPendingLiveInputCommit,
flushPendingLiveInputBeforeExternalSend,
handleLiveInputAccessoryBytes,
handleLiveInputChange,
handleLiveInputKeyPress,
handleLiveInputSubmit
} = useTerminalLiveInputCommit({
activeHandle,
activeHandleRef,
activeSessionTabType: activeSessionTab?.type,
activeSessionTabTypeRef,
liveInputRef,
liveInputTerminalHandles,
liveInputTerminalHandlesRef,
sendLiveTerminalInputRef,
setLiveInputCapture
})
const canSend =
connState === 'connected' &&
activeHandle != null &&
@@ -1114,7 +1106,6 @@ export default function SessionScreen() {
sessionTabsRef.current = sessionTabs
activeSessionTabIdRef.current = activeSessionTabId
markdownDocsRef.current = markdownDocs
liveInputTerminalHandlesRef.current = liveInputTerminalHandles
const reconciledCreateWarningState = reconcileMobileSessionCreateWarningState(
createWarningState,
initialCreateWarning
@@ -1180,52 +1171,6 @@ export default function SessionScreen() {
[clearToastHideTimer]
)
// Why: direct input is now the mobile default, but only once per discovered
// handle so a user's buffered-mode toggle survives tab/list refreshes.
const defaultTerminalHandlesToLiveInput = useCallback((handles: readonly string[]) => {
const result = defaultTerminalLiveInputHandles(
liveInputTerminalHandlesRef.current,
defaultedLiveInputTerminalHandlesRef.current,
handles
)
if (!result.changed) {
return
}
const nextEnabledHandles = new Set(result.enabledHandles)
const nextDefaultedHandles = new Set(result.defaultedHandles)
liveInputTerminalHandlesRef.current = nextEnabledHandles
defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles
setLiveInputTerminalHandles(nextEnabledHandles)
}, [])
const pruneTerminalHandlesFromLiveInput = useCallback((liveHandles: ReadonlySet<string>) => {
const result = pruneTerminalLiveInputHandles(
liveInputTerminalHandlesRef.current,
defaultedLiveInputTerminalHandlesRef.current,
liveHandles
)
if (!result.changed) {
return
}
const nextEnabledHandles = new Set(result.enabledHandles)
const nextDefaultedHandles = new Set(result.defaultedHandles)
liveInputTerminalHandlesRef.current = nextEnabledHandles
defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles
setLiveInputTerminalHandles(nextEnabledHandles)
}, [])
const clearTerminalLiveInputDefault = useCallback(
(handle: string) => {
const liveHandles = new Set([
...liveInputTerminalHandlesRef.current,
...defaultedLiveInputTerminalHandlesRef.current
])
liveHandles.delete(handle)
pruneTerminalHandlesFromLiveInput(liveHandles)
},
[pruneTerminalHandlesFromLiveInput]
)
const dictation = useMobileDictation({
client,
enabled: canSend,
@@ -1244,8 +1189,16 @@ export default function SessionScreen() {
if (!insertHandle) {
return
}
sendLiveTerminalInput(insertHandle, route.text)
showToast('Dictation inserted')
void (async () => {
const flushedPendingInput = await flushPendingLiveInputBeforeExternalSend(insertHandle)
if (!flushedPendingInput) {
return
}
const sent = await sendLiveTerminalInput(insertHandle, route.text)
if (sent) {
showToast('Dictation inserted')
}
})()
return
}
setInput((current) => appendBufferedDictation(current, route.text))
@@ -1361,6 +1314,7 @@ export default function SessionScreen() {
webReadyHandlesRef.current.clear()
subscribeSeqRef.current.clear()
layoutSeqRef.current.clear()
terminalCwdRef.current.clear()
setTerminalKeyboardMetrics(new Map())
for (const term of terminalRefs.current.values()) {
term.clear()
@@ -1460,6 +1414,7 @@ export default function SessionScreen() {
if (initializedHandlesRef.current.has(handle)) {
return
}
updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current)
const cols = (data.cols as number) || 80
const rows = (data.rows as number) || 24
const scrollbackCols = cols
@@ -1555,7 +1510,10 @@ export default function SessionScreen() {
}
})()
}
} else if (data.type === 'metadata') {
updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current)
} else if (data.type === 'data') {
updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current)
// Why: log when data arrives but the WebView ref is missing
// — this is the most likely cause of "blank but input works":
// server stream is alive, sends flow, but writes are dropped
@@ -1578,6 +1536,7 @@ export default function SessionScreen() {
}
dataRef.write(data.chunk as string)
} else if (data.type === 'resized') {
updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current)
// Why: inline resize event — the server changed the PTY dimensions
// (mode toggle, desktop restore, or a width reflow). When the server
// includes a fresh full-buffer snapshot (width reflow), reinitialize
@@ -2680,19 +2639,23 @@ export default function SessionScreen() {
terminalsRef.current = []
setSessionTabs([])
setActiveSessionTabId(null)
setLiveInputCapture('')
liveInputTerminalHandlesRef.current = new Set()
defaultedLiveInputTerminalHandlesRef.current = new Set()
setLiveInputTerminalHandles(new Set())
clearPendingLiveInputCommit()
setMarkdownDocs(new Map())
setFileDocs(new Map())
clearDelayedActionTimers()
return () => {
sessionTabActionSheetRequestSeqRef.current += 1
sessionTabActionSheetKeyboardHideSubRef.current?.remove()
clearPendingLiveInputCommit()
clearDelayedActionTimers()
}
}, [clearDelayedActionTimers, clearTerminalCache, hostId, worktreeId])
}, [
clearDelayedActionTimers,
clearPendingLiveInputCommit,
clearTerminalCache,
hostId,
worktreeId
])
useEffect(() => {
if (connState !== 'connected') {
@@ -3097,46 +3060,62 @@ export default function SessionScreen() {
}
}
async function handleAccessoryKey(bytes: string) {
async function handleAccessoryKey(input: TerminalLiveAccessoryInput) {
if (!client || !activeHandle || !canSend) {
return
}
try {
await client.sendRequest('terminal.send', {
terminal: activeHandle,
text: bytes,
const targetHandle = activeHandle
const accessoryCommit = await handleLiveInputAccessoryBytes(input)
if (accessoryCommit.kind !== 'allow-raw') {
return
}
const currentClient = clientRef.current
// Why: async IME flushing can outlive the original terminal selection.
const rawSendTarget = getTerminalLiveAccessoryRawSendTarget({
targetHandle,
activeHandle: activeHandleRef.current,
activeSessionTabType: activeSessionTabTypeRef.current
})
if (!currentClient || !rawSendTarget || connStateRef.current !== 'connected') {
return
}
await currentClient
.sendRequest('terminal.send', {
terminal: rawSendTarget,
text: input.bytes,
enter: false,
...(deviceTokenRef.current
? { client: { id: deviceTokenRef.current, type: 'mobile' as const } }
: {})
})
} catch {
// Transient failure
}
.then(
() => undefined,
() => undefined
)
}
const sendLiveTerminalInput = useCallback(
(handle: string, bytes: string) => {
async (handle: string, bytes: string): Promise<boolean> => {
const text = normalizeTerminalTextInput(bytes)
if (text.length === 0) {
return
return false
}
if (!isTerminalLiveInputWithinByteLimit(text)) {
triggerError()
showToast('Input too large (max 256 KiB)', 1500)
return
return false
}
const rpc = clientRef.current
// Why: callers suppress follow-up controls/toasts when this live send is stale.
if (
!rpc ||
connStateRef.current !== 'connected' ||
handle !== activeHandleRef.current ||
activeSessionTabTypeRef.current !== 'terminal'
) {
return
return false
}
void rpc
return rpc
.sendRequest('terminal.send', {
terminal: handle,
text,
@@ -3145,12 +3124,11 @@ export default function SessionScreen() {
? { client: { id: deviceTokenRef.current, type: 'mobile' as const } }
: {})
})
.catch(() => {
// Transient failure
})
.then(isTerminalSendRpcAccepted, () => false)
},
[showToast]
)
sendLiveTerminalInputRef.current = sendLiveTerminalInput
const focusLiveInput = useCallback(() => {
if (!canSend || !liveInputEnabled) {
@@ -3238,82 +3216,42 @@ export default function SessionScreen() {
// Tap on a file path in terminal output → resolve it on the host and open it
// as a file tab (mirrors desktop Cmd/Ctrl-click). Silent on a miss; the
// WebView only emits this when the tap landed on a detected path.
const handleFileTapActivationSeqRef = useRef(0)
const handleFileTap = useCallback(
(handle: string, pathText: string) => {
(handle: string, pathText: string, line: number | null, column: number | null) => {
if (handle !== activeHandleRef.current || !client) {
return
}
void (async () => {
try {
const worktree = `id:${worktreeId}`
const response = await client.sendRequest(
'files.resolveTerminalPath',
{ worktree, pathText },
{ timeoutMs: 10_000 }
)
if (!response.ok) {
return
}
const resolved = (response as RpcSuccess).result as RuntimeTerminalPathResolution
if (!resolved.exists || resolved.isDirectory || !resolved.relativePath) {
return
}
// Confirm the tap landed on something openable before giving feedback.
triggerSelection()
// Why: HTML opens in a browser pane (streamed from the desktop),
// matching desktop's terminal-click behavior, instead of a file view.
if (classifyMobileArtifact(resolved.relativePath) === 'html' && resolved.absolutePath) {
void handleCreateBrowser('file://' + resolved.absolutePath)
return
}
const openResponse = await client.sendRequest(
'files.open',
{ worktree, relativePath: resolved.relativePath },
{ timeoutMs: 15_000 }
)
if (!openResponse.ok) {
return
}
// Why: the host opens the file as a markdown/file/image tab (the type
// depends on the file — .md opens as a 'markdown' tab), and from a terminal
// the active tab stays on the terminal. Once the new tab syncs in, switch to
// it by relativePath across ANY openable type. Poll since it arrives async.
const openedPath = resolved.relativePath
// Why: retries poll for the async-arriving tab, but once activation lands
// a later retry would steal focus back from the user — short-circuit the
// remaining ones once the opened tab is (or becomes) the active tab.
let activated = false
const activateOpenedTab = async (): Promise<void> => {
if (activated) {
return
}
await fetchSessionTabs()
if (activated) {
return
}
const opened = sessionTabsRef.current.find(
(tab): tab is Extract<MobileSessionTab, { relativePath?: string }> =>
'relativePath' in tab && tab.relativePath === openedPath
)
if (!opened) {
return
}
if (activeSessionTabIdRef.current === opened.id) {
activated = true
return
}
switchSessionTabRef.current?.(opened)
activated = true
}
scheduleDelayedAction(() => void activateOpenedTab(), 300)
scheduleDelayedAction(() => void activateOpenedTab(), 900)
scheduleDelayedAction(() => void activateOpenedTab(), 1800)
} catch {
// Resolution/open is best-effort; a failed tap silently no-ops.
}
})()
const activationSeq = ++handleFileTapActivationSeqRef.current
openMobileTerminalFileTap<MobileSessionTab>({
client,
hostId,
worktreeId,
worktreeName: routeWorktreeName,
terminalHandle: handle,
pathText,
cwd: terminalCwdRef.current.get(handle) ?? null,
line,
column,
pushPreviewRoute: (href) => router.push(href),
openBrowser: (url) => void handleCreateBrowserRef.current?.(url),
triggerOpenFeedback: triggerSelection,
fetchSessionTabs,
getSessionTabs: () => sessionTabsRef.current,
getActiveSessionTabId: () => activeSessionTabIdRef.current,
getActivationState: (activated) => ({
activated,
activationSeq,
latestActivationSeq: handleFileTapActivationSeqRef.current,
sourceTerminalHandle: handle,
activeTerminalHandle: activeHandleRef.current,
activeTabType: activeSessionTabTypeRef.current
}),
switchSessionTab: (tab) => switchSessionTabRef.current?.(tab),
scheduleDelayedAction
})
},
[client, worktreeId, scheduleDelayedAction, fetchSessionTabs]
[client, fetchSessionTabs, hostId, routeWorktreeName, router, scheduleDelayedAction, worktreeId]
)
const handleOpenedFileDiffActivationSeqRef = useRef(0)
@@ -3376,81 +3314,15 @@ export default function SessionScreen() {
if (!activeHandle) {
return
}
const nextEnabled = !liveInputTerminalHandles.has(activeHandle)
setLiveInputTerminalHandles((prev) => {
const next = new Set(prev)
if (nextEnabled) {
next.add(activeHandle)
} else {
next.delete(activeHandle)
}
liveInputTerminalHandlesRef.current = next
return next
})
setLiveInputCapture('')
const nextEnabled = toggleTerminalLiveInput(activeHandle)
clearPendingLiveInputCommit()
if (nextEnabled) {
scheduleTerminalLiveInputFocus(liveInputFocusTimerRef, () => liveInputRef.current?.focus())
} else {
clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef)
liveInputRef.current?.blur()
}
}, [activeHandle, liveInputTerminalHandles])
const handleLiveInputChange = useCallback(
(text: string) => {
if (!activeHandle) {
setLiveInputCapture('')
liveInputRef.current?.setNativeProps({ text: '' })
return
}
if (!liveInputTerminalHandles.has(activeHandle)) {
setLiveInputCapture('')
liveInputRef.current?.setNativeProps({ text: '' })
return
}
const normalizedText = normalizeTerminalTextInput(text)
if (normalizedText.length > 0) {
sendLiveTerminalInput(activeHandle, normalizedText)
}
setLiveInputCapture('')
// Why: the field is only a keyboard capture surface. Clearing the
// native value prevents subsequent phone-keyboard events from replaying
// already-sent characters when React state remains the empty string.
liveInputRef.current?.setNativeProps({ text: '' })
},
[activeHandle, liveInputTerminalHandles, sendLiveTerminalInput]
)
const handleLiveInputKeyPress = useCallback(
(event: { nativeEvent: { key: string } }) => {
if (!activeHandle) {
return
}
if (!liveInputTerminalHandles.has(activeHandle)) {
return
}
const bytes = getTerminalLiveSpecialKeyBytes(event.nativeEvent.key)
if (!bytes) {
return
}
sendLiveTerminalInput(activeHandle, bytes)
setLiveInputCapture('')
liveInputRef.current?.setNativeProps({ text: '' })
},
[activeHandle, liveInputTerminalHandles, sendLiveTerminalInput]
)
const handleLiveInputSubmit = useCallback(() => {
if (!activeHandle) {
return
}
if (!liveInputTerminalHandles.has(activeHandle)) {
return
}
sendLiveTerminalInput(activeHandle, '\r')
setLiveInputCapture('')
liveInputRef.current?.setNativeProps({ text: '' })
}, [activeHandle, liveInputTerminalHandles, sendLiveTerminalInput])
}, [activeHandle, clearPendingLiveInputCommit, toggleTerminalLiveInput])
const allowTerminalGestureInput = useCallback(
(handle: string, sequenceCount: number): boolean => {
@@ -3650,11 +3522,11 @@ export default function SessionScreen() {
}
}, [])
const startAccessoryRepeat = useCallback(
(bytes: string) => {
(input: TerminalLiveAccessoryInput) => {
stopAccessoryRepeat()
repeatTimeoutRef.current = setTimeout(() => {
repeatIntervalRef.current = setInterval(() => {
void handleAccessoryKeyRef.current(bytes)
void handleAccessoryKeyRef.current(input)
}, 45)
}, 400)
},
@@ -3673,11 +3545,13 @@ export default function SessionScreen() {
clearToastHideTimer()
clearDelayedActionTimers()
clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef)
clearPendingLiveInputCommit()
sessionTabActionSheetRequestSeqRef.current += 1
clearSessionTabActionSheetKeyboardListener()
stopAccessoryRepeat()
},
[
clearPendingLiveInputCommit,
clearDelayedActionTimers,
clearSessionTabActionSheetKeyboardListener,
clearTerminalCache,
@@ -3801,84 +3675,38 @@ export default function SessionScreen() {
})
}, [])
const handlePaste = useCallback(async () => {
if (!client || !activeHandle || !canSend) {
return
}
try {
const text = await Clipboard.getStringAsync()
let payload: string | null = null
if (text.length > 0) {
const modes = ptyModesRef.current.get(activeHandle) || {
bracketedPasteMode: false,
altScreen: false,
mouseTrackingMode: 'none',
sgrMouseMode: false,
sgrMousePixelsMode: false
}
const wrap = modes.bracketedPasteMode && !modes.altScreen
// Why: strip embedded bracketed-paste markers from clipboard text so a
// malicious copy containing `\x1b[201~` can't terminate paste mode early
// and have the trailing bytes interpreted as shell commands. Matches
// xterm.js / iTerm2 behavior.
// eslint-disable-next-line no-control-regex -- intentional bracketed-paste marker stripping
const sanitized = wrap ? text.replace(/\x1b\[20[01]~/g, '') : text
payload = wrap ? `\x1b[200~${sanitized}\x1b[201~` : sanitized
} else {
const image = await Clipboard.getImageAsync({ format: 'png' })
if (!image) {
refreshCanPaste()
return
}
const connectionId = await getActiveWorktreeConnectionId()
const base64 = await prepareMobileClipboardImageBase64(image, resizeMobileClipboardImage)
const imagePath = await saveMobileClipboardImageAsTempFile(client, base64, {
connectionId
})
payload = buildMobileImagePastePayload(imagePath)
}
const wrappedBytes = new TextEncoder().encode(payload).byteLength
if (wrappedBytes > 256 * 1024) {
triggerError()
// eslint-disable-next-line no-console
console.warn('[mobile-clip] paste oversized', { wrappedBytes })
showToast('Paste too large (max 256 KiB)', 1500)
return
}
await client.sendRequest('terminal.send', {
terminal: activeHandle,
text: payload,
enter: false,
...(deviceTokenRef.current
? { client: { id: deviceTokenRef.current, type: 'mobile' as const } }
: {})
})
triggerSelection()
refreshCanPaste()
} catch (e) {
triggerError()
const err = e as { name?: string; message?: string }
const isDisconnected = connState !== 'connected'
// eslint-disable-next-line no-console
console.warn('[mobile-clip] paste failed', { name: err.name, message: err.message })
if (isDisconnected) {
showToast('Paste failed (disconnected)', 1500)
} else if (err.message === 'Clipboard image is too large') {
showToast('Image too large to paste', 1500)
} else {
showToast('Paste failed', 1500)
}
}
}, [
const handlePaste = useMobileTerminalPaste({
client,
activeHandle,
activeHandleRef,
activeSessionTabTypeRef,
canSend,
connState,
connStateRef,
clientRef,
deviceTokenRef,
flushPendingLiveInputBeforeExternalSend,
getActiveWorktreeConnectionId,
onError: triggerError,
onSuccess: triggerSelection,
ptyModesRef,
refreshCanPaste,
showToast
])
})
const flushPendingLiveInputBeforeAttachmentSend = useCallback(
async (targetHandle: string): Promise<boolean> => {
const flushedPendingInput = await flushPendingLiveInputBeforeExternalSend(targetHandle)
// Why: image picking/upload and IME flushing can outlive the original tab.
return (
flushedPendingInput &&
connStateRef.current === 'connected' &&
targetHandle === activeHandleRef.current &&
activeSessionTabTypeRef.current === 'terminal'
)
},
[flushPendingLiveInputBeforeExternalSend]
)
const { attachImage, isAttaching } = useMobileImageAttachment({
client,
@@ -3886,6 +3714,7 @@ export default function SessionScreen() {
canSend,
connState,
deviceTokenRef,
beforeTerminalSend: flushPendingLiveInputBeforeAttachmentSend,
getActiveWorktreeConnectionId,
showToast,
onSuccess: triggerSelection,
@@ -5046,8 +4875,9 @@ export default function SessionScreen() {
if (!key.repeatable) {
return
}
void handleAccessoryKey(key.bytes)
startAccessoryRepeat(key.bytes)
const input = createTerminalLiveAccessoryInput(key)
void handleAccessoryKey(input)
startAccessoryRepeat(input)
}}
onPressOut={() => {
if (key.repeatable) {
@@ -5058,7 +4888,7 @@ export default function SessionScreen() {
if (key.repeatable) {
return
}
void handleAccessoryKey(key.bytes)
void handleAccessoryKey(createTerminalLiveAccessoryInput(key))
}}
accessibilityLabel={key.accessibilityLabel ?? `Send ${key.label}`}
>
@@ -5082,7 +4912,7 @@ export default function SessionScreen() {
!canSend && styles.accessoryKeyDisabled
]}
disabled={!canSend}
onPress={() => void handleAccessoryKey(key.bytes)}
onPress={() => void handleAccessoryKey({ bytes: key.bytes })}
onLongPress={() => {
triggerMediumImpact()
setDeleteKeyTarget(key)
+2
View File
@@ -60,10 +60,12 @@
"devDependencies": {
"@types/react": "^19.2.14",
"@types/react-native": "^0.73.0",
"@types/react-test-renderer": "19.1.0",
"@types/ws": "^8.18.1",
"expo-module-scripts": "^55.0.2",
"oxfmt": "^0.52.0",
"oxlint": "^1.71.0",
"react-test-renderer": "19.2.6",
"tsx": "^4.22.4",
"typescript": "^5.9.3",
"vite": "^8.0.16",
+53 -10
View File
@@ -67,7 +67,7 @@ importers:
version: 55.0.22(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-router:
specifier: ^55.0.14
version: 55.0.14(ce047ddbdcb481fe003f0510f5198c7e)
version: 55.0.14(e0a8113e3689d84edb7af23d00a72ffa)
expo-secure-store:
specifier: ^55.0.13
version: 55.0.13(expo@55.0.23)
@@ -135,18 +135,24 @@ importers:
'@types/react-native':
specifier: ^0.73.0
version: 0.73.0(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)
'@types/react-test-renderer':
specifier: 19.1.0
version: 19.1.0
'@types/ws':
specifier: ^8.18.1
version: 8.18.1
expo-module-scripts:
specifier: ^55.0.2
version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6)
version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6)
oxfmt:
specifier: ^0.52.0
version: 0.52.0
oxlint:
specifier: ^1.71.0
version: 1.71.0
react-test-renderer:
specifier: 19.2.6
version: 19.2.6(react@19.2.6)
tsx:
specifier: ^4.22.4
version: 4.22.4
@@ -1724,48 +1730,56 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-arm64-musl@0.52.0':
resolution: {integrity: sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-ppc64-gnu@0.52.0':
resolution: {integrity: sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-gnu@0.52.0':
resolution: {integrity: sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-musl@0.52.0':
resolution: {integrity: sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-s390x-gnu@0.52.0':
resolution: {integrity: sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-gnu@0.52.0':
resolution: {integrity: sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-musl@0.52.0':
resolution: {integrity: sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxfmt/binding-openharmony-arm64@0.52.0':
resolution: {integrity: sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ==}
@@ -1838,48 +1852,56 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-arm64-musl@1.71.0':
resolution: {integrity: sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-ppc64-gnu@1.71.0':
resolution: {integrity: sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-gnu@1.71.0':
resolution: {integrity: sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-musl@1.71.0':
resolution: {integrity: sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-s390x-gnu@1.71.0':
resolution: {integrity: sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-gnu@1.71.0':
resolution: {integrity: sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-musl@1.71.0':
resolution: {integrity: sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxlint/binding-openharmony-arm64@1.71.0':
resolution: {integrity: sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw==}
@@ -2341,36 +2363,42 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.1.3':
resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.1.3':
resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.1.3':
resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.1.3':
resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.1.3':
resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.1.3':
resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==}
@@ -4632,24 +4660,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@@ -5424,6 +5456,11 @@ packages:
peerDependencies:
react: ^19.2.0
react-test-renderer@19.2.6:
resolution: {integrity: sha512-GbS6V23YduFTPiWJ5xICbKEjRcqx1Z90js/V5miqhz7qp/d6xSe9Dd6NjSQODFRdzdsqRMPW82E/sFpPRbY5Mw==}
peerDependencies:
react: ^19.2.6
react@19.2.6:
resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==}
engines: {node: '>=0.10.0'}
@@ -7825,7 +7862,7 @@ snapshots:
ws: 8.21.0
zod: 3.25.76
optionalDependencies:
expo-router: 55.0.14(ce047ddbdcb481fe003f0510f5198c7e)
expo-router: 55.0.14(e0a8113e3689d84edb7af23d00a72ffa)
react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)
transitivePeerDependencies:
- '@expo/dom-webview'
@@ -8093,7 +8130,7 @@ snapshots:
react: 19.2.6
optionalDependencies:
'@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-dom@19.2.6(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
expo-router: 55.0.14(ce047ddbdcb481fe003f0510f5198c7e)
expo-router: 55.0.14(e0a8113e3689d84edb7af23d00a72ffa)
react-dom: 19.2.6(react@19.2.6)
transitivePeerDependencies:
- supports-color
@@ -9055,14 +9092,14 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
'@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6)':
'@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
jest-matcher-utils: 30.3.0
picocolors: 1.1.1
pretty-format: 30.3.0
react: 19.2.6
react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)
react-test-renderer: 19.2.0(react@19.2.6)
react-test-renderer: 19.2.6(react@19.2.6)
redent: 3.0.0
optionalDependencies:
jest: 29.7.0(@types/node@25.9.3)
@@ -10651,7 +10688,7 @@ snapshots:
expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-json-utils: 55.0.2
expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6):
expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
'@babel/cli': 7.28.6(@babel/core@7.29.7)
'@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.7)
@@ -10659,7 +10696,7 @@ snapshots:
'@babel/preset-typescript': 7.28.5(@babel/core@7.29.7)
'@expo/npm-proofread': 1.0.1
'@expo/spawn-async': 1.7.2
'@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6)
'@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6)
'@tsconfig/node18': 18.2.6
'@types/jest': 29.5.14
babel-plugin-dynamic-import-node: 2.3.3
@@ -10734,7 +10771,7 @@ snapshots:
- supports-color
- typescript
expo-router@55.0.14(ce047ddbdcb481fe003f0510f5198c7e):
expo-router@55.0.14(e0a8113e3689d84edb7af23d00a72ffa):
dependencies:
'@expo/log-box': 55.0.12(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
'@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-dom@19.2.6(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
@@ -10771,7 +10808,7 @@ snapshots:
use-latest-callback: 0.2.6(react@19.2.6)
vaul: 1.1.2(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
optionalDependencies:
'@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6)
'@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6)
react-dom: 19.2.6(react@19.2.6)
react-native-gesture-handler: 2.31.2(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
react-native-reanimated: 4.3.0(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
@@ -12948,6 +12985,12 @@ snapshots:
react-is: 19.2.6
scheduler: 0.27.0
react-test-renderer@19.2.6(react@19.2.6):
dependencies:
react: 19.2.6
react-is: 19.2.6
scheduler: 0.27.0
react@19.2.6: {}
readdirp@3.6.0:
+14 -3
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { Pressable, ScrollView, View } from 'react-native'
import { Code, Pencil } from 'lucide-react-native'
import { MobileMarkdown } from '../components/MobileMarkdown'
@@ -14,10 +14,20 @@ type Props = {
content: string
truncated: boolean
byteLength: number
initialLine?: number
}
export function MobileFileMarkdownPreview({ relativePath, content, truncated, byteLength }: Props) {
const [mode, setMode] = useState<'preview' | 'source'>('preview')
export function MobileFileMarkdownPreview({
relativePath,
content,
truncated,
byteLength,
initialLine
}: Props) {
const [mode, setMode] = useState<'preview' | 'source'>(() => (initialLine ? 'source' : 'preview'))
useEffect(() => {
setMode(initialLine ? 'source' : 'preview')
}, [initialLine, relativePath])
const previewSelected = mode === 'preview'
const sourceSelected = mode === 'source'
@@ -62,6 +72,7 @@ export function MobileFileMarkdownPreview({ relativePath, content, truncated, by
content={content}
truncated={truncated}
byteLength={byteLength}
initialLine={initialLine}
/>
)}
</View>
+130
View File
@@ -0,0 +1,130 @@
import { ActivityIndicator, Image, Pressable, ScrollView, Text, View } from 'react-native'
import { colors } from '../theme/mobile-theme'
import type { MobileFilePreviewResult } from './mobile-file-preview-request'
import { MobileFileMarkdownPreview } from './MobileFileMarkdownPreview'
import { MobileFilePreviewEditableSource } from './MobileFilePreviewEditableSource'
import { MobileFilePreviewSourceText } from './MobileFilePreviewSourceText'
import type { MobileFilePreviewLineColumn } from './mobile-file-preview-line-column'
import { filePreviewStyles as styles } from './mobile-file-preview-styles'
type Props = {
preview: MobileFilePreviewResult
relativePath: string
title: string
editable: boolean
draftContent: string
saveError: string
lineColumn: MobileFilePreviewLineColumn | null
imageWidth: number
imageHeight: number
onDraftChange: (content: string) => void
onImageError: () => void
onRetry: () => void
}
export function MobileFilePreviewBody({ preview, ...options }: Props) {
if (preview.status === 'loading') {
return (
<View style={styles.state}>
<ActivityIndicator size="small" color={colors.textSecondary} />
<Text style={styles.stateText}>{preview.message}</Text>
</View>
)
}
if (preview.status === 'error' || preview.status === 'waiting') {
return (
<View style={styles.state}>
<Text style={styles.errorText}>{preview.message}</Text>
<Pressable style={styles.retryButton} onPress={options.onRetry}>
<Text style={styles.retryText}>Retry</Text>
</Pressable>
</View>
)
}
if (preview.status === 'empty') {
return options.editable ? (
<EditablePreviewSource {...options} />
) : (
<View style={styles.state}>
<Text style={styles.stateText}>Empty file</Text>
</View>
)
}
if (preview.kind === 'image') {
return (
<View style={styles.imageContainer}>
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.imageScrollContent}
maximumZoomScale={4}
minimumZoomScale={1}
centerContent
>
<Image
source={{ uri: preview.dataUri }}
style={[styles.image, { width: options.imageWidth, height: options.imageHeight }]}
resizeMode="contain"
onError={options.onImageError}
accessibilityLabel={`${options.title} image`}
/>
</ScrollView>
</View>
)
}
if (preview.kind === 'markdown') {
return options.editable ? (
<EditablePreviewSource {...options} />
) : (
<MobileFileMarkdownPreview
relativePath={options.relativePath}
content={preview.content}
truncated={preview.truncated}
byteLength={preview.byteLength}
initialLine={options.lineColumn?.line}
/>
)
}
if (preview.kind === 'html') {
return options.editable ? (
<EditablePreviewSource {...options} />
) : (
<MobileFilePreviewSourceText
relativePath={options.relativePath}
content={preview.content}
truncated={preview.truncated}
byteLength={preview.byteLength}
initialLine={options.lineColumn?.line}
/>
)
}
if (options.editable) {
return <EditablePreviewSource {...options} />
}
return (
<MobileFilePreviewSourceText
relativePath={options.relativePath}
content={preview.content}
truncated={preview.truncated}
byteLength={preview.byteLength}
initialLine={options.lineColumn?.line}
/>
)
}
function EditablePreviewSource(options: {
title: string
draftContent: string
saveError: string
lineColumn: MobileFilePreviewLineColumn | null
onDraftChange: (content: string) => void
}) {
return (
<MobileFilePreviewEditableSource
title={options.title}
draftContent={options.draftContent}
saveError={options.saveError}
lineColumn={options.lineColumn}
onDraftChange={options.onDraftChange}
/>
)
}
@@ -0,0 +1,56 @@
import { useEffect, useState } from 'react'
import { Text, TextInput, View } from 'react-native'
import type { MobileFilePreviewLineColumn } from './mobile-file-preview-line-column'
import { textOffsetForLineColumn } from './mobile-file-preview-line-column'
import { filePreviewStyles as styles } from './mobile-file-preview-styles'
type Props = {
title: string
draftContent: string
lineColumn: MobileFilePreviewLineColumn | null
saveError?: string
onDraftChange: (content: string) => void
}
export function MobileFilePreviewEditableSource({
title,
draftContent,
lineColumn,
saveError,
onDraftChange
}: Props) {
const selectionTargetKey = lineColumn
? `${title}:${lineColumn.line}:${lineColumn.column ?? ''}`
: ''
const [selection, setSelection] = useState<{ start: number; end: number } | null>(null)
const [revealedTargetKey, setRevealedTargetKey] = useState('')
// Why: line/column opens should reveal once, then user cursor movement owns selection.
useEffect(() => {
if (!lineColumn || !selectionTargetKey || revealedTargetKey === selectionTargetKey) {
return
}
const offset = textOffsetForLineColumn(draftContent, lineColumn)
const initialSelection = { start: offset, end: offset }
setSelection(initialSelection)
setRevealedTargetKey(selectionTargetKey)
}, [draftContent, lineColumn, revealedTargetKey, selectionTargetKey])
return (
<View style={styles.editContainer}>
{saveError ? <Text style={styles.saveErrorText}>{saveError}</Text> : null}
<TextInput
style={styles.editInput}
value={draftContent}
onChangeText={onDraftChange}
multiline
textAlignVertical="top"
autoCapitalize="none"
autoCorrect={false}
selection={selection ?? undefined}
onSelectionChange={() => setSelection(null)}
accessibilityLabel={`${title} editor`}
/>
</View>
)
}
@@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest'
import { sourceKeyForPreview } from './mobile-file-preview-source'
import {
hasUnsavedMobileTerminalArtifactDraft,
isEditableMobileTerminalArtifactPreview,
shouldKeepDirtyDraftOnPreviewLoadResult
} from './mobile-file-preview-editability'
describe('MobileFilePreviewScreen', () => {
it('treats empty terminal artifact text previews as editable', () => {
expect(isEditableMobileTerminalArtifactPreview({ status: 'empty', kind: 'text' })).toBe(true)
expect(
isEditableMobileTerminalArtifactPreview({
status: 'ready',
kind: 'text',
content: '',
truncated: false,
byteLength: 0
})
).toBe(true)
expect(
isEditableMobileTerminalArtifactPreview({
status: 'ready',
kind: 'image',
dataUri: 'data:image/png;base64,aW1n'
})
).toBe(false)
})
it('treats truncated terminal artifact text previews as read-only', () => {
expect(
isEditableMobileTerminalArtifactPreview({
status: 'ready',
kind: 'text',
content: 'partial',
truncated: true,
byteLength: 1024
})
).toBe(false)
})
it('keeps dirty terminal artifact drafts protected while preview is waiting for reconnect', () => {
const sourceKey = sourceKeyForPreview({
source: 'terminalArtifact',
worktreeId: 'wt-1',
absolutePath: '/tmp/result.json',
grantId: 'grant-1'
})
expect(
hasUnsavedMobileTerminalArtifactDraft({
source: 'terminalArtifact',
draftSourceKey: sourceKey,
previewSourceKey: sourceKey,
draftContent: '{"ok":false}',
savedContent: '{"ok":true}'
})
).toBe(true)
})
it('keeps dirty terminal artifact drafts when a reload fails or waits', () => {
expect(
shouldKeepDirtyDraftOnPreviewLoadResult(true, {
status: 'error',
message: 'Unable to reach desktop filesystem',
reconnect: true
})
).toBe(true)
expect(
shouldKeepDirtyDraftOnPreviewLoadResult(true, {
status: 'waiting',
message: 'Waiting for desktop...',
reconnect: true
})
).toBe(true)
expect(
shouldKeepDirtyDraftOnPreviewLoadResult(false, {
status: 'error',
message: 'Unable to load preview',
reconnect: false
})
).toBe(false)
})
})
+198 -124
View File
@@ -1,30 +1,30 @@
import { useCallback, useEffect, useState } from 'react'
import {
ActivityIndicator,
Image,
Pressable,
ScrollView,
Text,
View,
useWindowDimensions
} from 'react-native'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Alert, BackHandler, Pressable, Text, View, useWindowDimensions } from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { ChevronLeft } from 'lucide-react-native'
import { ChevronLeft, Save } from 'lucide-react-native'
import { getWorktreeLabel } from '../session/worktree-label'
import { colors, spacing } from '../theme/mobile-theme'
import { useForceReconnect, useHostClient } from '../transport/client-context'
import {
loadMobileFilePreview,
previewError,
saveMobileTerminalArtifactPreview,
type MobileFilePreviewSource,
type MobileFilePreviewResult
} from './mobile-file-preview-request'
import { MobileFileMarkdownPreview } from './MobileFileMarkdownPreview'
import { MobileFilePreviewSourceText } from './MobileFilePreviewSourceText'
import { MobileFilePreviewBody } from './MobileFilePreviewBody'
import {
displayNameFromPreviewPath,
type MobileFilePreviewRouteState
} from './mobile-file-preview-route'
import { previewSourceFromRoute, sourceKeyForPreview } from './mobile-file-preview-source'
import { normalizeMobileFilePreviewLineColumn } from './mobile-file-preview-line-column'
import {
hasUnsavedMobileTerminalArtifactDraft,
isEditableMobileTerminalArtifactPreview,
shouldKeepDirtyDraftOnPreviewLoadResult
} from './mobile-file-preview-editability'
import { filePreviewStyles as styles } from './mobile-file-preview-styles'
type Props = {
@@ -39,30 +39,116 @@ export function MobileFilePreviewScreen({ route }: Props) {
const [preview, setPreview] = useState<MobileFilePreviewResult>(() =>
route.ok ? { status: 'loading', message: 'Loading preview...' } : previewError(route.message)
)
const [draftContent, setDraftContent] = useState('')
const [savedContent, setSavedContent] = useState('')
const [saveError, setSaveError] = useState('')
const [saving, setSaving] = useState(false)
const draftContentRef = useRef(draftContent)
const savedContentRef = useRef(savedContent)
const draftSourceKeyRef = useRef<string | null>(null)
const { width, height } = useWindowDimensions()
const routePreviewSource = useMemo(
() => (previewParams ? previewSourceFromRoute(previewParams) : null),
[previewParams]
)
const [previewSource, setPreviewSource] = useState<MobileFilePreviewSource | null>(
routePreviewSource
)
const previewSourceKey = useMemo(() => sourceKeyForPreview(previewSource), [previewSource])
const routePreviewSourceKey = useMemo(
() => sourceKeyForPreview(routePreviewSource),
[routePreviewSource]
)
const previewSourceKeyRef = useRef(previewSourceKey)
const lineColumn = useMemo(
() =>
previewParams
? normalizeMobileFilePreviewLineColumn(previewParams.line, previewParams.column)
: null,
[previewParams]
)
useEffect(() => {
setPreviewSource(routePreviewSource)
draftSourceKeyRef.current = null
}, [routePreviewSource])
useEffect(() => {
previewSourceKeyRef.current = previewSourceKey
}, [previewSourceKey])
useEffect(() => {
draftContentRef.current = draftContent
}, [draftContent])
useEffect(() => {
savedContentRef.current = savedContent
}, [savedContent])
const loadPreview = useCallback(async () => {
if (!previewParams) {
const loadSourceKey = previewSourceKey
if (!previewParams || !previewSource || loadSourceKey !== routePreviewSourceKey) {
setPreview(previewError(route.ok ? 'Unable to load preview' : route.message))
return
}
const preserveDirtyDraft =
draftSourceKeyRef.current === previewSourceKey &&
draftContentRef.current !== savedContentRef.current
if (!client || connState !== 'connected') {
if (preserveDirtyDraft) {
setSaveError('Waiting for desktop...')
return
}
setPreview({ status: 'waiting', message: 'Waiting for desktop...', reconnect: true })
return
}
setPreview({ status: 'loading', message: 'Loading preview...' })
if (!preserveDirtyDraft) {
setPreview({ status: 'loading', message: 'Loading preview...' })
}
setSaveError('')
try {
const result = await loadMobileFilePreview(
client,
previewParams.worktreeId,
previewParams.relativePath
)
const result = await loadMobileFilePreview(client, previewSource, undefined, {
onTerminalArtifactSourceRefreshed: setPreviewSource,
refreshGrant: true
})
if (previewSourceKeyRef.current !== loadSourceKey) {
return
}
if (shouldKeepDirtyDraftOnPreviewLoadResult(preserveDirtyDraft, result)) {
setSaveError(result.message)
return
}
const loadedContent =
result.status === 'ready' && result.kind !== 'image'
? result.content
: result.status === 'empty'
? ''
: null
if (loadedContent !== null) {
if (!preserveDirtyDraft) {
setDraftContent(loadedContent)
setSavedContent(loadedContent)
}
draftSourceKeyRef.current = previewSourceKey
}
setPreview(result)
} catch (err) {
const message = err instanceof Error ? err.message : 'Unable to load preview'
if (preserveDirtyDraft) {
setSaveError(message)
return
}
setPreview(previewError(message))
}
}, [client, connState, previewParams, route])
}, [
client,
connState,
previewParams,
previewSource,
previewSourceKey,
route,
routePreviewSourceKey
])
useEffect(() => {
void loadPreview()
@@ -84,12 +170,70 @@ export function MobileFilePreviewScreen({ route }: Props) {
void loadPreview()
}, [connState, forceReconnect, loadPreview, preview, previewParams])
const title = previewParams?.name ?? displayNameFromPreviewPath(previewParams?.relativePath ?? '')
const displayPath =
previewParams?.source === 'terminalArtifact'
? (previewParams.absolutePath ?? '')
: (previewParams?.relativePath ?? '')
const title = previewParams?.name ?? displayNameFromPreviewPath(displayPath)
const worktreeLabel = getWorktreeLabel(
previewParams?.worktreeName,
previewParams?.worktreeId ?? ''
)
const meta = previewParams ? `${worktreeLabel} - ${previewParams.relativePath}` : 'Preview'
const meta = previewParams ? `${worktreeLabel} - ${displayPath}` : 'Preview'
const isEditableTerminalArtifact =
previewSource?.source === 'terminalArtifact' && isEditableMobileTerminalArtifactPreview(preview)
const canSaveArtifact =
isEditableTerminalArtifact &&
draftSourceKeyRef.current === previewSourceKey &&
draftContent !== savedContent
const hasUnsavedTerminalArtifactDraft = hasUnsavedMobileTerminalArtifactDraft({
source: previewSource?.source,
draftSourceKey: draftSourceKeyRef.current,
previewSourceKey,
draftContent,
savedContent
})
const saveArtifact = useCallback(async () => {
if (!client || previewSource?.source !== 'terminalArtifact' || !canSaveArtifact || saving) {
return
}
setSaving(true)
setSaveError('')
try {
const result = await saveMobileTerminalArtifactPreview(client, previewSource, draftContent, {
baseContent: savedContent,
onTerminalArtifactSourceRefreshed: setPreviewSource
})
if (result.status === 'saved') {
setSavedContent(draftContent)
} else {
setSaveError(saveErrorMessageFromPreviewResult(result))
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Unable to save file'
setSaveError(message)
} finally {
setSaving(false)
}
}, [canSaveArtifact, client, draftContent, previewSource, savedContent, saving])
const requestBack = useCallback(() => {
if (!hasUnsavedTerminalArtifactDraft) {
router.back()
return true
}
Alert.alert('Discard changes?', 'Unsaved edits will be lost.', [
{ text: 'Stay', style: 'cancel' },
{ text: 'Discard', style: 'destructive', onPress: () => router.back() }
])
return true
}, [hasUnsavedTerminalArtifactDraft, router])
useEffect(() => {
const subscription = BackHandler.addEventListener('hardwareBackPress', requestBack)
return () => subscription.remove()
}, [requestBack])
return (
<View style={styles.container}>
@@ -97,7 +241,7 @@ export function MobileFilePreviewScreen({ route }: Props) {
<View style={styles.topBar}>
<Pressable
style={({ pressed }) => [styles.backButton, pressed && styles.backButtonPressed]}
onPress={() => router.back()}
onPress={requestBack}
hitSlop={8}
accessibilityLabel="Back to files"
>
@@ -111,110 +255,40 @@ export function MobileFilePreviewScreen({ route }: Props) {
{meta}
</Text>
</View>
{isEditableTerminalArtifact ? (
<Pressable
style={[styles.saveButton, (!canSaveArtifact || saving) && styles.saveButtonDisabled]}
onPress={() => void saveArtifact()}
disabled={!canSaveArtifact || saving}
accessibilityLabel="Save terminal artifact"
>
<Save size={18} color={colors.textPrimary} strokeWidth={2.2} />
</Pressable>
) : null}
</View>
</SafeAreaView>
{renderPreviewBody(preview, {
relativePath: previewParams?.relativePath ?? '',
title: title || 'File',
imageWidth: Math.max(1, width - spacing.md * 2),
imageHeight: Math.max(240, height - 160),
onImageError: () =>
setPreview({ status: 'error', message: 'Unable to load preview', reconnect: false }),
onRetry: retry
})}
<MobileFilePreviewBody
preview={preview}
relativePath={displayPath}
title={title || 'File'}
editable={isEditableTerminalArtifact}
draftContent={draftContent}
saveError={saveError}
lineColumn={lineColumn}
imageWidth={Math.max(1, width - spacing.md * 2)}
imageHeight={Math.max(240, height - 160)}
onDraftChange={setDraftContent}
onImageError={() =>
setPreview({ status: 'error', message: 'Unable to load preview', reconnect: false })
}
onRetry={retry}
/>
</View>
)
}
function renderPreviewBody(
preview: MobileFilePreviewResult,
options: {
relativePath: string
title: string
imageWidth: number
imageHeight: number
onImageError: () => void
onRetry: () => void
}
) {
if (preview.status === 'loading') {
return (
<View style={styles.state}>
<ActivityIndicator size="small" color={colors.textSecondary} />
<Text style={styles.stateText}>{preview.message}</Text>
</View>
)
}
if (preview.status === 'error' || preview.status === 'waiting') {
return (
<View style={styles.state}>
<Text style={styles.errorText}>{preview.message}</Text>
<Pressable style={styles.retryButton} onPress={options.onRetry}>
<Text style={styles.retryText}>Retry</Text>
</Pressable>
</View>
)
}
if (preview.status === 'empty') {
return (
<View style={styles.state}>
<Text style={styles.stateText}>Empty file</Text>
</View>
)
}
if (preview.kind === 'image') {
return (
<View style={styles.imageContainer}>
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.imageScrollContent}
maximumZoomScale={4}
minimumZoomScale={1}
centerContent
>
<Image
source={{ uri: preview.dataUri }}
style={[styles.image, { width: options.imageWidth, height: options.imageHeight }]}
resizeMode="contain"
onError={options.onImageError}
accessibilityLabel={`${options.title} image`}
/>
</ScrollView>
</View>
)
}
if (preview.kind === 'markdown') {
return (
<MobileFileMarkdownPreview
relativePath={options.relativePath}
content={preview.content}
truncated={preview.truncated}
byteLength={preview.byteLength}
/>
)
}
if (preview.kind === 'html') {
return (
<MobileFilePreviewSourceText
relativePath={options.relativePath}
content={preview.content}
truncated={preview.truncated}
byteLength={preview.byteLength}
/>
)
}
return (
<MobileFilePreviewSourceText
relativePath={options.relativePath}
content={preview.content}
truncated={preview.truncated}
byteLength={preview.byteLength}
/>
)
function saveErrorMessageFromPreviewResult(result: MobileFilePreviewResult): string {
return result.status === 'error' || result.status === 'waiting'
? result.message
: 'Unable to save file'
}
@@ -1,7 +1,8 @@
import { useMemo } from 'react'
import { useEffect, useMemo, useRef } from 'react'
import { ScrollView, Text } from 'react-native'
import { MobileSyntaxSegments } from '../components/MobileSyntaxSegments'
import { formatPreviewByteLength } from './mobile-file-preview-request'
import { scrollOffsetForPreviewLine } from './mobile-file-preview-line-column'
import { buildMobileFilePreviewSyntax } from './mobile-file-preview-syntax'
import { filePreviewStyles as styles } from './mobile-file-preview-styles'
@@ -9,19 +10,44 @@ export function MobileFilePreviewSourceText({
relativePath,
content,
truncated,
byteLength
byteLength,
initialLine
}: {
relativePath: string
content: string
truncated?: boolean
byteLength?: number
initialLine?: number
}) {
const scrollRef = useRef<ScrollView>(null)
const revealedRef = useRef(false)
const syntax = useMemo(
() => buildMobileFilePreviewSyntax(relativePath, content),
[content, relativePath]
)
useEffect(() => {
revealedRef.current = false
}, [content, initialLine, relativePath])
const revealInitialLine = () => {
if (!initialLine || revealedRef.current) {
return
}
revealedRef.current = true
scrollRef.current?.scrollTo({
y: scrollOffsetForPreviewLine(initialLine),
animated: false
})
}
return (
<ScrollView style={styles.scroll} contentContainerStyle={styles.textContent}>
<ScrollView
ref={scrollRef}
style={styles.scroll}
contentContainerStyle={styles.textContent}
onContentSizeChange={revealInitialLine}
>
{truncated ? (
<MobileFilePreviewTruncatedNote byteLength={byteLength ?? content.length} />
) : null}
@@ -0,0 +1,38 @@
import type {
MobileFilePreviewResult,
MobileFilePreviewSource
} from './mobile-file-preview-request'
export function isEditableMobileTerminalArtifactPreview(preview: MobileFilePreviewResult): boolean {
return (
(preview.status === 'ready' && preview.kind !== 'image' && !preview.truncated) ||
preview.status === 'empty'
)
}
export function hasUnsavedMobileTerminalArtifactDraft({
source,
draftSourceKey,
previewSourceKey,
draftContent,
savedContent
}: {
source?: MobileFilePreviewSource['source']
draftSourceKey: string | null
previewSourceKey: string | null
draftContent: string
savedContent: string
}): boolean {
return (
source === 'terminalArtifact' &&
draftSourceKey === previewSourceKey &&
draftContent !== savedContent
)
}
export function shouldKeepDirtyDraftOnPreviewLoadResult(
preserveDirtyDraft: boolean,
result: MobileFilePreviewResult
): result is Extract<MobileFilePreviewResult, { status: 'error' | 'waiting' }> {
return preserveDirtyDraft && (result.status === 'error' || result.status === 'waiting')
}
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import {
normalizeMobileFilePreviewLineColumn,
scrollOffsetForPreviewLine,
textOffsetForLineColumn
} from './mobile-file-preview-line-column'
describe('mobile-file-preview-line-column', () => {
it('normalizes positive route params and rejects invalid lines', () => {
expect(normalizeMobileFilePreviewLineColumn('12', '3')).toEqual({ line: 12, column: 3 })
expect(normalizeMobileFilePreviewLineColumn('12', undefined)).toEqual({
line: 12,
column: null
})
expect(normalizeMobileFilePreviewLineColumn('0', '3')).toBeNull()
expect(normalizeMobileFilePreviewLineColumn('12x', '3')).toBeNull()
expect(normalizeMobileFilePreviewLineColumn(undefined, '3')).toBeNull()
})
it('converts one-based line and column values to a text offset', () => {
const content = 'alpha\nbravo\ncharlie'
expect(textOffsetForLineColumn(content, { line: 1, column: 1 })).toBe(0)
expect(textOffsetForLineColumn(content, { line: 2, column: 3 })).toBe(8)
expect(textOffsetForLineColumn(content, { line: 3, column: null })).toBe(12)
})
it('caps out-of-range line and column values safely', () => {
const content = 'alpha\nbravo'
expect(textOffsetForLineColumn(content, { line: 99, column: 1 })).toBe(content.length)
expect(textOffsetForLineColumn(content, { line: 1, column: 99 })).toBe(5)
})
it('maps line numbers to the mono preview scroll offset', () => {
expect(scrollOffsetForPreviewLine(1)).toBe(0)
expect(scrollOffsetForPreviewLine(4)).toBe(57)
})
})
@@ -0,0 +1,51 @@
export type MobileFilePreviewLineColumn = {
line: number
column: number | null
}
const MOBILE_FILE_PREVIEW_TEXT_LINE_HEIGHT = 19
export function normalizeMobileFilePreviewLineColumn(
line: string | undefined,
column: string | undefined
): MobileFilePreviewLineColumn | null {
const normalizedLine = parsePositiveInteger(line)
if (normalizedLine === null) {
return null
}
return {
line: normalizedLine,
column: parsePositiveInteger(column)
}
}
export function textOffsetForLineColumn(
content: string,
target: MobileFilePreviewLineColumn
): number {
let lineStart = 0
for (let currentLine = 1; currentLine < target.line; currentLine += 1) {
const nextBreak = content.indexOf('\n', lineStart)
if (nextBreak === -1) {
return content.length
}
lineStart = nextBreak + 1
}
const lineEnd = content.indexOf('\n', lineStart)
const cappedLineEnd = lineEnd === -1 ? content.length : lineEnd
const columnOffset = Math.max(0, (target.column ?? 1) - 1)
return Math.min(cappedLineEnd, lineStart + columnOffset)
}
export function scrollOffsetForPreviewLine(line: number): number {
return Math.max(0, line - 1) * MOBILE_FILE_PREVIEW_TEXT_LINE_HEIGHT
}
function parsePositiveInteger(value: string | undefined): number | null {
if (!value || !/^\d+$/.test(value)) {
return null
}
const parsed = Number.parseInt(value, 10)
return Number.isSafeInteger(parsed) && parsed >= 1 ? parsed : null
}
@@ -4,7 +4,8 @@ import {
createMobileFilePreviewRequest,
formatPreviewByteLength,
loadMobileFilePreview,
normalizeMobileFilePreviewResponse
normalizeMobileFilePreviewResponse,
saveMobileTerminalArtifactPreview
} from './mobile-file-preview-request'
function ok(result: unknown): RpcSuccess {
@@ -21,6 +22,12 @@ function clientWith(response: RpcResponse) {
}
}
function clientWithResponses(responses: RpcResponse[]) {
return {
sendRequest: vi.fn(async () => responses.shift()!)
}
}
describe('mobile-file-preview-request', () => {
it('selects readPreview for raster images and read for text-like files', () => {
expect(createMobileFilePreviewRequest('wt-1', 'assets/logo.png')).toEqual({
@@ -54,6 +61,471 @@ describe('mobile-file-preview-request', () => {
expect(client.sendRequest).not.toHaveBeenCalledWith('files.open', expect.anything())
})
it('loads terminal artifacts through grant-scoped artifact RPCs', async () => {
const client = clientWith(ok({ content: '{"ok":true}', truncated: false, byteLength: 11 }))
await expect(
loadMobileFilePreview(client, {
source: 'terminalArtifact',
worktreeId: 'wt-1',
absolutePath: '/tmp/result.json',
grantId: 'grant-1'
})
).resolves.toMatchObject({ status: 'ready', kind: 'text', content: '{"ok":true}' })
expect(client.sendRequest).toHaveBeenCalledWith('files.readTerminalArtifact', {
worktree: 'id:wt-1',
absolutePath: '/tmp/result.json',
grantId: 'grant-1'
})
expect(client.sendRequest).not.toHaveBeenCalledWith('files.read', expect.anything())
})
it('refreshes an expired terminal artifact grant and retries the read once', async () => {
const client = clientWithResponses([
fail('terminal_file_grant_expired'),
ok({
worktree: 'wt-1',
relativePath: null,
absolutePath: '/tmp/result.json',
exists: true,
isDirectory: false,
openTarget: {
kind: 'absolute-file',
absolutePath: '/tmp/result.json',
grantId: 'grant-2'
}
}),
ok({ content: '{"ok":true}', truncated: false, byteLength: 11 })
])
const onTerminalArtifactSourceRefreshed = vi.fn()
await expect(
loadMobileFilePreview(
client,
{
source: 'terminalArtifact',
worktreeId: 'wt-1',
absolutePath: '/tmp/result.json',
grantId: 'grant-1',
terminalHandle: 'term-1',
pathText: 'result.json',
cwd: '/tmp/run'
},
undefined,
{ onTerminalArtifactSourceRefreshed }
)
).resolves.toMatchObject({ status: 'ready', kind: 'text', content: '{"ok":true}' })
expect(client.sendRequest).toHaveBeenNthCalledWith(1, 'files.readTerminalArtifact', {
worktree: 'id:wt-1',
absolutePath: '/tmp/result.json',
grantId: 'grant-1'
})
expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'files.resolveTerminalPath', {
worktree: 'id:wt-1',
pathText: 'result.json',
cwd: '/tmp/run',
terminal: 'term-1'
})
expect(client.sendRequest).toHaveBeenNthCalledWith(3, 'files.readTerminalArtifact', {
worktree: 'id:wt-1',
absolutePath: '/tmp/result.json',
grantId: 'grant-2'
})
expect(onTerminalArtifactSourceRefreshed).toHaveBeenCalledWith({
source: 'terminalArtifact',
worktreeId: 'wt-1',
absolutePath: '/tmp/result.json',
grantId: 'grant-2',
terminalHandle: 'term-1',
pathText: 'result.json',
cwd: '/tmp/run'
})
})
it('refreshes a stale terminal artifact grant and retries the read once', async () => {
const client = clientWithResponses([
fail('terminal_file_grant_stale'),
ok({
worktree: 'wt-1',
relativePath: null,
absolutePath: '/tmp/result.json',
exists: true,
isDirectory: false,
openTarget: {
kind: 'absolute-file',
absolutePath: '/tmp/result.json',
grantId: 'grant-2'
}
}),
ok({ content: '{"ok":true}', truncated: false, byteLength: 11 })
])
await expect(
loadMobileFilePreview(client, {
source: 'terminalArtifact',
worktreeId: 'wt-1',
absolutePath: '/tmp/result.json',
grantId: 'grant-1',
terminalHandle: 'term-1'
})
).resolves.toMatchObject({ status: 'ready', kind: 'text', content: '{"ok":true}' })
expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'files.resolveTerminalPath', {
worktree: 'id:wt-1',
pathText: '/tmp/result.json',
terminal: 'term-1'
})
})
it.each([
['terminal_file_grant_expired'],
['terminal_file_grant_mismatch'],
['terminal_file_grant_stale']
])(
'does not refresh a %s terminal artifact grant when disabled for a dirty editor',
async (error) => {
const client = clientWithResponses([fail(error)])
await expect(
loadMobileFilePreview(
client,
{
source: 'terminalArtifact',
worktreeId: 'wt-1',
absolutePath: '/tmp/result.json',
grantId: 'grant-1'
},
undefined,
{ refreshGrant: false }
)
).resolves.toEqual({
status: 'error',
message: 'Reload preview before saving',
reconnect: false
})
expect(client.sendRequest).toHaveBeenCalledTimes(1)
expect(client.sendRequest).toHaveBeenCalledWith('files.readTerminalArtifact', {
worktree: 'id:wt-1',
absolutePath: '/tmp/result.json',
grantId: 'grant-1'
})
}
)
it('saves terminal artifacts through the same exact-path grant', async () => {
const client = clientWith(ok({ ok: true }))
await expect(
saveMobileTerminalArtifactPreview(
client,
{
source: 'terminalArtifact',
worktreeId: 'wt-1',
absolutePath: '/tmp/result.json',
grantId: 'grant-1',
terminalHandle: 'term-1',
pathText: 'result.json',
cwd: '/tmp/run'
},
'{"ok":false}'
)
).resolves.toEqual({ status: 'saved' })
expect(client.sendRequest).toHaveBeenCalledWith('files.writeTerminalArtifact', {
worktree: 'id:wt-1',
absolutePath: '/tmp/result.json',
grantId: 'grant-1',
content: '{"ok":false}'
})
})
it('does not refresh and retry a failed terminal artifact save without a base content check', async () => {
const client = clientWithResponses([fail('terminal_file_grant_mismatch')])
await expect(
saveMobileTerminalArtifactPreview(
client,
{
source: 'terminalArtifact',
worktreeId: 'wt-1',
absolutePath: '/tmp/result.json',
grantId: 'grant-1',
terminalHandle: 'term-1',
pathText: 'result.json',
cwd: '/tmp/run'
},
'{"ok":false}'
)
).resolves.toEqual({
status: 'error',
message: 'Reload preview before saving',
reconnect: false
})
expect(client.sendRequest).toHaveBeenCalledTimes(1)
expect(client.sendRequest).toHaveBeenCalledWith('files.writeTerminalArtifact', {
worktree: 'id:wt-1',
absolutePath: '/tmp/result.json',
grantId: 'grant-1',
content: '{"ok":false}'
})
})
it('refreshes an expired terminal artifact grant and retries save when the file still matches the base content', async () => {
const client = clientWithResponses([
fail('terminal_file_grant_expired'),
ok({
worktree: 'wt-1',
relativePath: null,
absolutePath: '/tmp/result.json',
exists: true,
isDirectory: false,
openTarget: {
kind: 'absolute-file',
absolutePath: '/tmp/result.json',
grantId: 'grant-2'
}
}),
ok({ content: '{"ok":true}', truncated: false, byteLength: 11 }),
ok({ ok: true })
])
const onTerminalArtifactSourceRefreshed = vi.fn()
await expect(
saveMobileTerminalArtifactPreview(
client,
{
source: 'terminalArtifact',
worktreeId: 'wt-1',
absolutePath: '/tmp/result.json',
grantId: 'grant-1',
terminalHandle: 'term-1',
pathText: 'result.json',
cwd: '/tmp/run'
},
'{"ok":false}',
{ baseContent: '{"ok":true}', onTerminalArtifactSourceRefreshed }
)
).resolves.toEqual({ status: 'saved' })
expect(client.sendRequest).toHaveBeenNthCalledWith(1, 'files.readTerminalArtifact', {
worktree: 'id:wt-1',
absolutePath: '/tmp/result.json',
grantId: 'grant-1'
})
expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'files.resolveTerminalPath', {
worktree: 'id:wt-1',
pathText: 'result.json',
cwd: '/tmp/run',
terminal: 'term-1'
})
expect(client.sendRequest).toHaveBeenNthCalledWith(3, 'files.readTerminalArtifact', {
worktree: 'id:wt-1',
absolutePath: '/tmp/result.json',
grantId: 'grant-2'
})
expect(client.sendRequest).toHaveBeenNthCalledWith(4, 'files.writeTerminalArtifact', {
worktree: 'id:wt-1',
absolutePath: '/tmp/result.json',
grantId: 'grant-2',
content: '{"ok":false}'
})
expect(onTerminalArtifactSourceRefreshed).toHaveBeenCalledWith({
source: 'terminalArtifact',
worktreeId: 'wt-1',
absolutePath: '/tmp/result.json',
grantId: 'grant-2',
terminalHandle: 'term-1',
pathText: 'result.json',
cwd: '/tmp/run'
})
})
it('keeps the dirty draft when a refreshed terminal artifact save finds changed remote content', async () => {
const client = clientWithResponses([
fail('terminal_file_grant_stale'),
ok({
worktree: 'wt-1',
relativePath: null,
absolutePath: '/tmp/result.json',
exists: true,
isDirectory: false,
openTarget: {
kind: 'absolute-file',
absolutePath: '/tmp/result.json',
grantId: 'grant-2'
}
}),
ok({ content: '{"ok":"changed"}', truncated: false, byteLength: 16 })
])
const onTerminalArtifactSourceRefreshed = vi.fn()
await expect(
saveMobileTerminalArtifactPreview(
client,
{
source: 'terminalArtifact',
worktreeId: 'wt-1',
absolutePath: '/tmp/result.json',
grantId: 'grant-1',
terminalHandle: 'term-1'
},
'{"ok":false}',
{ baseContent: '{"ok":true}', onTerminalArtifactSourceRefreshed }
)
).resolves.toEqual({
status: 'error',
message: 'File changed on desktop. Reload preview before saving',
reconnect: false
})
expect(client.sendRequest).toHaveBeenCalledTimes(3)
expect(client.sendRequest).not.toHaveBeenCalledWith('files.writeTerminalArtifact', {
worktree: 'id:wt-1',
absolutePath: '/tmp/result.json',
grantId: 'grant-2',
content: '{"ok":false}'
})
expect(onTerminalArtifactSourceRefreshed).not.toHaveBeenCalled()
})
it('reports a failed refreshed artifact read instead of treating it as changed desktop content', async () => {
const client = clientWithResponses([
fail('terminal_file_grant_stale'),
ok({
worktree: 'wt-1',
relativePath: null,
absolutePath: '/tmp/result.json',
exists: true,
isDirectory: false,
openTarget: {
kind: 'absolute-file',
absolutePath: '/tmp/result.json',
grantId: 'grant-2'
}
}),
fail('provider unavailable')
])
await expect(
saveMobileTerminalArtifactPreview(
client,
{
source: 'terminalArtifact',
worktreeId: 'wt-1',
absolutePath: '/tmp/result.json',
grantId: 'grant-1',
terminalHandle: 'term-1'
},
'{"ok":false}',
{ baseContent: '{"ok":true}' }
)
).resolves.toEqual({
status: 'error',
message: 'Unable to reach the desktop filesystem',
reconnect: true
})
expect(client.sendRequest).toHaveBeenCalledTimes(3)
expect(client.sendRequest).not.toHaveBeenCalledWith('files.writeTerminalArtifact', {
worktree: 'id:wt-1',
absolutePath: '/tmp/result.json',
grantId: 'grant-2',
content: '{"ok":false}'
})
})
it('reports a malformed refreshed artifact read instead of treating it as changed desktop content', async () => {
const client = clientWithResponses([
fail('terminal_file_grant_stale'),
ok({
worktree: 'wt-1',
relativePath: null,
absolutePath: '/tmp/result.json',
exists: true,
isDirectory: false,
openTarget: {
kind: 'absolute-file',
absolutePath: '/tmp/result.json',
grantId: 'grant-2'
}
}),
ok({ truncated: false, byteLength: 11 })
])
await expect(
saveMobileTerminalArtifactPreview(
client,
{
source: 'terminalArtifact',
worktreeId: 'wt-1',
absolutePath: '/tmp/result.json',
grantId: 'grant-1',
terminalHandle: 'term-1'
},
'{"ok":false}',
{ baseContent: '{"ok":true}' }
)
).resolves.toEqual({
status: 'error',
message: 'Unable to load preview',
reconnect: false
})
expect(client.sendRequest).toHaveBeenCalledTimes(3)
expect(client.sendRequest).not.toHaveBeenCalledWith('files.writeTerminalArtifact', {
worktree: 'id:wt-1',
absolutePath: '/tmp/result.json',
grantId: 'grant-2',
content: '{"ok":false}'
})
})
it('does not retry a dirty save when grant refresh resolves to a different artifact', async () => {
const client = clientWithResponses([
fail('terminal_file_grant_expired'),
ok({
worktree: 'wt-1',
relativePath: null,
absolutePath: '/tmp/b.json',
exists: true,
isDirectory: false,
openTarget: {
kind: 'absolute-file',
absolutePath: '/tmp/b.json',
grantId: 'grant-2'
}
})
])
const onTerminalArtifactSourceRefreshed = vi.fn()
await expect(
saveMobileTerminalArtifactPreview(
client,
{
source: 'terminalArtifact',
worktreeId: 'wt-1',
absolutePath: '/tmp/a.json',
grantId: 'grant-1',
terminalHandle: 'term-1',
pathText: '/tmp/link.json'
},
'{"ok":false}',
{ baseContent: '{"ok":true}', onTerminalArtifactSourceRefreshed }
)
).resolves.toEqual({
status: 'error',
message: 'Reload preview before saving',
reconnect: false
})
expect(client.sendRequest).toHaveBeenCalledTimes(2)
expect(onTerminalArtifactSourceRefreshed).not.toHaveBeenCalled()
})
it.each([
['missing isBinary', { content: 'aW1hZ2U=', isImage: true, mimeType: 'image/png' }],
['missing isImage', { content: 'aW1hZ2U=', isBinary: true, mimeType: 'image/png' }],
@@ -116,6 +588,7 @@ describe('mobile-file-preview-request', () => {
true
],
['provider unavailable', 'Unable to reach the desktop filesystem', true],
['terminal_file_grant_stale', 'Reload preview before saving', false],
['permission denied', 'Unable to load preview', false]
])('maps preview failure %s', (message, expected, reconnect) => {
expect(normalizeMobileFilePreviewResponse('src/app.ts', fail(message))).toEqual({
+211 -150
View File
@@ -1,195 +1,256 @@
import { classifyMobileArtifact } from '../session/mobile-artifact-kind'
import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
import type { RpcFailure, RpcResponse } from '../transport/types'
import type { RpcClient } from '../transport/rpc-client'
import { isMarkdownPath } from './file-tree'
import {
normalizeMobileFilePreviewResponse,
previewError,
type MobileFilePreviewResult
} from './mobile-file-preview-response'
import {
refreshTerminalArtifactSourceAfterGrantFailure,
type MobileTerminalArtifactPreviewSource,
type TerminalArtifactRetryOptions
} from './mobile-terminal-artifact-grant-refresh'
export {
formatPreviewByteLength,
normalizeMobileFilePreviewResponse,
previewError
} from './mobile-file-preview-response'
export type {
MobileFilePreviewResult,
MobileFilePreviewTextKind
} from './mobile-file-preview-response'
export type MobileFilePreviewReadMethod = 'files.read' | 'files.readPreview'
export type MobileTerminalArtifactPreviewReadMethod =
| 'files.readTerminalArtifact'
| 'files.readTerminalArtifactPreview'
export type MobileFilePreviewSource =
| {
source: 'worktree'
worktreeId: string
relativePath: string
}
| MobileTerminalArtifactPreviewSource
export type MobileFilePreviewRequest = {
method: MobileFilePreviewReadMethod
method: MobileFilePreviewReadMethod | MobileTerminalArtifactPreviewReadMethod
params: {
worktree: string
relativePath: string
relativePath?: string
absolutePath?: string
grantId?: string
}
}
export type MobileFilePreviewTextKind = 'html' | 'markdown' | 'text'
export type MobileFilePreviewResult =
| {
status: 'loading'
message: string
}
| {
status: 'waiting'
message: string
reconnect: true
}
| {
status: 'ready'
kind: 'image'
dataUri: string
}
| {
status: 'ready'
kind: MobileFilePreviewTextKind
content: string
truncated: boolean
byteLength: number
}
| {
status: 'empty'
kind: MobileFilePreviewTextKind
}
| {
status: 'error'
message: string
reconnect: boolean
}
type MobileFilePreviewClient = Pick<RpcClient, 'sendRequest'>
type TerminalArtifactSource = MobileTerminalArtifactPreviewSource
type TerminalArtifactSaveOptions = TerminalArtifactRetryOptions & {
baseContent?: string
}
export function createMobileFilePreviewRequest(
worktreeId: string,
relativePath: string
worktreeIdOrSource: string | MobileFilePreviewSource,
relativePath?: string
): MobileFilePreviewRequest {
const source =
typeof worktreeIdOrSource === 'string'
? { source: 'worktree' as const, worktreeId: worktreeIdOrSource, relativePath: relativePath! }
: worktreeIdOrSource
if (source.source === 'terminalArtifact') {
const method =
classifyMobileArtifact(source.absolutePath) === 'image'
? 'files.readTerminalArtifactPreview'
: 'files.readTerminalArtifact'
return {
method,
params: {
worktree: `id:${source.worktreeId}`,
absolutePath: source.absolutePath,
grantId: source.grantId
}
}
}
return {
method: classifyMobileArtifact(relativePath) === 'image' ? 'files.readPreview' : 'files.read',
method:
classifyMobileArtifact(source.relativePath) === 'image' ? 'files.readPreview' : 'files.read',
params: {
worktree: `id:${worktreeId}`,
relativePath
worktree: `id:${source.worktreeId}`,
relativePath: source.relativePath
}
}
}
export async function loadMobileFilePreview(
client: MobileFilePreviewClient,
worktreeId: string,
relativePath: string
worktreeIdOrSource: string | MobileFilePreviewSource,
relativePath?: string,
options: TerminalArtifactRetryOptions = {}
): Promise<MobileFilePreviewResult> {
const request = createMobileFilePreviewRequest(worktreeId, relativePath)
const response = await client.sendRequest(request.method, request.params)
return normalizeMobileFilePreviewResponse(relativePath, response)
let source = worktreeIdOrSource
let request = createMobileFilePreviewRequest(source, relativePath)
let response = await client.sendRequest(request.method, request.params)
if (!response.ok && typeof source !== 'string' && source.source === 'terminalArtifact') {
const refreshed = await refreshTerminalArtifactSourceAfterGrantFailure(
client,
source,
response,
options
)
if (refreshed) {
source = refreshed
options.onTerminalArtifactSourceRefreshed?.(refreshed)
request = createMobileFilePreviewRequest(source, relativePath)
response = await client.sendRequest(request.method, request.params)
}
}
const previewPath = typeof source === 'string' ? relativePath! : previewPathForSource(source)
return normalizeMobileFilePreviewResponse(previewPath, response)
}
export function normalizeMobileFilePreviewResponse(
relativePath: string,
response: RpcResponse
): MobileFilePreviewResult {
export async function saveMobileTerminalArtifactPreview(
client: MobileFilePreviewClient,
source: TerminalArtifactSource,
content: string,
options: TerminalArtifactSaveOptions = {}
): Promise<MobileFilePreviewResult | { status: 'saved' }> {
let writeSource = source
if (typeof options.baseContent === 'string') {
const verified = await verifyTerminalArtifactBaseContent(
client,
writeSource,
options.baseContent,
options
)
if (verified.status === 'error') {
return verified.error
}
writeSource = verified.source
if (verified.refreshed) {
options.onTerminalArtifactSourceRefreshed?.(verified.source)
}
}
let response = await writeTerminalArtifactPreview(client, writeSource, content)
if (response.ok) {
return { status: 'saved' }
}
if (typeof options.baseContent !== 'string') {
return previewError(
(response as RpcFailure).error.message || (response as RpcFailure).error.code
)
}
const refreshed = await refreshTerminalArtifactSourceAfterGrantFailure(
client,
writeSource,
response,
options
)
if (!refreshed) {
return previewError(
(response as RpcFailure).error.message || (response as RpcFailure).error.code
)
}
const verified = await verifyTerminalArtifactBaseContent(client, refreshed, options.baseContent, {
refreshGrant: false
})
if (verified.status === 'error') {
return verified.error
}
options.onTerminalArtifactSourceRefreshed?.(refreshed)
writeSource = verified.source
response = await writeTerminalArtifactPreview(client, writeSource, content)
if (!response.ok) {
return previewError(
(response as RpcFailure).error.message || (response as RpcFailure).error.code
)
}
const result = (response as RpcSuccess).result
if (classifyMobileArtifact(relativePath) === 'image') {
return normalizeImagePreviewResult(result)
}
return normalizeTextPreviewResult(relativePath, result)
return { status: 'saved' }
}
export function previewError(message: string): MobileFilePreviewResult {
const normalized = message.toLowerCase()
if (normalized === 'binary_file' || normalized.includes('binary_file')) {
return { status: 'error', message: 'Binary preview unavailable', reconnect: false }
async function verifyTerminalArtifactBaseContent(
client: MobileFilePreviewClient,
source: TerminalArtifactSource,
baseContent: string,
options: TerminalArtifactRetryOptions
): Promise<
| { status: 'ok'; source: TerminalArtifactSource; refreshed: boolean }
| { status: 'error'; error: MobileFilePreviewResult }
> {
let readSource = source
let request = createMobileFilePreviewRequest(readSource)
let response = await client.sendRequest(request.method, request.params)
let refreshed = false
if (!response.ok) {
const nextSource = await refreshTerminalArtifactSourceAfterGrantFailure(
client,
readSource,
response,
options
)
if (!nextSource) {
return {
status: 'error',
error: previewError(
(response as RpcFailure).error.message || (response as RpcFailure).error.code
)
}
}
readSource = nextSource
refreshed = true
request = createMobileFilePreviewRequest(readSource)
response = await client.sendRequest(request.method, request.params)
}
if (normalized === 'file_too_large' || normalized.includes('file_too_large')) {
return { status: 'error', message: 'File too large for mobile preview', reconnect: false }
if (!response.ok) {
return {
status: 'error',
error: previewError(
(response as RpcFailure).error.message || (response as RpcFailure).error.code
)
}
}
if (
normalized.includes('remote connection dropped') ||
normalized.includes('provider unavailable') ||
normalized.includes('disconnected')
) {
return { status: 'error', message: 'Unable to reach the desktop filesystem', reconnect: true }
const latest = normalizeMobileFilePreviewResponse(readSource.absolutePath, response)
if (latest.status === 'error' || latest.status === 'waiting') {
return { status: 'error', error: latest }
}
if (
normalized.includes('enoent') ||
normalized.includes('no such file') ||
normalized.includes('not found') ||
normalized.includes('does not exist')
) {
return { status: 'error', message: 'File not found', reconnect: false }
if (!terminalArtifactPreviewMatchesBase(latest, baseContent)) {
return {
status: 'error',
error: {
status: 'error',
message: 'File changed on desktop. Reload preview before saving',
reconnect: false
}
}
}
return { status: 'error', message: 'Unable to load preview', reconnect: false }
return { status: 'ok', source: readSource, refreshed }
}
export function formatPreviewByteLength(byteLength: number): string {
if (!Number.isFinite(byteLength) || byteLength < 0) {
return 'unknown size'
}
if (byteLength < 1024) {
return `${byteLength} B`
}
if (byteLength < 1024 * 1024) {
return `${Math.round(byteLength / 1024)} KB`
}
return `${(byteLength / (1024 * 1024)).toFixed(1)} MB`
function writeTerminalArtifactPreview(
client: MobileFilePreviewClient,
source: TerminalArtifactSource,
content: string
): Promise<RpcResponse> {
return client.sendRequest('files.writeTerminalArtifact', {
worktree: `id:${source.worktreeId}`,
absolutePath: source.absolutePath,
grantId: source.grantId,
content
})
}
function normalizeImagePreviewResult(result: unknown): MobileFilePreviewResult {
if (!result || typeof result !== 'object') {
return previewError('binary_file')
}
const preview = result as {
content?: unknown
isBinary?: unknown
isImage?: unknown
mimeType?: unknown
}
if (
preview.isBinary !== true ||
preview.isImage !== true ||
typeof preview.mimeType !== 'string' ||
preview.mimeType.length === 0 ||
typeof preview.content !== 'string' ||
preview.content.length === 0
) {
return previewError('binary_file')
}
return {
status: 'ready',
kind: 'image',
dataUri: `data:${preview.mimeType};base64,${preview.content}`
function terminalArtifactPreviewMatchesBase(
preview: MobileFilePreviewResult,
baseContent: string
): boolean {
if (preview.status === 'empty') {
return baseContent.length === 0
}
return preview.status === 'ready' && preview.kind !== 'image' && preview.content === baseContent
}
function normalizeTextPreviewResult(
relativePath: string,
result: unknown
): MobileFilePreviewResult {
if (!result || typeof result !== 'object') {
return previewError('Unable to load preview')
}
const preview = result as {
content?: unknown
truncated?: unknown
byteLength?: unknown
isBinary?: unknown
}
if (preview.isBinary === true) {
return previewError('binary_file')
}
if (typeof preview.content !== 'string') {
return previewError('Unable to load preview')
}
const kind = textKindForPreviewPath(relativePath)
if (preview.content.length === 0) {
return { status: 'empty', kind }
}
return {
status: 'ready',
kind,
content: preview.content,
truncated: preview.truncated === true,
byteLength: typeof preview.byteLength === 'number' ? preview.byteLength : preview.content.length
}
}
function textKindForPreviewPath(relativePath: string): MobileFilePreviewTextKind {
if (classifyMobileArtifact(relativePath) === 'html') {
return 'html'
}
return isMarkdownPath(relativePath) ? 'markdown' : 'text'
function previewPathForSource(source: MobileFilePreviewSource): string {
return source.source === 'terminalArtifact' ? source.absolutePath : source.relativePath
}
@@ -0,0 +1,164 @@
import { classifyMobileArtifact } from '../session/mobile-artifact-kind'
import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
import { isMarkdownPath } from './file-tree'
import { isTerminalArtifactGrantError } from './terminal-artifact-grant-error'
export type MobileFilePreviewTextKind = 'html' | 'markdown' | 'text'
export type MobileFilePreviewResult =
| {
status: 'loading'
message: string
}
| {
status: 'waiting'
message: string
reconnect: true
}
| {
status: 'ready'
kind: 'image'
dataUri: string
}
| {
status: 'ready'
kind: MobileFilePreviewTextKind
content: string
truncated: boolean
byteLength: number
}
| {
status: 'empty'
kind: MobileFilePreviewTextKind
}
| {
status: 'error'
message: string
reconnect: boolean
}
export function normalizeMobileFilePreviewResponse(
relativePath: string,
response: RpcResponse
): MobileFilePreviewResult {
if (!response.ok) {
return previewError(
(response as RpcFailure).error.message || (response as RpcFailure).error.code
)
}
const result = (response as RpcSuccess).result
if (classifyMobileArtifact(relativePath) === 'image') {
return normalizeImagePreviewResult(result)
}
return normalizeTextPreviewResult(relativePath, result)
}
export function previewError(message: string): MobileFilePreviewResult {
const normalized = message.toLowerCase()
if (normalized === 'binary_file' || normalized.includes('binary_file')) {
return { status: 'error', message: 'Binary preview unavailable', reconnect: false }
}
if (normalized === 'file_too_large' || normalized.includes('file_too_large')) {
return { status: 'error', message: 'File too large for mobile preview', reconnect: false }
}
if (isTerminalArtifactGrantError(normalized)) {
return { status: 'error', message: 'Reload preview before saving', reconnect: false }
}
if (
normalized.includes('remote connection dropped') ||
normalized.includes('provider unavailable') ||
normalized.includes('disconnected') ||
normalized.includes('reconnect the ssh target')
) {
return { status: 'error', message: 'Unable to reach the desktop filesystem', reconnect: true }
}
if (
normalized.includes('enoent') ||
normalized.includes('no such file') ||
normalized.includes('not found') ||
normalized.includes('does not exist')
) {
return { status: 'error', message: 'File not found', reconnect: false }
}
return { status: 'error', message: 'Unable to load preview', reconnect: false }
}
export function formatPreviewByteLength(byteLength: number): string {
if (!Number.isFinite(byteLength) || byteLength < 0) {
return 'unknown size'
}
if (byteLength < 1024) {
return `${byteLength} B`
}
if (byteLength < 1024 * 1024) {
return `${Math.round(byteLength / 1024)} KB`
}
return `${(byteLength / (1024 * 1024)).toFixed(1)} MB`
}
function normalizeImagePreviewResult(result: unknown): MobileFilePreviewResult {
if (!result || typeof result !== 'object') {
return previewError('binary_file')
}
const preview = result as {
content?: unknown
isBinary?: unknown
isImage?: unknown
mimeType?: unknown
}
if (
preview.isBinary !== true ||
preview.isImage !== true ||
typeof preview.mimeType !== 'string' ||
preview.mimeType.length === 0 ||
typeof preview.content !== 'string' ||
preview.content.length === 0
) {
return previewError('binary_file')
}
return {
status: 'ready',
kind: 'image',
dataUri: `data:${preview.mimeType};base64,${preview.content}`
}
}
function normalizeTextPreviewResult(
relativePath: string,
result: unknown
): MobileFilePreviewResult {
if (!result || typeof result !== 'object') {
return previewError('Unable to load preview')
}
const preview = result as {
content?: unknown
truncated?: unknown
byteLength?: unknown
isBinary?: unknown
}
if (preview.isBinary === true) {
return previewError('binary_file')
}
if (typeof preview.content !== 'string') {
return previewError('Unable to load preview')
}
const kind = textKindForPreviewPath(relativePath)
if (preview.content.length === 0) {
return { status: 'empty', kind }
}
return {
status: 'ready',
kind,
content: preview.content,
truncated: preview.truncated === true,
byteLength: typeof preview.byteLength === 'number' ? preview.byteLength : preview.content.length
}
}
function textKindForPreviewPath(relativePath: string): MobileFilePreviewTextKind {
if (classifyMobileArtifact(relativePath) === 'html') {
return 'html'
}
return isMarkdownPath(relativePath) ? 'markdown' : 'text'
}
@@ -22,12 +22,70 @@ describe('mobile-file-preview-route', () => {
hostId: 'host-1',
worktreeId: 'wt-1',
relativePath,
source: 'worktree',
line: undefined,
column: undefined,
name: 'note.md',
worktreeName: 'Orca'
}
})
})
it('normalizes worktree preview line and column params', () => {
const route = normalizeMobileFilePreviewRouteParams({
hostId: 'host-1',
worktreeId: 'wt-1',
relativePath: 'src/app.ts',
line: '120',
column: '7'
})
expect(route).toEqual({
ok: true,
params: {
hostId: 'host-1',
worktreeId: 'wt-1',
relativePath: 'src/app.ts',
source: 'worktree',
line: '120',
column: '7',
name: undefined,
worktreeName: undefined
}
})
})
it('normalizes terminal artifact params without requiring a relative path', () => {
const route = normalizeMobileFilePreviewRouteParams({
hostId: 'host-1',
worktreeId: 'wt-1',
source: 'terminalArtifact',
absolutePath: '/tmp/result.json',
grantId: 'grant-1',
terminal: 'term-1',
pathText: 'result.json',
cwd: '/tmp/run',
line: '12',
column: '3'
})
expect(route).toEqual({
ok: true,
params: {
hostId: 'host-1',
worktreeId: 'wt-1',
source: 'terminalArtifact',
absolutePath: '/tmp/result.json',
grantId: 'grant-1',
terminal: 'term-1',
pathText: 'result.json',
cwd: '/tmp/run',
line: '12',
column: '3'
}
})
})
it('rejects missing, empty, or array-valued required params', () => {
expect(normalizeMobileFilePreviewRouteParams({ hostId: 'h', worktreeId: 'w' })).toEqual({
ok: false,
+49 -2
View File
@@ -3,7 +3,15 @@ export type MobileFilePreviewParamValue = string | string[] | undefined
export type MobileFilePreviewRouteParams = {
hostId: string
worktreeId: string
relativePath: string
relativePath?: string
source?: 'worktree' | 'terminalArtifact'
absolutePath?: string
grantId?: string
terminal?: string
pathText?: string
cwd?: string
line?: string
column?: string
name?: string
worktreeName?: string
}
@@ -21,6 +29,14 @@ type RawPreviewRouteParams = {
hostId?: MobileFilePreviewParamValue
worktreeId?: MobileFilePreviewParamValue
relativePath?: MobileFilePreviewParamValue
source?: MobileFilePreviewParamValue
absolutePath?: MobileFilePreviewParamValue
grantId?: MobileFilePreviewParamValue
terminal?: MobileFilePreviewParamValue
pathText?: MobileFilePreviewParamValue
cwd?: MobileFilePreviewParamValue
line?: MobileFilePreviewParamValue
column?: MobileFilePreviewParamValue
name?: MobileFilePreviewParamValue
worktreeName?: MobileFilePreviewParamValue
}
@@ -39,7 +55,35 @@ export function normalizeMobileFilePreviewRouteParams(
const hostId = singleParam(params.hostId)
const worktreeId = singleParam(params.worktreeId)
const relativePath = singleParam(params.relativePath)
if (!hostId || !worktreeId || !relativePath) {
const source = singleParam(params.source)
const absolutePath = singleParam(params.absolutePath)
const grantId = singleParam(params.grantId)
if (!hostId || !worktreeId) {
return { ok: false, message: 'Unable to load preview' }
}
if (source === 'terminalArtifact') {
if (!absolutePath || !grantId) {
return { ok: false, message: 'Unable to load preview' }
}
return {
ok: true,
params: {
hostId,
worktreeId,
source,
absolutePath,
grantId,
terminal: optionalSingleParam(params.terminal),
pathText: optionalSingleParam(params.pathText),
cwd: optionalSingleParam(params.cwd),
line: optionalSingleParam(params.line),
column: optionalSingleParam(params.column),
name: optionalSingleParam(params.name),
worktreeName: optionalSingleParam(params.worktreeName)
}
}
}
if (!relativePath) {
return { ok: false, message: 'Unable to load preview' }
}
return {
@@ -48,6 +92,9 @@ export function normalizeMobileFilePreviewRouteParams(
hostId,
worktreeId,
relativePath,
source: 'worktree',
line: optionalSingleParam(params.line),
column: optionalSingleParam(params.column),
name: optionalSingleParam(params.name),
worktreeName: optionalSingleParam(params.worktreeName)
}
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { sourceKeyForPreview } from './mobile-file-preview-source'
describe('mobile-file-preview-source', () => {
it('uses structured preview keys so colons in paths cannot collide', () => {
const first = sourceKeyForPreview({
source: 'terminalArtifact',
worktreeId: 'wt:1',
absolutePath: '/tmp/a',
grantId: 'grant-1',
terminalHandle: 'b:c'
})
const second = sourceKeyForPreview({
source: 'terminalArtifact',
worktreeId: 'wt',
absolutePath: '1:/tmp/a:b',
grantId: 'grant-2',
terminalHandle: 'c'
})
expect(first).toBe(JSON.stringify(['terminal', 'wt:1', '/tmp/a', 'b:c']))
expect(second).toBe(JSON.stringify(['terminal', 'wt', '1:/tmp/a:b', 'c']))
expect(first).not.toBe(second)
expect(
sourceKeyForPreview({ source: 'worktree', worktreeId: 'wt:1', relativePath: 'a:b.ts' })
).toBe(JSON.stringify(['worktree', 'wt:1', 'a:b.ts']))
})
})
@@ -0,0 +1,39 @@
import type { MobileFilePreviewRouteParams } from './mobile-file-preview-route'
import type { MobileFilePreviewSource } from './mobile-file-preview-request'
export function previewSourceFromRoute(
params: MobileFilePreviewRouteParams
): MobileFilePreviewSource | null {
if (params.source === 'terminalArtifact') {
if (!params.absolutePath || !params.grantId) {
return null
}
return {
source: 'terminalArtifact',
worktreeId: params.worktreeId,
absolutePath: params.absolutePath,
grantId: params.grantId,
...(params.terminal ? { terminalHandle: params.terminal } : {}),
...(params.pathText ? { pathText: params.pathText } : {}),
...(params.cwd ? { cwd: params.cwd } : {})
}
}
if (!params.relativePath) {
return null
}
return { source: 'worktree', worktreeId: params.worktreeId, relativePath: params.relativePath }
}
export function sourceKeyForPreview(source: MobileFilePreviewSource | null): string | null {
if (!source) {
return null
}
return source.source === 'terminalArtifact'
? JSON.stringify([
'terminal',
source.worktreeId,
source.absolutePath,
source.terminalHandle ?? ''
])
: JSON.stringify(['worktree', source.worktreeId, source.relativePath])
}
@@ -73,6 +73,17 @@ export const filePreviewStyles = StyleSheet.create({
fontSize: typography.bodySize,
fontWeight: '600'
},
saveButton: {
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button,
backgroundColor: colors.bgRaised
},
saveButtonDisabled: {
opacity: 0.42
},
scroll: {
flex: 1,
backgroundColor: colors.editorSurface
@@ -136,5 +147,23 @@ export const filePreviewStyles = StyleSheet.create({
},
image: {
backgroundColor: colors.editorSurface
},
editContainer: {
flex: 1,
backgroundColor: colors.editorSurface,
padding: spacing.md
},
saveErrorText: {
marginBottom: spacing.sm,
color: colors.statusRed,
fontSize: typography.metaSize
},
editInput: {
flex: 1,
color: colors.textPrimary,
fontFamily: typography.monoFamily,
fontSize: 13,
lineHeight: 19,
padding: 0
}
})
@@ -0,0 +1,88 @@
import type { RpcClient } from '../transport/rpc-client'
import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
import { isTerminalArtifactGrantError } from './terminal-artifact-grant-error'
type MobileFilePreviewClient = Pick<RpcClient, 'sendRequest'>
export type MobileTerminalArtifactPreviewSource = {
source: 'terminalArtifact'
worktreeId: string
absolutePath: string
grantId: string
terminalHandle?: string
pathText?: string
cwd?: string
}
export type TerminalArtifactRetryOptions = {
onTerminalArtifactSourceRefreshed?: (source: MobileTerminalArtifactPreviewSource) => void
refreshGrant?: boolean
}
export async function refreshTerminalArtifactSourceAfterGrantFailure(
client: MobileFilePreviewClient,
source: MobileTerminalArtifactPreviewSource,
response: RpcResponse,
options: TerminalArtifactRetryOptions = {}
): Promise<MobileTerminalArtifactPreviewSource | null> {
if (response.ok || !isTerminalArtifactGrantFailure(response, options)) {
return null
}
const refreshed = await client.sendRequest('files.resolveTerminalPath', {
worktree: `id:${source.worktreeId}`,
pathText: source.pathText ?? source.absolutePath,
...(source.cwd ? { cwd: source.cwd } : {}),
...(source.terminalHandle ? { terminal: source.terminalHandle } : {})
})
if (!refreshed.ok) {
return null
}
const result = (refreshed as RpcSuccess).result
if (!isTerminalArtifactResolution(result)) {
return null
}
if (result.openTarget.absolutePath !== source.absolutePath) {
return null
}
return {
source: 'terminalArtifact',
worktreeId: source.worktreeId,
absolutePath: result.openTarget.absolutePath,
grantId: result.openTarget.grantId,
...(source.terminalHandle ? { terminalHandle: source.terminalHandle } : {}),
...(source.pathText ? { pathText: source.pathText } : {}),
...(source.cwd ? { cwd: source.cwd } : {})
}
}
function isTerminalArtifactGrantFailure(
response: RpcFailure,
options: TerminalArtifactRetryOptions
): boolean {
if (options.refreshGrant === false) {
return false
}
return isTerminalArtifactGrantError(`${response.error.code} ${response.error.message}`)
}
function isTerminalArtifactResolution(result: unknown): result is {
exists: true
isDirectory: false
openTarget: { kind: 'absolute-file'; absolutePath: string; grantId: string }
} {
if (!result || typeof result !== 'object') {
return false
}
const resolution = result as {
exists?: unknown
isDirectory?: unknown
openTarget?: { kind?: unknown; absolutePath?: unknown; grantId?: unknown }
}
return (
resolution.exists === true &&
resolution.isDirectory === false &&
resolution.openTarget?.kind === 'absolute-file' &&
typeof resolution.openTarget.absolutePath === 'string' &&
typeof resolution.openTarget.grantId === 'string'
)
}
@@ -0,0 +1,11 @@
// Why: the runtime rejects stale terminal-artifact grants with these codes, but
// the RPC mux flattens error shapes to code+message strings, so mobile matches
// by substring. Single source so grant refresh and preview errors can't drift.
export function isTerminalArtifactGrantError(text: string): boolean {
const normalized = text.toLowerCase()
return (
normalized.includes('terminal_file_grant_expired') ||
normalized.includes('terminal_file_grant_mismatch') ||
normalized.includes('terminal_file_grant_stale')
)
}
@@ -118,4 +118,30 @@ describe('attachMobileImageToTerminal', () => {
const sendCall = client.calls.find((c) => c.method === 'terminal.send')
expect(sendCall?.params).not.toHaveProperty('client')
})
it('waits for pending live input before sending the image payload', async () => {
const client = clientWithResponses([
{
id: 'start',
ok: false,
error: { code: 'method_not_found', message: 'no' },
_meta: { runtimeId: 'r' }
},
ok('save', '/tmp/pending.png')
])
const beforeTerminalSend = vi.fn(async () => false)
const sent = await attachMobileImageToTerminal('library', {
client,
terminal: 'term-pending',
deviceToken: null,
getConnectionId: async () => null,
pickImage: vi.fn().mockResolvedValue({ base64: 'DDDD' }),
beforeTerminalSend
})
expect(sent).toBe(false)
expect(beforeTerminalSend).toHaveBeenCalledWith('term-pending')
expect(client.calls.some((call) => call.method === 'terminal.send')).toBe(false)
})
})
@@ -16,6 +16,7 @@ export type AttachMobileImageDeps = {
// start — lets the UI show a sending spinner only for the transfer, not the
// (potentially long) time the picker is open.
readonly onUploadStart?: () => void
readonly beforeTerminalSend?: (terminal: string) => Promise<boolean>
}
// Uploads a picked image to the host and pastes the resulting file path into the
@@ -30,7 +31,8 @@ export async function attachMobileImageToTerminal(
deviceToken,
getConnectionId,
pickImage,
onUploadStart
onUploadStart,
beforeTerminalSend
}: AttachMobileImageDeps
): Promise<boolean> {
const picked = await pickImage(source)
@@ -45,6 +47,9 @@ export async function attachMobileImageToTerminal(
// Why: a generated image path is terminal image injection, so it's always
// bracketed (matching desktop paste) regardless of terminal mode.
const payload = buildMobileImagePastePayload(imagePath)
if (beforeTerminalSend && !(await beforeTerminalSend(terminal))) {
return false
}
await client.sendRequest('terminal.send', {
terminal,
text: payload,
@@ -0,0 +1,432 @@
import { describe, expect, it, vi } from 'vitest'
import { openMobileTerminalFileTap } from './mobile-terminal-file-tap-open'
function ok(result: unknown) {
return { ok: true, result, _meta: { runtimeId: 'runtime-1' } }
}
function createClient(responses: unknown[]) {
return {
sendRequest: vi.fn(async () => responses.shift())
}
}
function activeTerminalState(activated: boolean) {
return {
activated,
activationSeq: 1,
latestActivationSeq: 1,
sourceTerminalHandle: 'terminal-1',
activeTerminalHandle: 'terminal-1',
activeTabType: 'terminal'
}
}
describe('openMobileTerminalFileTap', () => {
it('opens absolute terminal artifacts through the grant-backed preview route', async () => {
const client = createClient([
ok({
worktree: 'wt-1',
relativePath: null,
absolutePath: '/tmp/result.json',
exists: true,
isDirectory: false,
openTarget: {
kind: 'absolute-file',
provider: 'local',
absolutePath: '/tmp/result.json',
grantId: 'grant-1'
}
})
])
const pushPreviewRoute = vi.fn()
const triggerOpenFeedback = vi.fn()
openMobileTerminalFileTap({
client,
hostId: 'host-1',
worktreeId: 'wt-1',
pathText: '/tmp/result.json',
terminalHandle: 'terminal-1',
line: 12,
column: 3,
pushPreviewRoute,
openBrowser: vi.fn(),
triggerOpenFeedback,
fetchSessionTabs: vi.fn(),
getSessionTabs: () => [],
getActiveSessionTabId: () => null,
getActivationState: activeTerminalState,
switchSessionTab: vi.fn(),
scheduleDelayedAction: vi.fn()
})
await Promise.resolve()
expect(client.sendRequest).toHaveBeenCalledWith(
'files.resolveTerminalPath',
{ worktree: 'id:wt-1', pathText: '/tmp/result.json', terminal: 'terminal-1' },
{ timeoutMs: 10_000 }
)
expect(pushPreviewRoute).toHaveBeenCalledWith({
pathname: '/h/[hostId]/files/preview/[worktreeId]',
params: expect.objectContaining({
hostId: 'host-1',
worktreeId: 'wt-1',
source: 'terminalArtifact',
absolutePath: '/tmp/result.json',
grantId: 'grant-1',
pathText: '/tmp/result.json',
terminal: 'terminal-1',
line: '12',
column: '3'
})
})
expect(triggerOpenFeedback).toHaveBeenCalledTimes(1)
expect(client.sendRequest).not.toHaveBeenCalledWith('files.open', expect.anything())
})
it('preserves the worktree-contained files.open flow', async () => {
const client = createClient([
ok({
worktree: 'wt-1',
relativePath: 'src/index.ts',
absolutePath: '/repo/src/index.ts',
exists: true,
isDirectory: false,
openTarget: {
kind: 'worktree-file',
provider: 'local',
relativePath: 'src/index.ts',
absolutePath: '/repo/src/index.ts'
}
}),
ok({ opened: true })
])
const scheduleDelayedAction = vi.fn((callback: () => void) => callback())
const openedTab = { id: 'tab-2', relativePath: 'src/index.ts' }
const switchSessionTab = vi.fn()
openMobileTerminalFileTap({
client,
hostId: 'host-1',
worktreeId: 'wt-1',
pathText: 'src/index.ts',
line: null,
column: null,
pushPreviewRoute: vi.fn(),
openBrowser: vi.fn(),
triggerOpenFeedback: vi.fn(),
fetchSessionTabs: vi.fn(),
getSessionTabs: () => [openedTab],
getActiveSessionTabId: () => 'terminal-tab',
getActivationState: activeTerminalState,
switchSessionTab,
scheduleDelayedAction
})
await Promise.resolve()
await Promise.resolve()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(client.sendRequest).toHaveBeenCalledWith(
'files.open',
{ worktree: 'id:wt-1', relativePath: 'src/index.ts' },
{ timeoutMs: 15_000 }
)
expect(switchSessionTab).toHaveBeenCalledWith(openedTab)
})
it('opens worktree-contained line references through the preview route', async () => {
const client = createClient([
ok({
worktree: 'wt-1',
relativePath: 'src/index.ts',
absolutePath: '/repo/src/index.ts',
exists: true,
isDirectory: false,
openTarget: {
kind: 'worktree-file',
provider: 'local',
relativePath: 'src/index.ts',
absolutePath: '/repo/src/index.ts'
}
})
])
const pushPreviewRoute = vi.fn()
const triggerOpenFeedback = vi.fn()
openMobileTerminalFileTap({
client,
hostId: 'host-1',
worktreeId: 'wt-1',
worktreeName: 'Orca',
pathText: 'src/index.ts:120:7',
line: 120,
column: 7,
pushPreviewRoute,
openBrowser: vi.fn(),
triggerOpenFeedback,
fetchSessionTabs: vi.fn(),
getSessionTabs: () => [],
getActiveSessionTabId: () => null,
getActivationState: activeTerminalState,
switchSessionTab: vi.fn(),
scheduleDelayedAction: vi.fn()
})
await Promise.resolve()
expect(pushPreviewRoute).toHaveBeenCalledWith({
pathname: '/h/[hostId]/files/preview/[worktreeId]',
params: expect.objectContaining({
hostId: 'host-1',
worktreeId: 'wt-1',
source: 'worktree',
relativePath: 'src/index.ts',
line: '120',
column: '7',
worktreeName: 'Orca'
})
})
expect(triggerOpenFeedback).toHaveBeenCalledTimes(1)
expect(client.sendRequest).not.toHaveBeenCalledWith('files.open', expect.anything())
})
it('encodes worktree HTML paths before opening a browser tab', async () => {
const client = createClient([
ok({
worktree: 'wt-1',
relativePath: 'public/report #1?.html',
absolutePath: '/repo/public/report #1?.html',
exists: true,
isDirectory: false,
openTarget: {
kind: 'worktree-file',
provider: 'local',
relativePath: 'public/report #1?.html',
absolutePath: '/repo/public/report #1?.html'
}
})
])
const openBrowser = vi.fn()
openMobileTerminalFileTap({
client,
hostId: 'host-1',
worktreeId: 'wt-1',
pathText: 'public/report #1?.html',
line: null,
column: null,
pushPreviewRoute: vi.fn(),
openBrowser,
triggerOpenFeedback: vi.fn(),
fetchSessionTabs: vi.fn(),
getSessionTabs: () => [],
getActiveSessionTabId: () => null,
getActivationState: activeTerminalState,
switchSessionTab: vi.fn(),
scheduleDelayedAction: vi.fn()
})
await Promise.resolve()
expect(openBrowser).toHaveBeenCalledWith('file:///repo/public/report%20%231%3F.html')
expect(client.sendRequest).not.toHaveBeenCalledWith('files.open', expect.anything())
})
it('passes the terminal cwd when resolving relative taps', async () => {
const client = createClient([
ok({
worktree: 'wt-1',
relativePath: 'src/index.ts',
absolutePath: '/repo/src/index.ts',
exists: true,
isDirectory: false,
openTarget: {
kind: 'worktree-file',
provider: 'local',
relativePath: 'src/index.ts',
absolutePath: '/repo/src/index.ts'
}
}),
ok({ opened: true })
])
openMobileTerminalFileTap({
client,
hostId: 'host-1',
worktreeId: 'wt-1',
pathText: 'index.ts',
terminalHandle: 'term-1',
cwd: '/repo/src',
line: null,
column: null,
pushPreviewRoute: vi.fn(),
openBrowser: vi.fn(),
triggerOpenFeedback: vi.fn(),
fetchSessionTabs: vi.fn(),
getSessionTabs: () => [],
getActiveSessionTabId: () => null,
getActivationState: activeTerminalState,
switchSessionTab: vi.fn(),
scheduleDelayedAction: vi.fn()
})
await Promise.resolve()
expect(client.sendRequest).toHaveBeenCalledWith(
'files.resolveTerminalPath',
{ worktree: 'id:wt-1', pathText: 'index.ts', terminal: 'term-1', cwd: '/repo/src' },
{ timeoutMs: 10_000 }
)
})
it('does not open SSH worktree HTML paths as local browser file URLs', async () => {
const client = createClient([
ok({
worktree: 'wt-1',
relativePath: 'report.html',
absolutePath: '/home/me/repo/report.html',
exists: true,
isDirectory: false,
openTarget: {
kind: 'worktree-file',
provider: 'ssh',
relativePath: 'report.html',
absolutePath: '/home/me/repo/report.html'
}
}),
ok({ opened: true })
])
const openBrowser = vi.fn()
openMobileTerminalFileTap({
client,
hostId: 'host-1',
worktreeId: 'wt-1',
pathText: 'report.html',
line: null,
column: null,
pushPreviewRoute: vi.fn(),
openBrowser,
triggerOpenFeedback: vi.fn(),
fetchSessionTabs: vi.fn(),
getSessionTabs: () => [],
getActiveSessionTabId: () => null,
getActivationState: activeTerminalState,
switchSessionTab: vi.fn(),
scheduleDelayedAction: vi.fn()
})
await Promise.resolve()
await Promise.resolve()
expect(openBrowser).not.toHaveBeenCalled()
expect(client.sendRequest).toHaveBeenCalledWith(
'files.open',
{ worktree: 'id:wt-1', relativePath: 'report.html' },
{ timeoutMs: 15_000 }
)
})
it('does not navigate an absolute artifact after the user leaves the source terminal', async () => {
let resolveRequest: (value: unknown) => void = () => {}
const client = {
sendRequest: vi.fn(
() =>
new Promise((resolve) => {
resolveRequest = resolve
})
)
}
let activeTerminalHandle: string | null = 'terminal-1'
const pushPreviewRoute = vi.fn()
openMobileTerminalFileTap({
client,
hostId: 'host-1',
worktreeId: 'wt-1',
pathText: '/tmp/result.json',
terminalHandle: 'terminal-1',
line: null,
column: null,
pushPreviewRoute,
openBrowser: vi.fn(),
triggerOpenFeedback: vi.fn(),
fetchSessionTabs: vi.fn(),
getSessionTabs: () => [],
getActiveSessionTabId: () => null,
getActivationState: (activated) => ({
...activeTerminalState(activated),
activeTerminalHandle
}),
switchSessionTab: vi.fn(),
scheduleDelayedAction: vi.fn()
})
activeTerminalHandle = 'terminal-2'
resolveRequest(
ok({
worktree: 'wt-1',
relativePath: null,
absolutePath: '/tmp/result.json',
exists: true,
isDirectory: false,
openTarget: {
kind: 'absolute-file',
provider: 'local',
absolutePath: '/tmp/result.json',
grantId: 'grant-1'
}
})
)
await Promise.resolve()
await Promise.resolve()
expect(pushPreviewRoute).not.toHaveBeenCalled()
})
it('does not activate a worktree file tab after a newer tap supersedes it', async () => {
const client = createClient([
ok({
worktree: 'wt-1',
relativePath: 'src/index.ts',
absolutePath: '/repo/src/index.ts',
exists: true,
isDirectory: false,
openTarget: {
kind: 'worktree-file',
provider: 'local',
relativePath: 'src/index.ts',
absolutePath: '/repo/src/index.ts'
}
}),
ok({ opened: true })
])
const callbacks: (() => void)[] = []
const openedTab = { id: 'tab-2', relativePath: 'src/index.ts' }
const switchSessionTab = vi.fn()
openMobileTerminalFileTap({
client,
hostId: 'host-1',
worktreeId: 'wt-1',
pathText: 'src/index.ts',
line: null,
column: null,
pushPreviewRoute: vi.fn(),
openBrowser: vi.fn(),
triggerOpenFeedback: vi.fn(),
fetchSessionTabs: vi.fn(),
getSessionTabs: () => [openedTab],
getActiveSessionTabId: () => 'terminal-tab',
getActivationState: (activated) => ({
...activeTerminalState(activated),
latestActivationSeq: 2
}),
switchSessionTab,
scheduleDelayedAction: (callback) => callbacks.push(callback)
})
await Promise.resolve()
await Promise.resolve()
callbacks.forEach((callback) => callback())
await Promise.resolve()
expect(switchSessionTab).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,173 @@
import type { RuntimeTerminalPathResolution } from '../../../src/shared/runtime-types'
import { filesystemPathToFileUri } from '../../../src/shared/file-uri-path'
import { createMobileFilePreviewHref } from '../files/mobile-file-preview-route'
import { classifyMobileArtifact } from './mobile-artifact-kind'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
import { shouldActivateOpenedMobileSessionTab } from './opened-mobile-session-tab'
type TerminalFileTapSessionTab = {
id: string
relativePath?: string
}
type OpenMobileTerminalFileTapOptions<T extends TerminalFileTapSessionTab> = {
client: Pick<RpcClient, 'sendRequest'>
hostId: string
worktreeId: string
worktreeName?: string
terminalHandle?: string | null
pathText: string
cwd?: string | null
line: number | null
column: number | null
pushPreviewRoute: (href: ReturnType<typeof createMobileFilePreviewHref>) => void
openBrowser: (url: string) => void
triggerOpenFeedback: () => void
fetchSessionTabs: () => Promise<void>
getSessionTabs: () => readonly T[]
getActiveSessionTabId: () => string | null
getActivationState: (activated: boolean) => {
activated: boolean
activationSeq: number
latestActivationSeq: number
sourceTerminalHandle: string
activeTerminalHandle: string | null
activeTabType: string | null
}
switchSessionTab: (tab: T) => void
scheduleDelayedAction: (callback: () => void, delayMs: number) => unknown
}
export function openMobileTerminalFileTap<T extends TerminalFileTapSessionTab>(
options: OpenMobileTerminalFileTapOptions<T>
): void {
void openMobileTerminalFileTapAsync(options).catch(() => {
// Terminal file taps are best-effort: a failed host resolution should leave
// terminal focus/input untouched, matching the existing silent miss behavior.
})
}
async function openMobileTerminalFileTapAsync<T extends TerminalFileTapSessionTab>(
options: OpenMobileTerminalFileTapOptions<T>
): Promise<void> {
const worktree = `id:${options.worktreeId}`
const response = await options.client.sendRequest(
'files.resolveTerminalPath',
{
worktree,
pathText: options.pathText,
...(options.terminalHandle && options.terminalHandle.trim().length > 0
? { terminal: options.terminalHandle }
: {}),
...(options.cwd && options.cwd.trim().length > 0 ? { cwd: options.cwd } : {})
},
{ timeoutMs: 10_000 }
)
if (!response.ok) {
return
}
const resolved = (response as RpcSuccess).result as RuntimeTerminalPathResolution
if (!resolved.exists || resolved.isDirectory) {
return
}
if (!shouldActivateOpenedMobileSessionTab(options.getActivationState(false))) {
return
}
if (resolved.openTarget?.kind === 'absolute-file') {
options.triggerOpenFeedback()
options.pushPreviewRoute(
createMobileFilePreviewHref({
hostId: options.hostId,
worktreeId: options.worktreeId,
source: 'terminalArtifact',
absolutePath: resolved.openTarget.absolutePath,
grantId: resolved.openTarget.grantId,
pathText: options.pathText,
...(options.cwd && options.cwd.trim().length > 0 ? { cwd: options.cwd } : {}),
...(options.terminalHandle && options.terminalHandle.trim().length > 0
? { terminal: options.terminalHandle }
: {}),
name: displayNameFromPath(resolved.openTarget.absolutePath),
...(options.line !== null ? { line: String(options.line) } : {}),
...(options.column !== null ? { column: String(options.column) } : {}),
...(options.worktreeName ? { worktreeName: options.worktreeName } : {})
})
)
return
}
const openedPath =
resolved.openTarget?.kind === 'worktree-file'
? resolved.openTarget.relativePath
: resolved.relativePath
if (!openedPath) {
return
}
options.triggerOpenFeedback()
if (options.line !== null || options.column !== null) {
options.pushPreviewRoute(
createMobileFilePreviewHref({
hostId: options.hostId,
worktreeId: options.worktreeId,
source: 'worktree',
relativePath: openedPath,
name: displayNameFromPath(openedPath),
...(options.line !== null ? { line: String(options.line) } : {}),
...(options.column !== null ? { column: String(options.column) } : {}),
...(options.worktreeName ? { worktreeName: options.worktreeName } : {})
})
)
return
}
if (
classifyMobileArtifact(openedPath) === 'html' &&
resolved.openTarget?.kind === 'worktree-file' &&
resolved.openTarget.provider === 'local'
) {
options.openBrowser(filesystemPathToFileUri(resolved.openTarget.absolutePath))
return
}
const openResponse = await options.client.sendRequest(
'files.open',
{ worktree, relativePath: openedPath },
{ timeoutMs: 15_000 }
)
if (!openResponse.ok) {
return
}
scheduleOpenedWorktreeTabActivation(options, openedPath)
}
function scheduleOpenedWorktreeTabActivation<T extends TerminalFileTapSessionTab>(
options: OpenMobileTerminalFileTapOptions<T>,
openedPath: string
): void {
let activated = false
const activateOpenedTab = async (): Promise<void> => {
if (!shouldActivateOpenedMobileSessionTab(options.getActivationState(activated))) {
return
}
await options.fetchSessionTabs()
if (!shouldActivateOpenedMobileSessionTab(options.getActivationState(activated))) {
return
}
const opened = options.getSessionTabs().find((tab) => tab.relativePath === openedPath)
if (!opened) {
return
}
if (options.getActiveSessionTabId() !== opened.id) {
options.switchSessionTab(opened)
}
activated = true
}
options.scheduleDelayedAction(() => void activateOpenedTab(), 300)
options.scheduleDelayedAction(() => void activateOpenedTab(), 900)
options.scheduleDelayedAction(() => void activateOpenedTab(), 1800)
}
function displayNameFromPath(path: string): string | undefined {
return path.split(/[\\/]/).filter(Boolean).at(-1)
}
@@ -24,6 +24,7 @@ type UseMobileImageAttachmentArgs = {
readonly showToast: ShowToast
readonly onSuccess: () => void
readonly onError: () => void
readonly beforeTerminalSend?: (terminal: string) => Promise<boolean>
}
type MobileImageAttachment = {
@@ -46,7 +47,8 @@ export function useMobileImageAttachment({
getActiveWorktreeConnectionId,
showToast,
onSuccess,
onError
onError,
beforeTerminalSend
}: UseMobileImageAttachmentArgs): MobileImageAttachment {
const [isAttaching, setIsAttaching] = useState(false)
const attachImage = useCallback(
@@ -61,7 +63,8 @@ export function useMobileImageAttachment({
deviceToken: deviceTokenRef.current,
getConnectionId: getActiveWorktreeConnectionId,
pickImage: pickMobileImage,
onUploadStart: () => setIsAttaching(true)
onUploadStart: () => setIsAttaching(true),
beforeTerminalSend
})
// Cancelled picker: no error, no toast.
if (sent) {
@@ -88,6 +91,7 @@ export function useMobileImageAttachment({
},
[
activeHandle,
beforeTerminalSend,
canSend,
client,
connState,
@@ -0,0 +1,200 @@
import { useCallback, type RefObject } from 'react'
import * as Clipboard from 'expo-clipboard'
import { File as FsFile, Paths } from 'expo-file-system'
import { ImageManipulator, SaveFormat } from 'expo-image-manipulator'
import type { TerminalModes } from '../terminal/TerminalWebView'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import {
buildMobileImagePastePayload,
prepareMobileClipboardImageBase64,
saveMobileClipboardImageAsTempFile,
type MobileClipboardImageResizer
} from './mobile-clipboard-image'
const CLIPBOARD_IMAGE_DATA_URL_PREFIX_RE = /^data:image\/[a-z0-9.+-]+;base64,/i
// Why: clipboard images are re-encoded as lossless PNG, so high-res screenshots and
// photos can exceed the upload byte budget; resize the raster down to fit before upload.
// The iOS ImageManipulator loader cannot decode large base64 data URIs, so use a file.
const resizeMobileClipboardImage: MobileClipboardImageResizer = async (source, target) => {
const base64 = source.replace(CLIPBOARD_IMAGE_DATA_URL_PREFIX_RE, '')
const file = new FsFile(Paths.cache, `orca-clip-resize-${Date.now()}.png`)
let context: ReturnType<typeof ImageManipulator.manipulate> | null = null
let rendered: Awaited<
ReturnType<ReturnType<typeof ImageManipulator.manipulate>['renderAsync']>
> | null = null
let resultUri: string | null = null
try {
file.create({ overwrite: true })
file.write(base64, { encoding: 'base64' })
context = ImageManipulator.manipulate(file.uri)
context.resize({ width: target.width, height: target.height })
rendered = await context.renderAsync()
const result = await rendered.saveAsync({ format: SaveFormat.PNG, base64: true })
resultUri = result.uri
// Why: empty base64 would pass the downstream base64 check and upload a corrupt
// image, so fail loudly here instead of silently sending an invalid payload.
if (!result.base64) {
throw new Error('Failed to encode resized clipboard image')
}
return { data: result.base64, width: result.width, height: result.height }
} finally {
rendered?.release()
context?.release()
if (resultUri) {
try {
new FsFile(resultUri).delete()
} catch {
// Best-effort cleanup; ImageManipulator saves into cache for every retry.
}
}
try {
file.delete()
} catch {
// Best-effort cleanup; the OS reclaims the cache directory regardless.
}
}
}
function buildMobileTerminalClipboardTextPayload(
text: string,
modes: TerminalModes | undefined
): string {
const wrap = modes?.bracketedPasteMode === true && !modes.altScreen
// Why: strip embedded bracketed-paste markers so copied text cannot terminate
// paste mode early and turn trailing bytes into shell commands.
// eslint-disable-next-line no-control-regex -- intentional bracketed-paste marker stripping
const sanitized = wrap ? text.replace(/\x1b\[20[01]~/g, '') : text
return wrap ? `\x1b[200~${sanitized}\x1b[201~` : sanitized
}
type UseMobileTerminalPasteOptions = {
readonly activeHandle: string | null
readonly activeHandleRef: RefObject<string | null>
readonly activeSessionTabTypeRef: RefObject<string | null>
readonly canSend: boolean
readonly client: RpcClient | null
readonly clientRef: RefObject<RpcClient | null>
readonly connState: ConnectionState
readonly connStateRef: RefObject<ConnectionState>
readonly deviceTokenRef: RefObject<string | null>
readonly flushPendingLiveInputBeforeExternalSend: (handle: string) => Promise<boolean>
readonly getActiveWorktreeConnectionId: () => Promise<string | null>
readonly onError: () => void
readonly onSuccess: () => void
readonly ptyModesRef: RefObject<Map<string, TerminalModes>>
readonly refreshCanPaste: () => void
readonly showToast: (message: string, durationMs?: number) => void
}
export function useMobileTerminalPaste({
activeHandle,
activeHandleRef,
activeSessionTabTypeRef,
canSend,
client,
clientRef,
connState,
connStateRef,
deviceTokenRef,
flushPendingLiveInputBeforeExternalSend,
getActiveWorktreeConnectionId,
onError,
onSuccess,
ptyModesRef,
refreshCanPaste,
showToast
}: UseMobileTerminalPasteOptions): () => Promise<void> {
return useCallback(async () => {
if (!client || !activeHandle || !canSend) {
return
}
const targetHandle = activeHandle
try {
const text = await Clipboard.getStringAsync()
let payload: string | null = null
if (text.length > 0) {
payload = buildMobileTerminalClipboardTextPayload(
text,
ptyModesRef.current.get(targetHandle)
)
} else {
const image = await Clipboard.getImageAsync({ format: 'png' })
if (!image) {
refreshCanPaste()
return
}
const connectionId = await getActiveWorktreeConnectionId()
const base64 = await prepareMobileClipboardImageBase64(image, resizeMobileClipboardImage)
const imagePath = await saveMobileClipboardImageAsTempFile(client, base64, {
connectionId
})
payload = buildMobileImagePastePayload(imagePath)
}
const wrappedBytes = new TextEncoder().encode(payload).byteLength
if (wrappedBytes > 256 * 1024) {
onError()
// eslint-disable-next-line no-console
console.warn('[mobile-clip] paste oversized', { wrappedBytes })
showToast('Paste too large (max 256 KiB)', 1500)
return
}
// Why: paste lives in the accessory row and must not overtake pending IME text.
const flushedPendingInput = await flushPendingLiveInputBeforeExternalSend(targetHandle)
if (!flushedPendingInput) {
return
}
const currentClient = clientRef.current
if (
!currentClient ||
connStateRef.current !== 'connected' ||
targetHandle !== activeHandleRef.current ||
activeSessionTabTypeRef.current !== 'terminal'
) {
return
}
await currentClient.sendRequest('terminal.send', {
terminal: targetHandle,
text: payload,
enter: false,
...(deviceTokenRef.current
? { client: { id: deviceTokenRef.current, type: 'mobile' as const } }
: {})
})
onSuccess()
refreshCanPaste()
} catch (e) {
onError()
const err = e as { name?: string; message?: string }
const isDisconnected = connState !== 'connected'
// eslint-disable-next-line no-console
console.warn('[mobile-clip] paste failed', { name: err.name, message: err.message })
if (isDisconnected) {
showToast('Paste failed (disconnected)', 1500)
} else if (err.message === 'Clipboard image is too large') {
showToast('Image too large to paste', 1500)
} else {
showToast('Paste failed', 1500)
}
}
}, [
activeHandle,
activeHandleRef,
activeSessionTabTypeRef,
canSend,
client,
clientRef,
connState,
connStateRef,
deviceTokenRef,
flushPendingLiveInputBeforeExternalSend,
getActiveWorktreeConnectionId,
onError,
onSuccess,
ptyModesRef,
refreshCanPaste,
showToast
])
}
@@ -0,0 +1,138 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
readDisabledTerminalLiveInputHandlesPreference,
saveDisabledTerminalLiveInputHandles,
type DisabledTerminalLiveInputHandlesPreference
} from '../storage/preferences'
import { useTerminalLiveInputModePreference } from './use-terminal-live-input-mode-preference'
vi.mock('../storage/preferences', () => ({
readDisabledTerminalLiveInputHandlesPreference: vi.fn(),
saveDisabledTerminalLiveInputHandles: vi.fn()
}))
type TerminalLiveInputModePreferenceHarness = {
readonly current: ReturnType<typeof useTerminalLiveInputModePreference>
readonly unmount: () => void
}
type Deferred<T> = {
readonly promise: Promise<T>
readonly resolve: (value: T) => void
}
function createDeferred<T>(): Deferred<T> {
let resolve: ((value: T) => void) | null = null
const promise = new Promise<T>((innerResolve) => {
resolve = innerResolve
})
if (!resolve) {
throw new Error('deferred resolver was not initialized')
}
return { promise, resolve }
}
function suppressReactTestRendererDeprecationWarning(): () => void {
const originalConsoleError = console.error
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
const firstArg = args[0]
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
return
}
originalConsoleError(...args)
})
return () => consoleErrorSpy.mockRestore()
}
function createTerminalLiveInputModePreferenceHarness(): TerminalLiveInputModePreferenceHarness {
let current: ReturnType<typeof useTerminalLiveInputModePreference> | null = null
let renderer: ReactTestRenderer | null = null
function Harness(): null {
current = useTerminalLiveInputModePreference({
hostId: 'host-1',
worktreeId: 'worktree-1'
})
return null
}
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
try {
act(() => {
renderer = create(createElement(Harness))
})
} finally {
restoreConsoleError()
}
if (!current || !renderer) {
throw new Error('terminal live input mode preference hook did not render')
}
return {
get current() {
if (!current) {
throw new Error('terminal live input mode preference hook is not mounted')
}
return current
},
unmount: () => {
act(() => renderer?.unmount())
}
}
}
describe('terminal live input mode preference hook', () => {
beforeEach(() => {
vi.mocked(readDisabledTerminalLiveInputHandlesPreference).mockReset()
vi.mocked(saveDisabledTerminalLiveInputHandles).mockReset()
vi.mocked(saveDisabledTerminalLiveInputHandles).mockResolvedValue()
})
it('merges pre-hydration edits with loaded disabled handles', async () => {
const load = createDeferred<DisabledTerminalLiveInputHandlesPreference>()
vi.mocked(readDisabledTerminalLiveInputHandlesPreference).mockReturnValue(load.promise)
const harness = createTerminalLiveInputModePreferenceHarness()
act(() => {
harness.current.defaultTerminalHandlesToLiveInput(['pty-1', 'pty-2'])
})
act(() => {
expect(harness.current.toggleTerminalLiveInput('pty-1')).toBe(true)
})
await act(async () => {
load.resolve({ handles: new Set(['pty-2']), loaded: true })
await load.promise
})
expect([...harness.current.liveInputTerminalHandles]).toEqual(['pty-1'])
expect(saveDisabledTerminalLiveInputHandles).toHaveBeenCalledTimes(1)
expect(saveDisabledTerminalLiveInputHandles).toHaveBeenCalledWith(
'host-1',
'worktree-1',
new Set(['pty-2'])
)
harness.unmount()
})
it('does not persist fallback-empty storage reads during clean hydration', async () => {
const load = createDeferred<DisabledTerminalLiveInputHandlesPreference>()
vi.mocked(readDisabledTerminalLiveInputHandlesPreference).mockReturnValue(load.promise)
const harness = createTerminalLiveInputModePreferenceHarness()
act(() => {
harness.current.defaultTerminalHandlesToLiveInput(['pty-1'])
})
await act(async () => {
load.resolve({ handles: new Set(), loaded: false })
await load.promise
})
expect([...harness.current.liveInputTerminalHandles]).toEqual(['pty-1'])
expect(saveDisabledTerminalLiveInputHandles).not.toHaveBeenCalled()
harness.unmount()
})
})
@@ -0,0 +1,212 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import {
readDisabledTerminalLiveInputHandlesPreference,
saveDisabledTerminalLiveInputHandles
} from '../storage/preferences'
import {
applyDisabledTerminalLiveInputHandles,
defaultTerminalLiveInputHandles,
filterTerminalLiveInputDefaultCandidates,
pruneTerminalLiveInputHandles
} from '../terminal/terminal-live-input'
type UseTerminalLiveInputModePreferenceOptions = {
readonly hostId: string
readonly worktreeId: string
}
export function useTerminalLiveInputModePreference({
hostId,
worktreeId
}: UseTerminalLiveInputModePreferenceOptions) {
const [liveInputTerminalHandles, setLiveInputTerminalHandles] = useState<Set<string>>(
() => new Set()
)
const liveInputTerminalHandlesRef = useRef<Set<string>>(new Set())
const defaultedLiveInputTerminalHandlesRef = useRef<Set<string>>(new Set())
const disabledLiveInputTerminalHandlesRef = useRef<Set<string>>(new Set())
const disabledLiveInputHydratedRef = useRef(false)
const pendingDisabledLiveInputHydrationEditsRef = useRef<Map<string, boolean>>(new Map())
const pendingLiveInputDefaultHandlesRef = useRef<Set<string>>(new Set())
const defaultTerminalHandlesToLiveInput = useCallback((handles: readonly string[]) => {
// Why: terminal discovery (tab snapshots, list poll, create) can arrive
// before the async persisted-disabled load on worktree re-entry.
if (!disabledLiveInputHydratedRef.current) {
for (const handle of handles) {
pendingLiveInputDefaultHandlesRef.current.add(handle)
}
return
}
const defaultableHandles = filterTerminalLiveInputDefaultCandidates(
handles,
disabledLiveInputTerminalHandlesRef.current
)
const result = defaultTerminalLiveInputHandles(
liveInputTerminalHandlesRef.current,
defaultedLiveInputTerminalHandlesRef.current,
defaultableHandles
)
if (!result.changed) {
return
}
const nextEnabledHandles = new Set(result.enabledHandles)
const nextDefaultedHandles = new Set(result.defaultedHandles)
liveInputTerminalHandlesRef.current = nextEnabledHandles
defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles
setLiveInputTerminalHandles(nextEnabledHandles)
}, [])
const persistDisabledLiveInputHandles = useCallback(() => {
void saveDisabledTerminalLiveInputHandles(
hostId,
worktreeId,
disabledLiveInputTerminalHandlesRef.current
).catch(() => {})
}, [hostId, worktreeId])
const pruneTerminalHandlesFromLiveInput = useCallback(
(liveHandles: ReadonlySet<string>) => {
const result = pruneTerminalLiveInputHandles(
liveInputTerminalHandlesRef.current,
defaultedLiveInputTerminalHandlesRef.current,
liveHandles
)
let prunedDisabledHandles = false
for (const handle of disabledLiveInputTerminalHandlesRef.current) {
if (liveHandles.has(handle)) {
continue
}
disabledLiveInputTerminalHandlesRef.current.delete(handle)
if (!disabledLiveInputHydratedRef.current) {
pendingDisabledLiveInputHydrationEditsRef.current.set(handle, false)
}
prunedDisabledHandles = true
}
if (prunedDisabledHandles && disabledLiveInputHydratedRef.current) {
persistDisabledLiveInputHandles()
}
if (!result.changed) {
return
}
const nextEnabledHandles = new Set(result.enabledHandles)
const nextDefaultedHandles = new Set(result.defaultedHandles)
liveInputTerminalHandlesRef.current = nextEnabledHandles
defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles
setLiveInputTerminalHandles(nextEnabledHandles)
},
[persistDisabledLiveInputHandles]
)
const clearTerminalLiveInputDefault = useCallback(
(handle: string) => {
const liveHandles = new Set([
...liveInputTerminalHandlesRef.current,
...defaultedLiveInputTerminalHandlesRef.current
])
liveHandles.delete(handle)
if (!disabledLiveInputHydratedRef.current) {
pendingDisabledLiveInputHydrationEditsRef.current.set(handle, false)
}
if (disabledLiveInputTerminalHandlesRef.current.delete(handle)) {
if (disabledLiveInputHydratedRef.current) {
persistDisabledLiveInputHandles()
}
}
pruneTerminalHandlesFromLiveInput(liveHandles)
},
[persistDisabledLiveInputHandles, pruneTerminalHandlesFromLiveInput]
)
const toggleTerminalLiveInput = useCallback(
(handle: string): boolean => {
const nextEnabled = !liveInputTerminalHandlesRef.current.has(handle)
if (nextEnabled) {
disabledLiveInputTerminalHandlesRef.current.delete(handle)
} else {
disabledLiveInputTerminalHandlesRef.current.add(handle)
}
// Why: pre-hydration edits must patch the loaded set per handle; replacing
// the loaded set would erase other persisted opt-outs for this worktree.
if (!disabledLiveInputHydratedRef.current) {
pendingDisabledLiveInputHydrationEditsRef.current.set(handle, !nextEnabled)
}
// Why: only persist after hydration; an earlier write would use the
// reset-empty ref and overwrite other handles' opt-outs for the worktree.
if (disabledLiveInputHydratedRef.current) {
persistDisabledLiveInputHandles()
}
setLiveInputTerminalHandles((prev) => {
const next = new Set(prev)
if (nextEnabled) {
next.add(handle)
} else {
next.delete(handle)
}
liveInputTerminalHandlesRef.current = next
return next
})
return nextEnabled
},
[persistDisabledLiveInputHandles]
)
useEffect(() => {
liveInputTerminalHandlesRef.current = new Set()
defaultedLiveInputTerminalHandlesRef.current = new Set()
disabledLiveInputTerminalHandlesRef.current = new Set()
disabledLiveInputHydratedRef.current = false
pendingDisabledLiveInputHydrationEditsRef.current = new Map()
pendingLiveInputDefaultHandlesRef.current = new Set()
setLiveInputTerminalHandles(new Set())
let disposed = false
// Why: load the persisted opt-outs first so defaulting logic (which can
// fire immediately from subscriptions) respects prior user choices.
void readDisabledTerminalLiveInputHandlesPreference(hostId, worktreeId).then((preference) => {
if (disposed) {
return
}
const pendingEdits = pendingDisabledLiveInputHydrationEditsRef.current
const hydratedDisabledHandles = new Set(preference.handles)
for (const [handle, disabled] of pendingEdits) {
if (disabled) {
hydratedDisabledHandles.add(handle)
} else {
hydratedDisabledHandles.delete(handle)
}
}
disabledLiveInputTerminalHandlesRef.current = hydratedDisabledHandles
disabledLiveInputHydratedRef.current = true
pendingDisabledLiveInputHydrationEditsRef.current = new Map()
if (preference.loaded && pendingEdits.size > 0) {
persistDisabledLiveInputHandles()
}
const result = applyDisabledTerminalLiveInputHandles(
liveInputTerminalHandlesRef.current,
defaultedLiveInputTerminalHandlesRef.current,
hydratedDisabledHandles
)
const nextEnabledHandles = new Set(result.enabledHandles)
const nextDefaultedHandles = new Set(result.defaultedHandles)
liveInputTerminalHandlesRef.current = nextEnabledHandles
defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles
setLiveInputTerminalHandles(nextEnabledHandles)
const pendingDefaultHandles = [...pendingLiveInputDefaultHandlesRef.current]
pendingLiveInputDefaultHandlesRef.current.clear()
defaultTerminalHandlesToLiveInput(pendingDefaultHandles)
})
return () => {
disposed = true
}
}, [defaultTerminalHandlesToLiveInput, hostId, persistDisabledLiveInputHandles, worktreeId])
return {
clearTerminalLiveInputDefault,
defaultTerminalHandlesToLiveInput,
liveInputTerminalHandles,
liveInputTerminalHandlesRef,
pruneTerminalHandlesFromLiveInput,
toggleTerminalLiveInput
}
}
+59
View File
@@ -8,9 +8,12 @@ import {
HOST_SIDEBAR_MIN_WIDTH,
clampHostDockWidth,
clampHostSidebarWidth,
loadDisabledTerminalLiveInputHandles,
loadHostSidebarWidth,
loadTerminalAutocompleteEnabled,
loadTerminalLinkOpenMode,
readDisabledTerminalLiveInputHandlesPreference,
saveDisabledTerminalLiveInputHandles,
saveHostSidebarWidth,
saveTerminalAutocompleteEnabled,
saveTerminalLinkOpenMode
@@ -63,6 +66,62 @@ describe('terminal autocomplete preference', () => {
})
})
describe('terminal live input disabled handles preference', () => {
beforeEach(() => {
vi.mocked(AsyncStorage.getItem).mockReset()
vi.mocked(AsyncStorage.setItem).mockReset()
})
it('defaults to no disabled handles when unset', async () => {
vi.mocked(AsyncStorage.getItem).mockResolvedValue(null)
await expect(loadDisabledTerminalLiveInputHandles('host-1', 'worktree-1')).resolves.toEqual(
new Set()
)
await expect(
readDisabledTerminalLiveInputHandlesPreference('host-1', 'worktree-1')
).resolves.toEqual({ handles: new Set(), loaded: true })
})
it('loads only string terminal handles from storage', async () => {
vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(['pty-1', 42, 'pty-2']))
await expect(loadDisabledTerminalLiveInputHandles('host-1', 'worktree-1')).resolves.toEqual(
new Set(['pty-1', 'pty-2'])
)
})
it('falls back to no disabled handles for invalid or unreadable storage', async () => {
vi.mocked(AsyncStorage.getItem).mockResolvedValue('not-json')
await expect(loadDisabledTerminalLiveInputHandles('host-1', 'worktree-1')).resolves.toEqual(
new Set()
)
vi.mocked(AsyncStorage.getItem).mockRejectedValue(new Error('storage unavailable'))
await expect(loadDisabledTerminalLiveInputHandles('host-1', 'worktree-1')).resolves.toEqual(
new Set()
)
await expect(
readDisabledTerminalLiveInputHandlesPreference('host-1', 'worktree-1')
).resolves.toEqual({ handles: new Set(), loaded: false })
})
it('persists disabled handles per host and worktree', async () => {
await saveDisabledTerminalLiveInputHandles(
'host/one',
'folder:C:\\repo',
new Set(['pty-2', 'pty-1'])
)
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
'orca:terminalLiveInputDisabled:host%2Fone:folder%3AC%3A%5Crepo',
JSON.stringify(['pty-2', 'pty-1'])
)
})
})
describe('host sidebar width preference', () => {
beforeEach(() => {
vi.mocked(AsyncStorage.getItem).mockReset()
+47
View File
@@ -73,6 +73,53 @@ export async function saveTerminalAutocompleteEnabled(enabled: boolean): Promise
await AsyncStorage.setItem(AUTOCOMPLETE_KEY, String(enabled))
}
const TERMINAL_LIVE_INPUT_DISABLED_PREFIX = 'orca:terminalLiveInputDisabled:'
export type DisabledTerminalLiveInputHandlesPreference = {
readonly handles: Set<string>
readonly loaded: boolean
}
function terminalLiveInputDisabledKey(hostId: string, worktreeId: string): string {
return `${TERMINAL_LIVE_INPUT_DISABLED_PREFIX}${encodeURIComponent(hostId)}:${encodeURIComponent(
worktreeId
)}`
}
export async function readDisabledTerminalLiveInputHandlesPreference(
hostId: string,
worktreeId: string
): Promise<DisabledTerminalLiveInputHandlesPreference> {
try {
const raw = await AsyncStorage.getItem(terminalLiveInputDisabledKey(hostId, worktreeId))
if (!raw) {
return { handles: new Set(), loaded: true }
}
return { handles: new Set(stringArray(JSON.parse(raw))), loaded: true }
} catch {
return { handles: new Set(), loaded: false }
}
}
export async function loadDisabledTerminalLiveInputHandles(
hostId: string,
worktreeId: string
): Promise<Set<string>> {
const preference = await readDisabledTerminalLiveInputHandlesPreference(hostId, worktreeId)
return preference.handles
}
export async function saveDisabledTerminalLiveInputHandles(
hostId: string,
worktreeId: string,
handles: ReadonlySet<string>
): Promise<void> {
await AsyncStorage.setItem(
terminalLiveInputDisabledKey(hostId, worktreeId),
JSON.stringify([...handles])
)
}
const SIDEBAR_WIDTH_KEY = 'orca:hostSidebarWidth'
// Bounds for the draggable host worktree-list sidebar on tablet/foldable
@@ -0,0 +1,95 @@
import type { TappedFilePath } from './terminal-path-tap'
import { parsePathWithOptionalLineColumn } from './terminal-path-tap'
export function resolveTerminalFileUrlTap(uri: string): TappedFilePath | null {
let parsed: URL
try {
parsed = new URL(uri)
} catch {
return null
}
const filePath = terminalFileUriToPathText(parsed)
if (!filePath) {
return null
}
const hashTarget = parseFileUrlLineHash(parsed.hash)
if (hashTarget) {
return { pathText: filePath, line: hashTarget.line, column: hashTarget.column }
}
if (/%3a/i.test(parsed.pathname)) {
return { pathText: filePath, line: null, column: null }
}
return (
parseFilePathTrailingLineTarget(filePath) ?? { pathText: filePath, line: null, column: null }
)
}
export function resolveTerminalOscFileTap(uri: string): TappedFilePath | null {
return resolveTerminalFileUrlTap(uri) ?? parseOscPathLikeTarget(uri)
}
function terminalFileUriToPathText(url: URL): string | null {
if (url.protocol !== 'file:') {
return null
}
let decodedPath: string
try {
decodedPath = decodeURIComponent(url.pathname)
} catch {
return null
}
if (url.hostname && !isLocalFileUriHostname(url.hostname)) {
return `//${url.hostname}${decodedPath}`
}
if (/^\/[A-Za-z]:\//.test(decodedPath)) {
return decodedPath.slice(1)
}
return decodedPath || null
}
function isLocalFileUriHostname(hostname: string): boolean {
const normalized = hostname.toLowerCase()
return (
normalized === 'localhost' ||
normalized === '127.0.0.1' ||
normalized === '::1' ||
normalized === '[::1]'
)
}
function parseOscPathLikeTarget(value: string): TappedFilePath | null {
if (
!/^(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))/.test(
value
)
) {
return null
}
return parsePathWithOptionalLineColumn(value)
}
function parseFileUrlLineHash(hash: string): { line: number; column: number | null } | null {
const match = /^#?L(\d+)(?:C(\d+))?$/i.exec(hash)
if (!match) {
return null
}
const line = Number.parseInt(match[1]!, 10)
const column = match[2] ? Number.parseInt(match[2], 10) : null
if (line < 1 || (column !== null && column < 1)) {
return null
}
return { line, column }
}
function parseFilePathTrailingLineTarget(filePath: string): TappedFilePath | null {
const match = /^(.*?)(?::(\d+))(?::(\d+))?$/.exec(filePath)
if (!match || !match[1] || match[1].endsWith('/') || match[1].endsWith('\\')) {
return null
}
const line = Number.parseInt(match[2]!, 10)
const column = match[3] ? Number.parseInt(match[3], 10) : null
if (line < 1 || (column !== null && column < 1)) {
return null
}
return { pathText: match[1], line, column }
}
@@ -0,0 +1,21 @@
import type { TerminalLiveAccessoryLocalEdit } from './terminal-live-text-commit'
export type TerminalLiveAccessoryInput = {
readonly bytes: string
readonly localEdit?: TerminalLiveAccessoryLocalEdit
}
type TerminalLiveAccessoryKey = {
readonly bytes: string
readonly id: string
}
export function createTerminalLiveAccessoryInput(
key: TerminalLiveAccessoryKey
): TerminalLiveAccessoryInput {
if (key.id === 'backspace' || key.id === 'delete') {
return { bytes: key.bytes, localEdit: key.id }
}
return { bytes: key.bytes }
}
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { getTerminalLiveAccessoryRawSendTarget } from './terminal-live-accessory-raw-send-target'
describe('terminal live accessory raw send target', () => {
it('Given the original terminal is still active When raw fallback resumes Then returns that handle', () => {
// Given
const targetHandle = 'terminal-a'
// When
const sendTarget = getTerminalLiveAccessoryRawSendTarget({
targetHandle,
activeHandle: targetHandle,
activeSessionTabType: 'terminal'
})
// Then
expect(sendTarget).toBe(targetHandle)
})
it('Given the active terminal changed while waiting When raw fallback resumes Then suppresses the send', () => {
// Given
const targetHandle = 'terminal-a'
// When
const sendTarget = getTerminalLiveAccessoryRawSendTarget({
targetHandle,
activeHandle: 'terminal-b',
activeSessionTabType: 'terminal'
})
// Then
expect(sendTarget).toBeNull()
})
it('Given the target is not an active terminal tab When raw fallback resumes Then suppresses the send', () => {
// Given
const targetHandle = 'terminal-a'
// When
const inactiveTabTarget = getTerminalLiveAccessoryRawSendTarget({
targetHandle,
activeHandle: targetHandle,
activeSessionTabType: 'browser'
})
// Then
expect(inactiveTabTarget).toBeNull()
})
})
@@ -0,0 +1,17 @@
type TerminalLiveAccessoryRawSendTargetInput<TTabType extends string> = {
readonly targetHandle: string
readonly activeHandle: string | null
readonly activeSessionTabType: TTabType | null
}
export function getTerminalLiveAccessoryRawSendTarget<TTabType extends string>({
targetHandle,
activeHandle,
activeSessionTabType
}: TerminalLiveAccessoryRawSendTargetInput<TTabType>): string | null {
if (targetHandle !== activeHandle || activeSessionTabType !== 'terminal') {
return null
}
return targetHandle
}
@@ -0,0 +1,74 @@
import { describe, expect, it, vi } from 'vitest'
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
describe('terminal live control send order', () => {
it('Given a failed pending text flush When control bytes follow Then skips the control bytes', async () => {
// Given
const events: string[] = []
const flushPendingText = vi.fn(async () => {
events.push('flush')
return false
})
const sendControlBytes = vi.fn(async () => {
events.push('control')
return true
})
// When
const result = await sendTerminalLiveControlAfterPendingFlush(
flushPendingText,
sendControlBytes
)
// Then
expect(result).toBe(false)
expect(sendControlBytes).not.toHaveBeenCalled()
expect(events).toEqual(['flush'])
})
it('Given a successful pending text flush When control bytes follow Then sends them afterward', async () => {
// Given
const events: string[] = []
const flushPendingText = vi.fn(async () => {
events.push('flush')
return true
})
const sendControlBytes = vi.fn(async () => {
events.push('control')
return true
})
// When
const result = await sendTerminalLiveControlAfterPendingFlush(
flushPendingText,
sendControlBytes
)
// Then
expect(result).toBe(true)
expect(events).toEqual(['flush', 'control'])
})
it('Given a failed control byte send When pending text flushed Then reports failure', async () => {
// Given
const events: string[] = []
const flushPendingText = vi.fn(async () => {
events.push('flush')
return true
})
const sendControlBytes = vi.fn(async () => {
events.push('control')
return false
})
// When
const result = await sendTerminalLiveControlAfterPendingFlush(
flushPendingText,
sendControlBytes
)
// Then
expect(result).toBe(false)
expect(events).toEqual(['flush', 'control'])
})
})
@@ -0,0 +1,12 @@
export type TerminalLiveAsyncSendStep = () => Promise<boolean>
export async function sendTerminalLiveControlAfterPendingFlush(
flushPendingText: TerminalLiveAsyncSendStep,
sendControlBytes: TerminalLiveAsyncSendStep
): Promise<boolean> {
const flushed = await flushPendingText()
if (!flushed) {
return false
}
return sendControlBytes()
}
@@ -0,0 +1 @@
export type TerminalLiveInputSender = (handle: string, bytes: string) => Promise<boolean>
@@ -1,8 +1,10 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
TERMINAL_LIVE_INPUT_MAX_BYTES,
applyDisabledTerminalLiveInputHandles,
clearTerminalLiveInputFocusTimer,
defaultTerminalLiveInputHandles,
filterTerminalLiveInputDefaultCandidates,
getTerminalLiveSpecialKeyBytes,
isTerminalLiveInputWithinByteLimit,
pruneTerminalLiveInputHandles,
@@ -101,6 +103,35 @@ describe('terminal live input', () => {
expect(result.defaultedHandles).toBe(defaulted)
})
it('does not default persisted buffered-mode handles back to live input on reentry', () => {
const defaultableHandles = filterTerminalLiveInputDefaultCandidates(
['pty-1', 'pty-2'],
new Set(['pty-1'])
)
const result = defaultTerminalLiveInputHandles(
new Set(),
new Set(['pty-1']),
defaultableHandles
)
expect(defaultableHandles).toEqual(['pty-2'])
expect([...result.enabledHandles]).toEqual(['pty-2'])
expect([...result.defaultedHandles]).toEqual(['pty-1', 'pty-2'])
})
it('reconciles persisted buffered-mode handles with currently enabled live input', () => {
const result = applyDisabledTerminalLiveInputHandles(
new Set(['pty-1', 'pty-2']),
new Set(['pty-2']),
new Set(['pty-1'])
)
expect(result.changed).toBe(true)
expect([...result.enabledHandles]).toEqual(['pty-2'])
expect([...result.defaultedHandles]).toEqual(['pty-2', 'pty-1'])
})
it('prunes terminal handles that disappear from session snapshots', () => {
const result = pruneTerminalLiveInputHandles(
new Set(['pty-1', 'pty-stale']),
@@ -118,6 +118,48 @@ export function defaultTerminalLiveInputHandles(
}
}
export function filterTerminalLiveInputDefaultCandidates(
terminalHandles: readonly string[],
disabledHandles: ReadonlySet<string>
): string[] {
return terminalHandles.filter((handle) => !disabledHandles.has(handle))
}
export function applyDisabledTerminalLiveInputHandles(
enabledHandles: ReadonlySet<string>,
defaultedHandles: ReadonlySet<string>,
disabledHandles: ReadonlySet<string>
): TerminalLiveInputDefaultResult {
let nextEnabledHandles: Set<string> | null = null
let nextDefaultedHandles: Set<string> | null = null
for (const handle of enabledHandles) {
if (!disabledHandles.has(handle)) {
continue
}
nextEnabledHandles ??= new Set(enabledHandles)
nextEnabledHandles.delete(handle)
}
for (const handle of disabledHandles) {
if (defaultedHandles.has(handle)) {
continue
}
nextDefaultedHandles ??= new Set(defaultedHandles)
nextDefaultedHandles.add(handle)
}
if (!nextEnabledHandles && !nextDefaultedHandles) {
return { enabledHandles, defaultedHandles, changed: false }
}
return {
enabledHandles: nextEnabledHandles ?? enabledHandles,
defaultedHandles: nextDefaultedHandles ?? defaultedHandles,
changed: true
}
}
export function pruneTerminalLiveInputHandles(
enabledHandles: ReadonlySet<string>,
defaultedHandles: ReadonlySet<string>,
@@ -0,0 +1,146 @@
import { describe, expect, it } from 'vitest'
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
import {
queueTerminalLivePendingFlush,
waitForTerminalLivePendingFlush,
type TerminalLivePendingFlushState
} from './terminal-live-pending-flush-state'
describe('terminal live pending flush state', () => {
it('Given no in-flight flush When waiting for the barrier Then allows control input', async () => {
// Given
const state: TerminalLivePendingFlushState = { current: null }
// When / Then
await expect(waitForTerminalLivePendingFlush(state)).resolves.toBe(true)
})
it('Given an in-flight flush When control input waits Then control is held until flush succeeds', async () => {
// Given
const events: string[] = []
let resolveFlush: (value: boolean) => void = () => {}
const flushPromise = new Promise<boolean>((resolve) => {
resolveFlush = resolve
})
const state: TerminalLivePendingFlushState = { current: flushPromise }
// When
const controlSend = sendTerminalLiveControlAfterPendingFlush(
() => waitForTerminalLivePendingFlush(state),
async () => {
events.push('control')
return true
}
)
await Promise.resolve()
// Then
expect(events).toEqual([])
resolveFlush(true)
await expect(controlSend).resolves.toBe(true)
expect(events).toEqual(['control'])
})
it('Given an in-flight flush fails When control input waits Then control is skipped', async () => {
// Given
const events: string[] = []
let resolveFlush: (value: boolean) => void = () => {}
const flushPromise = new Promise<boolean>((resolve) => {
resolveFlush = resolve
})
const state: TerminalLivePendingFlushState = { current: flushPromise }
// When
const controlSend = sendTerminalLiveControlAfterPendingFlush(
() => waitForTerminalLivePendingFlush(state),
async () => {
events.push('control')
return true
}
)
resolveFlush(false)
// Then
await expect(controlSend).resolves.toBe(false)
expect(events).toEqual([])
})
it('Given a queued pending flush When it resolves or rejects Then clears the barrier', async () => {
// Given
const resolvedState: TerminalLivePendingFlushState = { current: null }
const rejectedState: TerminalLivePendingFlushState = { current: null }
// When
await expect(queueTerminalLivePendingFlush(resolvedState, async () => true)).resolves.toBe(true)
await expect(
queueTerminalLivePendingFlush(rejectedState, async () => {
throw new Error('send failed')
})
).resolves.toBe(false)
await Promise.resolve()
// Then
expect(resolvedState.current).toBeNull()
expect(rejectedState.current).toBeNull()
})
it('Given a current pending snapshot while another flush is in flight When queued Then sends current text after prior success', async () => {
// Given
const events: string[] = []
let resolveFirstFlush: (value: boolean) => void = () => {}
const firstFlush = new Promise<boolean>((resolve) => {
resolveFirstFlush = resolve
})
const state: TerminalLivePendingFlushState = { current: firstFlush }
// When
const secondFlush = queueTerminalLivePendingFlush(state, async () => {
events.push('second-flush')
return true
})
const controlSend = sendTerminalLiveControlAfterPendingFlush(
() => waitForTerminalLivePendingFlush(state),
async () => {
events.push('control')
return true
}
)
await Promise.resolve()
// Then
expect(events).toEqual([])
resolveFirstFlush(true)
await expect(secondFlush).resolves.toBe(true)
await expect(controlSend).resolves.toBe(true)
expect(events).toEqual(['second-flush', 'control'])
})
it('Given a prior pending flush fails When another snapshot is queued Then skips the current text and control', async () => {
// Given
const events: string[] = []
let resolveFirstFlush: (value: boolean) => void = () => {}
const firstFlush = new Promise<boolean>((resolve) => {
resolveFirstFlush = resolve
})
const state: TerminalLivePendingFlushState = { current: firstFlush }
// When
const secondFlush = queueTerminalLivePendingFlush(state, async () => {
events.push('second-flush')
return true
})
const controlSend = sendTerminalLiveControlAfterPendingFlush(
() => waitForTerminalLivePendingFlush(state),
async () => {
events.push('control')
return true
}
)
resolveFirstFlush(false)
// Then
await expect(secondFlush).resolves.toBe(false)
await expect(controlSend).resolves.toBe(false)
expect(events).toEqual([])
})
})
@@ -0,0 +1,29 @@
export type TerminalLivePendingFlushState = {
current: Promise<boolean> | null
}
export function waitForTerminalLivePendingFlush(
state: TerminalLivePendingFlushState
): Promise<boolean> {
return state.current ?? Promise.resolve(true)
}
export function queueTerminalLivePendingFlush(
state: TerminalLivePendingFlushState,
sendPendingText: () => Promise<boolean>
): Promise<boolean> {
const previousFlush = state.current
const flushPromise = (async () => {
if (previousFlush && !(await previousFlush)) {
return false
}
return sendPendingText()
})().catch(() => false)
state.current = flushPromise
void flushPromise.then(() => {
if (state.current === flushPromise) {
state.current = null
}
})
return flushPromise
}
@@ -0,0 +1,234 @@
import { describe, expect, it } from 'vitest'
import {
TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS,
getTerminalLiveAccessoryBytesDecision,
getTerminalLiveAccessoryLocalEditText,
getTerminalLiveDeferredTextDelayMs,
getTerminalLiveSpecialKeyDecision,
getTerminalLiveSubmitSequence,
getTerminalLiveTextChangeDecision,
isTerminalLiveTextHangulCandidate,
isTerminalLiveTextImeCandidate
} from './terminal-live-text-commit'
describe('terminal live text commit', () => {
it('Given Korean IME changes When live text changes Then defers candidates and submits only final text before carriage return', () => {
// Given
const koreanCompositionSteps = ['ㅎ', '하', '한'] as const
// When
const decisions = koreanCompositionSteps.map(getTerminalLiveTextChangeDecision)
const sentImmediately = decisions.filter((decision) => decision.kind === 'send-now')
const submitSequence = getTerminalLiveSubmitSequence('한')
const composedWordDecision = getTerminalLiveTextChangeDecision('한글')
const composedWordSubmitSequence = getTerminalLiveSubmitSequence('한글')
// Then
expect(decisions).toEqual([
{ kind: 'defer', text: 'ㅎ', delayMs: null },
{ kind: 'defer', text: '하', delayMs: null },
{ kind: 'defer', text: '한', delayMs: null }
])
expect(sentImmediately).toEqual([])
expect(isTerminalLiveTextHangulCandidate('ㅎ')).toBe(true)
expect(getTerminalLiveDeferredTextDelayMs('ㅎ')).toBeNull()
expect(submitSequence).toEqual(['한', '\r'])
expect(composedWordDecision).toEqual({
kind: 'defer',
text: '한글',
delayMs: null
})
expect(composedWordSubmitSequence).toEqual(['한글', '\r'])
})
it('Given non-Hangul IME text When live text changes Then keeps the bounded settle timer', () => {
// Given
const text = 'あ'
// When
const decision = getTerminalLiveTextChangeDecision(text)
// Then
expect(isTerminalLiveTextImeCandidate(text)).toBe(true)
expect(isTerminalLiveTextHangulCandidate(text)).toBe(false)
expect(getTerminalLiveDeferredTextDelayMs(text)).toBe(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS)
expect(decision).toEqual({ kind: 'defer', text, delayMs: TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS })
})
it('Given Chinese and Vietnamese IME text When live text changes Then does not use Hangul-only indefinite deferral', () => {
// Given
const nonHangulImeTexts = ['你好', 'tiếng Việt'] as const
for (const text of nonHangulImeTexts) {
// When
const decision = getTerminalLiveTextChangeDecision(text)
// Then
expect(isTerminalLiveTextImeCandidate(text)).toBe(true)
expect(isTerminalLiveTextHangulCandidate(text)).toBe(false)
expect(getTerminalLiveDeferredTextDelayMs(text)).toBe(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS)
expect(decision).toEqual({
kind: 'defer',
text,
delayMs: TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS
})
}
})
it('Given ASCII text When live text changes Then sends immediately', () => {
// Given
const text = 'abc123'
// When
const decision = getTerminalLiveTextChangeDecision(text)
// Then
expect(isTerminalLiveTextImeCandidate(text)).toBe(false)
expect(decision).toEqual({ kind: 'send-now', text })
})
it('Given empty text When live text changes Then ignores the change', () => {
// Given
const text = ''
// When
const decision = getTerminalLiveTextChangeDecision(text)
// Then
expect(isTerminalLiveTextImeCandidate(text)).toBe(false)
expect(decision).toEqual({ kind: 'ignore' })
})
it('Given pending text When Backspace or Delete is pressed Then keeps edits local', () => {
// Given
const pendingText = '한'
// When
const backspaceDecision = getTerminalLiveSpecialKeyDecision({ key: 'Backspace', pendingText })
const deleteDecision = getTerminalLiveSpecialKeyDecision({ key: 'Delete', pendingText })
// Then
expect(backspaceDecision).toEqual({ kind: 'local-edit' })
expect(deleteDecision).toEqual({ kind: 'local-edit' })
})
it('Given no pending text When Backspace or Delete is pressed Then sends terminal bytes', () => {
// Given
const pendingText = ''
// When
const backspaceDecision = getTerminalLiveSpecialKeyDecision({ key: 'Backspace', pendingText })
const deleteDecision = getTerminalLiveSpecialKeyDecision({ key: 'Delete', pendingText })
// Then
expect(backspaceDecision).toEqual({ kind: 'send-now', bytes: '\x7f' })
expect(deleteDecision).toEqual({ kind: 'send-now', bytes: '\x1b[3~' })
})
it('Given pending text When a terminal special key is pressed Then flushes pending text before bytes', () => {
// Given
const pendingText = '한'
// When
const decision = getTerminalLiveSpecialKeyDecision({ key: 'Tab', pendingText })
// Then
expect(decision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\t' })
})
it('Given pending text When accessory control bytes are requested Then flushes pending text before bytes', () => {
// Given
const pendingText = '한글'
// When
const tabDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\t', pendingText })
const escapeDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\x1b', pendingText })
const enterDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\r', pendingText })
// Then
expect(tabDecision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\t' })
expect(escapeDecision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\x1b' })
expect(enterDecision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\r' })
})
it('Given pending text When accessory Backspace or Delete bytes are requested Then keeps edits local', () => {
// Given
const pendingText = '한글'
// When
const backspaceDecision = getTerminalLiveAccessoryBytesDecision({
bytes: '\x7f',
localEdit: 'backspace',
pendingText
})
const ctrlBackspaceDecision = getTerminalLiveAccessoryBytesDecision({
bytes: '\b',
localEdit: 'backspace',
pendingText
})
const deleteDecision = getTerminalLiveAccessoryBytesDecision({
bytes: '\x1b[3~',
localEdit: 'delete',
pendingText
})
const customDeleteByteDecision = getTerminalLiveAccessoryBytesDecision({
bytes: '\x7f',
pendingText
})
const backspaceText = getTerminalLiveAccessoryLocalEditText({
localEdit: 'backspace',
pendingText
})
const deleteText = getTerminalLiveAccessoryLocalEditText({
localEdit: 'delete',
pendingText
})
// Then
expect(backspaceDecision).toEqual({ kind: 'local-edit', localEdit: 'backspace' })
expect(ctrlBackspaceDecision).toEqual({ kind: 'local-edit', localEdit: 'backspace' })
expect(deleteDecision).toEqual({ kind: 'local-edit', localEdit: 'delete' })
expect(customDeleteByteDecision).toEqual({
kind: 'flush-then-send',
pendingText,
bytes: '\x7f'
})
expect(backspaceText).toBe('한')
expect(deleteText).toBe('한글')
expect(getTerminalLiveDeferredTextDelayMs(backspaceText)).toBeNull()
expect(getTerminalLiveDeferredTextDelayMs(deleteText)).toBeNull()
})
it('Given no pending text When accessory bytes are requested Then sends terminal bytes', () => {
// Given
const pendingText = ''
// When
const tabDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\t', pendingText })
// Then
expect(tabDecision).toEqual({ kind: 'send-now', bytes: '\t' })
})
it('Given a non-special key When key decision is requested Then ignores it', () => {
// Given
const key = 'a'
// When
const decision = getTerminalLiveSpecialKeyDecision({ key, pendingText: '한' })
// Then
expect(decision).toEqual({ kind: 'ignore' })
})
it('Given no pending text When submit is requested Then sends only carriage return', () => {
// Given
const pendingText = ''
// When
const sequence = getTerminalLiveSubmitSequence(pendingText)
// Then
expect(sequence).toEqual(['\r'])
})
})
@@ -0,0 +1,145 @@
import { getTerminalLiveSpecialKeyBytes } from './terminal-live-input'
// Why: React Native does not expose portable composition events here, so
// non-Hangul IME text gets a short settle window before being sent to the PTY.
export const TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS = 150
export type TerminalLiveTextChangeDecision =
| { readonly kind: 'ignore' }
| { readonly kind: 'send-now'; readonly text: string }
| { readonly kind: 'defer'; readonly text: string; readonly delayMs: number | null }
export type TerminalLiveSpecialKeyDecision =
| { readonly kind: 'ignore' }
| { readonly kind: 'local-edit' }
| { readonly kind: 'send-now'; readonly bytes: string }
| { readonly kind: 'flush-then-send'; readonly pendingText: string; readonly bytes: string }
export type TerminalLiveSpecialKeyDecisionInput = {
readonly key: string
readonly pendingText: string
}
export type TerminalLiveAccessoryLocalEdit = 'backspace' | 'delete'
export type TerminalLiveAccessoryBytesDecision =
| { readonly kind: 'local-edit'; readonly localEdit: TerminalLiveAccessoryLocalEdit }
| { readonly kind: 'send-now'; readonly bytes: string }
| { readonly kind: 'flush-then-send'; readonly pendingText: string; readonly bytes: string }
export type TerminalLiveAccessoryBytesDecisionInput = {
readonly bytes: string
readonly localEdit?: TerminalLiveAccessoryLocalEdit
readonly pendingText: string
}
export function isTerminalLiveTextImeCandidate(text: string): boolean {
for (const character of text) {
const codePoint = character.codePointAt(0)
if (codePoint !== undefined && codePoint > 0x7f) {
return true
}
}
return false
}
function isHangulCodePoint(codePoint: number): boolean {
return (
(codePoint >= 0x1100 && codePoint <= 0x11ff) ||
(codePoint >= 0x3130 && codePoint <= 0x318f) ||
(codePoint >= 0xa960 && codePoint <= 0xa97f) ||
(codePoint >= 0xac00 && codePoint <= 0xd7af)
)
}
export function isTerminalLiveTextHangulCandidate(text: string): boolean {
for (const character of text) {
const codePoint = character.codePointAt(0)
if (codePoint !== undefined && isHangulCodePoint(codePoint)) {
return true
}
}
return false
}
export function getTerminalLiveTextChangeDecision(text: string): TerminalLiveTextChangeDecision {
if (text.length === 0) {
return { kind: 'ignore' }
}
if (isTerminalLiveTextHangulCandidate(text)) {
return { kind: 'defer', text, delayMs: null }
}
if (isTerminalLiveTextImeCandidate(text)) {
return { kind: 'defer', text, delayMs: TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS }
}
return { kind: 'send-now', text }
}
export function getTerminalLiveDeferredTextDelayMs(text: string): number | null {
return isTerminalLiveTextHangulCandidate(text) ? null : TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS
}
export function getTerminalLiveSpecialKeyDecision({
key,
pendingText
}: TerminalLiveSpecialKeyDecisionInput): TerminalLiveSpecialKeyDecision {
const bytes = getTerminalLiveSpecialKeyBytes(key)
if (bytes === null) {
return { kind: 'ignore' }
}
if (pendingText.length > 0 && (key === 'Backspace' || key === 'Delete')) {
return { kind: 'local-edit' }
}
if (pendingText.length > 0) {
return { kind: 'flush-then-send', pendingText, bytes }
}
return { kind: 'send-now', bytes }
}
export function getTerminalLiveAccessoryBytesDecision({
bytes,
localEdit,
pendingText
}: TerminalLiveAccessoryBytesDecisionInput): TerminalLiveAccessoryBytesDecision {
if (pendingText.length > 0 && localEdit) {
return { kind: 'local-edit', localEdit }
}
if (pendingText.length > 0) {
return { kind: 'flush-then-send', pendingText, bytes }
}
return { kind: 'send-now', bytes }
}
export function getTerminalLiveAccessoryLocalEditText({
localEdit,
pendingText
}: {
readonly localEdit: TerminalLiveAccessoryLocalEdit
readonly pendingText: string
}): string {
if (localEdit === 'delete') {
// Why: accessory Delete mirrors forward-delete at the hidden input's end;
// it stays local but does not remove the pending IME text.
return pendingText
}
return Array.from(pendingText).slice(0, -1).join('')
}
export type TerminalLiveSubmitSequence = readonly ['\r'] | readonly [string, '\r']
export function getTerminalLiveSubmitSequence(pendingText: string): TerminalLiveSubmitSequence {
if (pendingText.length === 0) {
return ['\r']
}
return [pendingText, '\r']
}
@@ -14,11 +14,12 @@
// often print a bare filename (the markdown link target is consumed, leaving
// only the label text), so requiring a slash would miss the common case.
export const TERMINAL_PATH_TAP_JS = String.raw`
var FILE_PATH_RE = /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))[A-Za-z0-9._~\-\/%+@\\()[\]]*(?::\d+)?(?::\d+)?/g;
var PATH_LEADING_TRIM = { '(': 1, '[': 1, '{': 1, '"': 1, "'": 1 };
var PATH_TRAILING_TRIM = { ')': 1, ']': 1, '}': 1, '"': 1, "'": 1, ',': 1, ';': 1, '.': 1 };
var FILE_PATH_RE = /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))[A-Za-z0-9._~\-\/%+@\\()[\]]*(?::\d+)?(?::\d+)?/g;
var SPACED_PATH_RE = /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/])[^()[\]{}'",;<>|\`\r\n]+(?::\d+)?(?::\d+)?/g;
var PATH_LEADING_TRIM = { '(': 1, '[': 1, '{': 1, '"': 1, "'": 1 };
var PATH_TRAILING_TRIM = { ')': 1, ']': 1, '}': 1, '"': 1, "'": 1, ',': 1, ';': 1, '.': 1 };
function parsePathLineCol(value) {
function parsePathLineCol(value) {
var m = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(value);
if (!m) return null;
var pathText = m[1];
@@ -27,24 +28,91 @@ export const TERMINAL_PATH_TAP_JS = String.raw`
var line = m[2] ? parseInt(m[2], 10) : null;
var column = m[3] ? parseInt(m[3], 10) : null;
if ((line !== null && line < 1) || (column !== null && column < 1)) return null;
return { pathText: pathText, line: line, column: column };
}
return { pathText: pathText, line: line, column: column };
}
function matchFilePathAtColumn(lineText, col) {
FILE_PATH_RE.lastIndex = 0;
function trimPathBoundaryPunctuation(raw, rawStart) {
var start = 0, end = raw.length;
while (start < end && PATH_LEADING_TRIM[raw.charAt(start)]) start += 1;
while (end > start && PATH_TRAILING_TRIM[raw.charAt(end - 1)]) end -= 1;
if (start >= end) return null;
return { text: raw.slice(start, end), startIndex: rawStart + start, endIndex: rawStart + end };
}
function hasSeparatorAfterWhitespace(text) {
var sawWhitespace = false;
for (var i = 0; i < text.length; i++) {
var ch = text.charAt(i);
if (/\s/.test(ch)) { sawWhitespace = true; continue; }
if (sawWhitespace && (ch === '/' || ch === '\\')) return true;
}
return false;
}
function trimSpacedPathTrailingProse(range, col) {
// A line-end extension token only extends the span when the added segment
// is path-like (contains a separator) — prose must not be swallowed.
var selected = null;
var extensionPrefixPattern = /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$)/g;
var match;
while ((match = extensionPrefixPattern.exec(range.text)) !== null) {
var end = match.index + match[0].length;
var text = range.text.slice(0, end);
if (countPathStarts(text) > 1) continue;
if (end < range.text.length || selected === null || /[\\/]/.test(range.text.slice(selected.length, end))) {
selected = text;
}
}
if (selected) {
if (col !== undefined && col >= range.startIndex + selected.length) return null;
return { text: selected, startIndex: range.startIndex, endIndex: range.startIndex + selected.length };
}
var text = range.text.replace(/\s+$/, '');
return { text: text, startIndex: range.startIndex, endIndex: range.startIndex + text.length };
}
function countPathStarts(text) {
var count = 0;
var pathStartPattern = /(?:^|\s)(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/])/g;
while (pathStartPattern.exec(text) !== null) count += 1;
return count;
}
function hasSpacedPathExtension(text) {
var range = trimSpacedPathTrailingProse({ text: text, startIndex: 0, endIndex: text.length });
if (!range) return false;
var trimmed = range.text.replace(/\s+$/, '');
return /\s/.test(trimmed) && /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?$/.test(trimmed);
}
function matchSpacedFilePathAtColumn(lineText, col) {
SPACED_PATH_RE.lastIndex = 0;
var match;
while ((match = SPACED_PATH_RE.exec(lineText)) !== null) {
var trimmed = trimPathBoundaryPunctuation(match[0], match.index);
if (!trimmed || (!hasSeparatorAfterWhitespace(trimmed.text) && !hasSpacedPathExtension(trimmed.text))) continue;
var candidate = trimSpacedPathTrailingProse(trimmed, col);
if (!candidate) continue;
if (col < candidate.startIndex || col >= candidate.endIndex) continue;
var parsed = parsePathLineCol(candidate.text);
if (parsed) return parsed;
}
return null;
}
function matchFilePathAtColumn(lineText, col) {
var spaced = matchSpacedFilePathAtColumn(lineText, col);
if (spaced) return spaced;
FILE_PATH_RE.lastIndex = 0;
var match;
while ((match = FILE_PATH_RE.exec(lineText)) !== null) {
var raw = match[0];
if (raw.length === 0) { FILE_PATH_RE.lastIndex += 1; continue; }
var start = 0, end = raw.length;
while (start < end && PATH_LEADING_TRIM[raw.charAt(start)]) start += 1;
while (end > start && PATH_TRAILING_TRIM[raw.charAt(end - 1)]) end -= 1;
if (start >= end) continue;
var spanStart = match.index + start;
var spanEnd = match.index + end;
if (col < spanStart || col > spanEnd) continue;
var parsed = parsePathLineCol(raw.slice(start, end));
if (parsed) return parsed;
var trimmed = trimPathBoundaryPunctuation(raw, match.index);
if (!trimmed) continue;
if (col < trimmed.startIndex || col >= trimmed.endIndex) continue;
var parsed = parsePathLineCol(trimmed.text);
if (parsed) return parsed;
}
return null;
}
+103 -4
View File
@@ -1,6 +1,14 @@
import { Script, createContext } from 'node:vm'
import { describe, expect, it } from 'vitest'
import {
TERMINAL_FILE_LINK_TAP_CONFORMANCE_CASES,
columnForTerminalFileLinkTap
} from '../../../src/shared/terminal-file-link-conformance'
import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected'
import { matchFilePathAtColumn, parsePathWithOptionalLineColumn } from './terminal-path-tap'
type InjectedPathMatcher = typeof matchFilePathAtColumn
// Returns the column of the first occurrence of `needle` in `line` (+offset).
function colOf(line: string, needle: string, offset = 0): number {
return line.indexOf(needle) + offset
@@ -32,6 +40,15 @@ describe('parsePathWithOptionalLineColumn', () => {
})
describe('matchFilePathAtColumn', () => {
it.each(TERMINAL_FILE_LINK_TAP_CONFORMANCE_CASES)(
'matches shared terminal file-link tap case: $name',
(testCase) => {
expect(
matchFilePathAtColumn(testCase.lineText, columnForTerminalFileLinkTap(testCase))
).toEqual(testCase.expected)
}
)
it('matches an absolute path under the tap', () => {
const line = 'created /tmp/out/report.html for you'
const result = matchFilePathAtColumn(line, colOf(line, 'report'))
@@ -50,12 +67,68 @@ describe('matchFilePathAtColumn', () => {
expect(result?.pathText).toBe('~/Documents/notes.md')
})
it('yields the tight whitespace-bounded segment under the tap', () => {
// On a path whose dir name has a space, tapping the file segment yields the
// openable sub-path after the space (still resolves against the worktree).
it('matches a path whose directory name contains a space', () => {
const line = '/Users/me/My Project/readme.md done'
const result = matchFilePathAtColumn(line, colOf(line, 'readme'))
expect(result?.pathText).toBe('Project/readme.md')
expect(result?.pathText).toBe('/Users/me/My Project/readme.md')
})
it('matches a spaced path with line and column suffixes', () => {
const line = 'wrote /tmp/orca report/result.json:12:3 for you'
const result = matchFilePathAtColumn(line, colOf(line, 'result'))
expect(result).toEqual({
pathText: '/tmp/orca report/result.json',
line: 12,
column: 3
})
})
it('matches a spaced path when an earlier directory segment contains a dot', () => {
const line = 'wrote /tmp/v1.2 reports/result.json for you'
expect(matchFilePathAtColumn(line, colOf(line, 'v1.2'))).toEqual({
pathText: '/tmp/v1.2 reports/result.json',
line: null,
column: null
})
expect(matchFilePathAtColumn(line, colOf(line, 'reports'))).toEqual({
pathText: '/tmp/v1.2 reports/result.json',
line: null,
column: null
})
expect(matchFilePathAtColumn(line, colOf(line, 'result'))).toEqual({
pathText: '/tmp/v1.2 reports/result.json',
line: null,
column: null
})
})
it('matches a path whose final filename contains a space', () => {
const line = 'wrote /tmp/final report.json for you'
expect(matchFilePathAtColumn(line, colOf(line, 'final'))).toEqual({
pathText: '/tmp/final report.json',
line: null,
column: null
})
expect(matchFilePathAtColumn(line, colOf(line, 'report'))).toEqual({
pathText: '/tmp/final report.json',
line: null,
column: null
})
})
it('does not merge two spaced path candidates through prose', () => {
const line = 'see /tmp/a.txt and /tmp/b.txt done'
expect(matchFilePathAtColumn(line, colOf(line, 'a.txt'))).toEqual({
pathText: '/tmp/a.txt',
line: null,
column: null
})
expect(matchFilePathAtColumn(line, colOf(line, 'b.txt'))).toEqual({
pathText: '/tmp/b.txt',
line: null,
column: null
})
})
it('trims surrounding punctuation', () => {
@@ -74,6 +147,11 @@ describe('matchFilePathAtColumn', () => {
expect(matchFilePathAtColumn(line, 0)).toBeNull()
})
it('returns null when the tap is immediately after the path span', () => {
const line = 'prefix /tmp/x.ts next'
expect(matchFilePathAtColumn(line, line.indexOf(' next'))).toBeNull()
})
it('matches a bare filename with an extension (no slash)', () => {
// Why: agents commonly print a bare filename (e.g. a markdown link whose
// target was consumed). The host existence-check rejects non-files.
@@ -87,3 +165,24 @@ describe('matchFilePathAtColumn', () => {
expect(matchFilePathAtColumn(line, colOf(line, 'Here'))).toBeNull()
})
})
describe('injected matchFilePathAtColumn', () => {
const matcher = createInjectedPathMatcher()
it.each(TERMINAL_FILE_LINK_TAP_CONFORMANCE_CASES)(
'matches shared terminal file-link tap case: $name',
(testCase) => {
expect(matcher(testCase.lineText, columnForTerminalFileLinkTap(testCase))).toEqual(
testCase.expected
)
}
)
})
function createInjectedPathMatcher(): InjectedPathMatcher {
const context = createContext({})
new Script(
`${TERMINAL_PATH_TAP_JS}\nthis.__matchFilePathAtColumn = matchFilePathAtColumn;`
).runInContext(context)
return (context as { __matchFilePathAtColumn: InjectedPathMatcher }).__matchFilePathAtColumn
}
+111 -5
View File
@@ -13,11 +13,11 @@ export type TappedFilePath = {
// a bare filename with an extension (README.md, index.ts), optionally suffixed
// with :line or :line:col. Like desktop, we propose candidates and let the host
// existence-check reject non-files — agents often print a bare filename, so
// requiring a slash would miss the common case. The desktop's spaced-path
// variants are intentionally not ported: a tap always lands inside one
// whitespace-bounded segment, so this already covers the real cases.
// requiring a slash would miss the common case.
const LOCAL_PATH_REGEX =
/(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))[A-Za-z0-9._~\-/%+@\\()[\]]*(?::\d+)?(?::\d+)?/g
const SPACED_PATH_REGEX =
/(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/])[^()[\]{}'",;<>|`\r\n]+(?::\d+)?(?::\d+)?/g
const LEADING_TRIM_CHARS = new Set(['(', '[', '{', '"', "'"])
const TRAILING_TRIM_CHARS = new Set([')', ']', '}', '"', "'", ',', ';', '.'])
@@ -64,9 +64,116 @@ export function parsePathWithOptionalLineColumn(value: string): TappedFilePath |
return { pathText, line, column }
}
function hasSeparatorAfterWhitespace(text: string): boolean {
let sawWhitespace = false
for (const char of text) {
if (/\s/.test(char)) {
sawWhitespace = true
continue
}
if (sawWhitespace && (char === '/' || char === '\\')) {
return true
}
}
return false
}
function trimSpacedPathTrailingProse(
range: Span & { text: string },
col?: number
): (Span & { text: string }) | null {
// Why: a line-end extension token only extends the span when the added
// segment is path-like (contains a separator) — "v1.2 reports/result.json"
// extends, prose like "failed to start app.py" must not be swallowed.
let selected: string | null = null
const extensionPrefixPattern = /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$)/g
let match: RegExpExecArray | null
while ((match = extensionPrefixPattern.exec(range.text)) !== null) {
const end = match.index + match[0].length
const text = range.text.slice(0, end)
if (countPathStarts(text) > 1) {
continue
}
if (
end < range.text.length ||
selected === null ||
/[\\/]/.test(range.text.slice(selected.length, end))
) {
selected = text
}
}
if (!selected) {
const text = range.text.trimEnd()
return {
...range,
text,
endIndex: range.startIndex + text.length
}
}
if (col !== undefined && col >= range.startIndex + selected.length) {
return null
}
return {
text: selected,
startIndex: range.startIndex,
endIndex: range.startIndex + selected.length
}
}
function countPathStarts(text: string): number {
let count = 0
for (const match of text.matchAll(/(?:^|\s)(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/])/g)) {
void match
count += 1
}
return count
}
function hasSpacedPathExtension(text: string): boolean {
const trimmed = trimSpacedPathTrailingProse({
text,
startIndex: 0,
endIndex: text.length
})?.text.trimEnd()
if (!trimmed) {
return false
}
return /\s/.test(trimmed) && /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?$/.test(trimmed)
}
function matchSpacedFilePathAtColumn(lineText: string, col: number): TappedFilePath | null {
SPACED_PATH_REGEX.lastIndex = 0
let match: RegExpExecArray | null
while ((match = SPACED_PATH_REGEX.exec(lineText)) !== null) {
const trimmed = trimBoundaryPunctuation(match[0], match.index)
if (
!trimmed ||
(!hasSeparatorAfterWhitespace(trimmed.text) && !hasSpacedPathExtension(trimmed.text))
) {
continue
}
const candidate = trimSpacedPathTrailingProse(trimmed, col)
if (!candidate) {
continue
}
if (col < candidate.startIndex || col >= candidate.endIndex) {
continue
}
const parsed = parsePathWithOptionalLineColumn(candidate.text)
if (parsed) {
return parsed
}
}
return null
}
// Returns the file-path span (after punctuation trim) that contains `col`, or
// null when the tap isn't on a path.
export function matchFilePathAtColumn(lineText: string, col: number): TappedFilePath | null {
const spaced = matchSpacedFilePathAtColumn(lineText, col)
if (spaced) {
return spaced
}
LOCAL_PATH_REGEX.lastIndex = 0
let match: RegExpExecArray | null
while ((match = LOCAL_PATH_REGEX.exec(lineText)) !== null) {
@@ -78,8 +185,7 @@ export function matchFilePathAtColumn(lineText: string, col: number): TappedFile
if (!trimmed) {
continue
}
// Inclusive of the trailing edge so a tap on the last glyph still counts.
if (col < trimmed.startIndex || col > trimmed.endIndex) {
if (col < trimmed.startIndex || col >= trimmed.endIndex) {
continue
}
const parsed = parsePathWithOptionalLineColumn(trimmed.text)
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import type { RpcResponse } from '../transport/types'
import { isTerminalSendRpcAccepted } from './terminal-send-rpc-response'
const runtimeMeta = { runtimeId: 'test-runtime' } as const
describe('terminal send RPC response', () => {
it('Given accepted terminal send response When checked Then reports success', () => {
// Given
const response: RpcResponse = {
id: '1',
ok: true,
result: { send: { handle: 'terminal-1', accepted: true, bytesWritten: 1 } },
_meta: runtimeMeta
}
// When / Then
expect(isTerminalSendRpcAccepted(response)).toBe(true)
})
it('Given rejected terminal send response When checked Then reports failure', () => {
// Given
const response: RpcResponse = {
id: '1',
ok: true,
result: { send: { handle: 'terminal-1', accepted: false, bytesWritten: 0 } },
_meta: runtimeMeta
}
// When / Then
expect(isTerminalSendRpcAccepted(response)).toBe(false)
})
it('Given RPC failure or malformed terminal send response When checked Then reports failure', () => {
// Given
const rpcFailure: RpcResponse = {
id: '1',
ok: false,
error: { code: 'terminal_error', message: 'failed' },
_meta: runtimeMeta
}
const malformedSuccess: RpcResponse = {
id: '2',
ok: true,
result: {},
_meta: runtimeMeta
}
// When / Then
expect(isTerminalSendRpcAccepted(rpcFailure)).toBe(false)
expect(isTerminalSendRpcAccepted(malformedSuccess)).toBe(false)
})
})
@@ -0,0 +1,15 @@
import type { RpcResponse } from '../transport/types'
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
export function isTerminalSendRpcAccepted(response: RpcResponse): boolean {
if (!response.ok) {
return false
}
if (!isRecord(response.result) || !isRecord(response.result.send)) {
return false
}
return response.result.send.accepted === true
}
@@ -1,4 +1,4 @@
// @vitest-environment jsdom
// @vitest-environment happy-dom
// Exercises the in-WebView touch dispatcher end-to-end: a surface tap on a
// printed http(s) URL must post an `open-url` message (which RN routes to the
// in-app/phone browser). Regression guard for taps that jitter a few pixels —
@@ -174,6 +174,39 @@ describe('terminal WebView tap routing', () => {
expect(posted.find((m) => m.type === 'open-url')?.url).toBe('https://example.com/issue/1234')
})
it('opens first-load file OSC links through terminal file taps', async () => {
const oscLinks = [{ row: 0, startCol: 5, endCol: 15, uri: 'file:///tmp/result.json#L12C3' }]
const { posted } = boot('open artifact here', oscLinks)
await settle()
fireTouch('touchstart', [{ x: screenXForCol(7), y: tapY }])
fireTouch('touchend', [])
expect(posted.find((m) => m.type === 'open-url')).toBeUndefined()
expect(posted.find((m) => m.type === 'terminal-file-tap')).toMatchObject({
type: 'terminal-file-tap',
pathText: '/tmp/result.json',
line: 12,
column: 3
})
})
it('opens plain file URLs through terminal file taps', async () => {
const line = 'open file:///tmp/result.json#L12C3 now'
const { posted } = boot(line)
await settle()
fireTouch('touchstart', [{ x: screenXForCol(line.indexOf('result')), y: tapY }])
fireTouch('touchend', [])
expect(posted.find((m) => m.type === 'terminal-file-tap')).toMatchObject({
type: 'terminal-file-tap',
pathText: '/tmp/result.json',
line: 12,
column: 3
})
})
it('does not open snapshot OSC links from adjacent terminal cells', async () => {
const oscLinks = [{ row: 0, startCol: 6, endCol: 11, uri: 'https://example.com/issue/1234' }]
const { posted } = boot('issue #1234 done', oscLinks)
@@ -185,6 +218,36 @@ describe('terminal WebView tap routing', () => {
expect(posted.find((m) => m.type === 'open-url')).toBeUndefined()
})
it('does not parse off-cell snapshot file OSC links while routing a tap', async () => {
const oscLinks = Array.from({ length: 50 }, (_, index) => ({
row: index + 1,
startCol: 0,
endCol: 10,
uri: `file:///tmp/result-${index}.json#L1`
}))
const { posted } = boot('plain text', oscLinks)
await settle()
const OriginalURL = window.URL
let parseCount = 0
const CountingURL = class extends OriginalURL {
constructor(url: string | URL, base?: string | URL) {
parseCount += 1
super(url, base)
}
}
Object.defineProperty(window, 'URL', { value: CountingURL, configurable: true })
try {
fireTouch('touchstart', [{ x: screenXForCol(5), y: tapY }])
fireTouch('touchend', [])
} finally {
Object.defineProperty(window, 'URL', { value: OriginalURL, configurable: true })
}
expect(parseCount).toBe(0)
expect(posted.find((m) => m.type === 'terminal-file-tap')).toBeUndefined()
})
it('does not open stale snapshot OSC links after the row text changes', async () => {
const oscLinks = [{ row: 0, startCol: 6, endCol: 11, uri: 'https://example.com/issue/1234' }]
const { posted, setLine } = boot('issue #1234 done', oscLinks)
@@ -1,11 +1,140 @@
import { createContext, Script } from 'node:vm'
import { describe, expect, it } from 'vitest'
import type { TappedFilePath } from './terminal-path-tap'
import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected'
import {
TERMINAL_HTTP_URL_MAX_LENGTH,
TERMINAL_HTTP_URL_REGEX_SOURCE,
findUrlAtColumn
URL_TAP_WEBVIEW_JS,
findFileUrlAtColumn,
findUrlAtColumn,
resolveTerminalOscFileTap,
resolveTerminalFileUrlTap
} from './terminal-webview-url-tap'
import { XTERM_HTML } from './terminal-webview-html'
type FileTapResolverCase = {
name: string
uri: string
expected: TappedFilePath | null
}
const FILE_URL_TAP_CASES: FileTapResolverCase[] = [
{
name: 'plain file URL',
uri: 'file:///tmp/result.json',
expected: { pathText: '/tmp/result.json', line: null, column: null }
},
{
name: 'hash line and column',
uri: 'file:///tmp/result.json#L12C3',
expected: { pathText: '/tmp/result.json', line: 12, column: 3 }
},
{
name: 'trailing line and column suffix',
uri: 'file:///tmp/result.json:8:2',
expected: { pathText: '/tmp/result.json', line: 8, column: 2 }
},
{
name: 'percent-encoded colon suffix stays in the path',
uri: 'file:///tmp/report%3A8%3A2',
expected: { pathText: '/tmp/report:8:2', line: null, column: null }
},
{
name: 'Windows drive path with hash line',
uri: 'file:///C:/repo/src/app.ts#L4',
expected: { pathText: 'C:/repo/src/app.ts', line: 4, column: null }
},
{
name: 'host-qualified POSIX authority is preserved',
uri: 'file://remote-host/tmp/result.json#L12',
expected: { pathText: '//remote-host/tmp/result.json', line: 12, column: null }
},
{
name: 'bracketed IPv6 loopback is local',
uri: 'file://[::1]/tmp/result.json#L12',
expected: { pathText: '/tmp/result.json', line: 12, column: null }
},
{
name: 'IPv4 loopback is local',
uri: 'file://127.0.0.1/tmp/result.json#L12',
expected: { pathText: '/tmp/result.json', line: 12, column: null }
},
{
name: 'UNC authority is preserved',
uri: 'file://server/share/repo/app.ts#L12',
expected: { pathText: '//server/share/repo/app.ts', line: 12, column: null }
},
{
name: 'non-file scheme is rejected',
uri: 'https://example.com/result.json',
expected: null
}
]
const OSC_FILE_TAP_CASES: FileTapResolverCase[] = [
{
name: 'relative path',
uri: 'docs/README.md',
expected: { pathText: 'docs/README.md', line: null, column: null }
},
{
name: 'tilde path with line and column',
uri: '~/notes.md:4:2',
expected: { pathText: '~/notes.md', line: 4, column: 2 }
},
{
name: 'mailto target is rejected',
uri: 'mailto:team@example.com',
expected: null
}
]
type InjectedFileTapResolver = (uri: string) => TappedFilePath | null
// Why: the WebView blob hand-translates terminal-file-url-tap.ts into plain JS
// with re-escaped regexes; executing it against the same cases as the TS module
// keeps the two copies from drifting (mirrors createInjectedPathMatcher).
function createInjectedFileTapResolvers(): {
resolveTerminalFileUrlTap: InjectedFileTapResolver
resolveTerminalOscFileTap: InjectedFileTapResolver
} {
const context = createContext({ URL })
new Script(
`${TERMINAL_PATH_TAP_JS}\n${URL_TAP_WEBVIEW_JS}\n` +
'this.__resolveTerminalFileUrlTap = resolveTerminalFileUrlTap;\n' +
'this.__resolveTerminalOscFileTap = resolveTerminalOscFileTap;'
).runInContext(context)
const injected = context as {
__resolveTerminalFileUrlTap: InjectedFileTapResolver
__resolveTerminalOscFileTap: InjectedFileTapResolver
}
return {
resolveTerminalFileUrlTap: injected.__resolveTerminalFileUrlTap,
resolveTerminalOscFileTap: injected.__resolveTerminalOscFileTap
}
}
describe.each([
['module', { resolveTerminalFileUrlTap, resolveTerminalOscFileTap }],
['injected', createInjectedFileTapResolvers()]
] as const)('terminal file tap resolvers (%s)', (_variant, resolvers) => {
it.each(FILE_URL_TAP_CASES)('resolves file URL: $name', ({ uri, expected }) => {
expect(resolvers.resolveTerminalFileUrlTap(uri)).toEqual(expected)
})
it.each(OSC_FILE_TAP_CASES)('resolves OSC target: $name', ({ uri, expected }) => {
expect(resolvers.resolveTerminalOscFileTap(uri)).toEqual(expected)
})
it.each(FILE_URL_TAP_CASES)('OSC resolver accepts file URL: $name', ({ uri, expected }) => {
if (expected === null) {
return
}
expect(resolvers.resolveTerminalOscFileTap(uri)).toEqual(expected)
})
})
describe('findUrlAtColumn', () => {
it('returns the URL when the tapped column falls inside it', () => {
const line = 'see https://example.com/path for details'
@@ -46,6 +175,13 @@ describe('findUrlAtColumn', () => {
expect(findUrlAtColumn(line, line.indexOf('etc'))).toBeNull()
})
it('finds file URLs separately so taps route to file opens', () => {
const line = 'open file:///tmp/result.json#L12C3 please'
expect(findFileUrlAtColumn(line, line.indexOf('result'))).toBe('file:///tmp/result.json#L12C3')
expect(findFileUrlAtColumn(line, 0)).toBeNull()
})
it('matches desktop URL boundary and length guards', () => {
expect(findUrlAtColumn('prefixhttps://example.com/path', 'prefix'.length)).toBeNull()
expect(findUrlAtColumn('prefix https://example.com/path', 'prefix '.length)).toBe(
@@ -58,9 +194,15 @@ describe('findUrlAtColumn', () => {
it('injects URL and OSC tap handling into the WebView document', () => {
expect(XTERM_HTML).toContain('function findUrlAtColumn(')
expect(XTERM_HTML).toContain('function findFileUrlAtColumn(')
expect(XTERM_HTML).toContain('function fileUrlAtViewportPoint(')
expect(XTERM_HTML).toContain('function urlAtViewportPoint(')
expect(XTERM_HTML).toContain(JSON.stringify(TERMINAL_HTTP_URL_REGEX_SOURCE))
expect(XTERM_HTML).toContain('function oscLinkAtViewportPoint(')
expect(XTERM_HTML).toContain('function resolveTerminalOscFileTap(')
expect(XTERM_HTML).toContain('function resolveTerminalFileUrlTap(')
expect(XTERM_HTML).toContain('function isLocalFileUriHostname(')
expect(XTERM_HTML).toContain('return parsePathLineCol(value);')
expect(XTERM_HTML).toContain('function notifyTerminalSurfaceTap(')
expect(XTERM_HTML).toContain("notify({ type: 'open-url', url: tappedUrl });")
})
+117 -6
View File
@@ -1,16 +1,32 @@
export { resolveTerminalFileUrlTap, resolveTerminalOscFileTap } from './terminal-file-url-tap'
export const TERMINAL_HTTP_URL_REGEX_SOURCE =
String.raw`\bhttps?:\/\/[^\s"'!*(){}|\\^<>` +
'`' +
String.raw`]*[^\s"':,.!?{}|\\^~[\]` +
'`' +
String.raw`()<>]`
export const TERMINAL_FILE_URL_REGEX_SOURCE =
String.raw`\bfile:\/\/[^\s"'!*(){}|\\^<>` +
'`' +
String.raw`]*[^\s"',!?{}|\\^~[\]` +
'`' +
String.raw`()<>]`
export const TERMINAL_HTTP_URL_MAX_LENGTH = 2048
export function findUrlAtColumn(lineText: string, col: number): string | null {
return findTerminalUrlAtColumn(lineText, col, TERMINAL_HTTP_URL_REGEX_SOURCE)
}
export function findFileUrlAtColumn(lineText: string, col: number): string | null {
return findTerminalUrlAtColumn(lineText, col, TERMINAL_FILE_URL_REGEX_SOURCE)
}
function findTerminalUrlAtColumn(lineText: string, col: number, source: string): string | null {
if (typeof lineText !== 'string' || lineText.length === 0) {
return null
}
const re = new RegExp(TERMINAL_HTTP_URL_REGEX_SOURCE, 'gi')
const re = new RegExp(source, 'gi')
let match: RegExpExecArray | null
while ((match = re.exec(lineText)) !== null) {
const start = match.index
@@ -30,10 +46,17 @@ export function findUrlAtColumn(lineText: string, col: number): string | null {
export const URL_TAP_WEBVIEW_JS = `
var URL_TAP_RE_SOURCE = ${JSON.stringify(TERMINAL_HTTP_URL_REGEX_SOURCE)};
var FILE_URL_TAP_RE_SOURCE = ${JSON.stringify(TERMINAL_FILE_URL_REGEX_SOURCE)};
var URL_TAP_MAX_LENGTH = ${TERMINAL_HTTP_URL_MAX_LENGTH};
function findUrlAtColumn(lineText, col) {
return findTerminalUrlAtColumn(lineText, col, URL_TAP_RE_SOURCE);
}
function findFileUrlAtColumn(lineText, col) {
return findTerminalUrlAtColumn(lineText, col, FILE_URL_TAP_RE_SOURCE);
}
function findTerminalUrlAtColumn(lineText, col, source) {
if (typeof lineText !== 'string' || lineText.length === 0) return null;
var re = new RegExp(URL_TAP_RE_SOURCE, 'gi');
var re = new RegExp(source, 'gi');
var match;
while ((match = re.exec(lineText)) !== null) {
var end = match.index + match[0].length;
@@ -42,6 +65,11 @@ export const URL_TAP_WEBVIEW_JS = `
}
return null;
}
function fileUrlAtViewportPoint(clientX, clientY) {
var cell = viewportToCell(clientX, clientY);
if (!cell) return null;
return findFileUrlAtColumn(getLineText(cell.row), cellColToStringIndex(cell.row, cell.col));
}
function urlAtViewportPoint(clientX, clientY) {
var cell = viewportToCell(clientX, clientY);
if (!cell) return null;
@@ -73,19 +101,81 @@ export const URL_TAP_WEBVIEW_JS = `
if (!svc || !svc.getLinkData) return initialOscLinkAtCell(cell.row, cell.col);
var data = svc.getLinkData(urlId);
var uri = data && data.uri;
return uri && /^https?:/i.test(uri) ? uri : null;
return terminalOscLinkTarget(uri);
} catch (e) { return null; }
}
function initialOscLinkAtCell(row, col) {
for (var i = 0; i < initialOscLinks.length; i++) {
var link = initialOscLinks[i];
if (!link || typeof link.uri !== 'string' || !/^https?:/i.test(link.uri)) continue;
if (!link || typeof link.uri !== 'string') continue;
if (link.row < initialOscLinkRowOffset) continue;
var shiftedRow = link.row - initialOscLinkRowOffset;
if (shiftedRow === row && col >= link.startCol && col < link.endCol && initialOscLinkTextStillMatches(link, shiftedRow)) return link.uri;
if (shiftedRow === row && col >= link.startCol && col < link.endCol && initialOscLinkTextStillMatches(link, shiftedRow)) return terminalOscLinkTarget(link.uri);
}
return null;
}
function terminalOscLinkTarget(uri) {
if (typeof uri !== 'string') return null;
if (/^https?:/i.test(uri)) return { kind: 'url', url: uri };
var fileTap = resolveTerminalOscFileTap(uri);
return fileTap ? { kind: 'file', fileTap: fileTap } : null;
}
function resolveTerminalOscFileTap(uri) {
return resolveTerminalFileUrlTap(uri) || parseOscPathLikeTarget(uri);
}
function resolveTerminalFileUrlTap(uri) {
var parsed;
try {
parsed = new URL(uri);
} catch (e) {
return null;
}
if (parsed.protocol !== 'file:') return null;
var filePath;
try {
filePath = decodeURIComponent(parsed.pathname || '');
} catch (e) {
return null;
}
if (parsed.hostname && !isLocalFileUriHostname(parsed.hostname)) {
filePath = '//' + parsed.hostname + filePath;
} else if (/^\\/[A-Za-z]:\\//.test(filePath)) {
filePath = filePath.slice(1);
}
if (!filePath) return null;
var hashTarget = parseFileUrlLineHash(parsed.hash || '');
if (hashTarget) {
return { pathText: filePath, line: hashTarget.line, column: hashTarget.column };
}
if (/%3a/i.test(parsed.pathname || '')) {
return { pathText: filePath, line: null, column: null };
}
return parseFilePathTrailingLineTarget(filePath) || { pathText: filePath, line: null, column: null };
}
function isLocalFileUriHostname(hostname) {
var normalized = String(hostname).toLowerCase();
return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1' || normalized === '[::1]';
}
function parseOscPathLikeTarget(value) {
if (!/^(?:~[\\\\/]|[\\\\/]|\\.{1,2}[\\\\/]|[A-Za-z]:[\\\\/]|[A-Za-z0-9._-]+[\\\\/]|(?=[A-Za-z0-9._-]*\\.[A-Za-z0-9]))/.test(value)) return null;
return parsePathLineCol(value);
}
function parseFileUrlLineHash(hash) {
var match = /^#?L(\\d+)(?:C(\\d+))?$/i.exec(hash);
if (!match) return null;
var line = parseInt(match[1], 10);
var column = match[2] ? parseInt(match[2], 10) : null;
if (line < 1 || (column !== null && column < 1)) return null;
return { line: line, column: column };
}
function parseFilePathTrailingLineTarget(filePath) {
var match = /^(.*?)(?::(\\d+))(?::(\\d+))?$/.exec(filePath);
if (!match || !match[1] || match[1].charAt(match[1].length - 1) === '/' || match[1].charAt(match[1].length - 1) === '\\\\') return null;
var line = parseInt(match[2], 10);
var column = match[3] ? parseInt(match[3], 10) : null;
if (line < 1 || (column !== null && column < 1)) return null;
return { pathText: match[1], line: line, column: column };
}
function captureInitialOscLinkTexts() {
if (!Array.isArray(initialOscLinks)) return;
for (var i = 0; i < initialOscLinks.length; i++) {
@@ -116,7 +206,28 @@ export const URL_TAP_WEBVIEW_JS = `
}
function notifyTerminalSurfaceTap(originX, originY) {
var tappedUrl = oscLinkAtViewportPoint(originX, originY) || urlAtViewportPoint(originX, originY);
var tappedOscLink = oscLinkAtViewportPoint(originX, originY);
if (tappedOscLink && tappedOscLink.kind === 'file') {
notify({
type: 'terminal-file-tap',
pathText: tappedOscLink.fileTap.pathText,
line: tappedOscLink.fileTap.line,
column: tappedOscLink.fileTap.column
});
return;
}
var tappedFileUrl = fileUrlAtViewportPoint(originX, originY);
var tappedFileUrlPath = tappedFileUrl ? resolveTerminalFileUrlTap(tappedFileUrl) : null;
if (tappedFileUrlPath) {
notify({
type: 'terminal-file-tap',
pathText: tappedFileUrlPath.pathText,
line: tappedFileUrlPath.line,
column: tappedFileUrlPath.column
});
return;
}
var tappedUrl = tappedOscLink && tappedOscLink.kind === 'url' ? tappedOscLink.url : urlAtViewportPoint(originX, originY);
if (tappedUrl) {
notify({ type: 'open-url', url: tappedUrl });
return;
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import { getTerminalLiveAccessoryInactiveInputCommitResult } from './use-terminal-live-accessory-input-commit'
type DeferredBoolean = {
readonly promise: Promise<boolean>
readonly resolve: (value: boolean) => void
}
function createDeferredBoolean(): DeferredBoolean {
let resolvePromise: (value: boolean) => void = () => {
throw new Error('deferred promise was resolved before initialization')
}
const promise = new Promise<boolean>((resolve) => {
resolvePromise = resolve
})
return { promise, resolve: resolvePromise }
}
describe('terminal live accessory input commit', () => {
it('Given live input is disabled with an active flush When accessory raw fallback is requested Then waits before allowing raw send', async () => {
// Given
const deferredFlush = createDeferredBoolean()
let settled = false
// When
const resultPromise = getTerminalLiveAccessoryInactiveInputCommitResult(
() => deferredFlush.promise
)
void resultPromise.then(() => {
settled = true
})
await Promise.resolve()
// Then
expect(settled).toBe(false)
deferredFlush.resolve(true)
await expect(resultPromise).resolves.toEqual({ kind: 'allow-raw' })
})
it('Given live input is disabled with a failed active flush When accessory raw fallback is requested Then suppresses raw send', async () => {
// Given
const waitForPendingLiveInputFlush = async (): Promise<boolean> => false
// When
const result = await getTerminalLiveAccessoryInactiveInputCommitResult(
waitForPendingLiveInputFlush
)
// Then
expect(result).toEqual({ kind: 'suppress-raw' })
})
})
@@ -0,0 +1,124 @@
import { useCallback, type RefObject } from 'react'
import type { TextInput } from 'react-native'
import {
getTerminalLiveAccessoryBytesDecision,
getTerminalLiveAccessoryLocalEditText,
getTerminalLiveDeferredTextDelayMs
} from './terminal-live-text-commit'
import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input'
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
export type TerminalLiveAccessoryInputCommitResult =
| { readonly kind: 'allow-raw' }
| { readonly kind: 'handled' }
| { readonly kind: 'suppress-raw' }
type TerminalLiveInputCommitScheduler = (
handle: string,
text: string,
delayMs: number | null
) => void
export async function getTerminalLiveAccessoryInactiveInputCommitResult(
waitForPendingLiveInputFlush: () => Promise<boolean>
): Promise<TerminalLiveAccessoryInputCommitResult> {
return (await waitForPendingLiveInputFlush()) ? { kind: 'allow-raw' } : { kind: 'suppress-raw' }
}
type TerminalLiveAccessoryInputCommitOptions = {
readonly activeHandle: string | null
readonly clearPendingLiveInputCommit: () => void
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean>
readonly liveInputRef: RefObject<TextInput | null>
readonly liveInputTerminalHandles: ReadonlySet<string>
readonly pendingLiveInputHandleRef: RefObject<string | null>
readonly pendingLiveInputTextRef: RefObject<string>
readonly schedulePendingLiveInputCommit: TerminalLiveInputCommitScheduler
readonly sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender>
readonly setLiveInputCapture: (text: string) => void
readonly waitForPendingLiveInputFlush: () => Promise<boolean>
}
export function useTerminalLiveAccessoryInputCommit({
activeHandle,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
liveInputRef,
liveInputTerminalHandles,
pendingLiveInputHandleRef,
pendingLiveInputTextRef,
schedulePendingLiveInputCommit,
sendLiveTerminalInputRef,
setLiveInputCapture,
waitForPendingLiveInputFlush
}: TerminalLiveAccessoryInputCommitOptions): (
input: TerminalLiveAccessoryInput
) => Promise<TerminalLiveAccessoryInputCommitResult> {
return useCallback(
async (input: TerminalLiveAccessoryInput): Promise<TerminalLiveAccessoryInputCommitResult> => {
if (!activeHandle) {
return { kind: 'allow-raw' }
}
if (!liveInputTerminalHandles.has(activeHandle)) {
return getTerminalLiveAccessoryInactiveInputCommitResult(waitForPendingLiveInputFlush)
}
const pendingText =
pendingLiveInputHandleRef.current === activeHandle ? pendingLiveInputTextRef.current : ''
if (pendingLiveInputHandleRef.current && pendingLiveInputHandleRef.current !== activeHandle) {
clearPendingLiveInputCommit()
}
const decision = getTerminalLiveAccessoryBytesDecision({ ...input, pendingText })
switch (decision.kind) {
case 'send-now':
// Why: raw accessory bytes must wait behind any in-flight IME text
// flush so composed Hangul reaches the PTY before follow-up controls.
return (await waitForPendingLiveInputFlush())
? { kind: 'allow-raw' }
: { kind: 'suppress-raw' }
case 'local-edit': {
const editedText = getTerminalLiveAccessoryLocalEditText({
localEdit: decision.localEdit,
pendingText
})
if (editedText.length === 0) {
clearPendingLiveInputCommit()
return { kind: 'handled' }
}
// Why: accessory buttons do not emit native TextInput edits, so the
// pending IME buffer must be edited and rescheduled here.
setLiveInputCapture(editedText)
liveInputRef.current?.setNativeProps({ text: editedText })
schedulePendingLiveInputCommit(
activeHandle,
editedText,
getTerminalLiveDeferredTextDelayMs(editedText)
)
return { kind: 'handled' }
}
case 'flush-then-send':
await sendTerminalLiveControlAfterPendingFlush(
() => flushPendingLiveInputText(activeHandle),
() => sendLiveTerminalInputRef.current(activeHandle, decision.bytes)
)
return { kind: 'handled' }
default:
decision satisfies never
return { kind: 'handled' }
}
},
[
activeHandle,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
liveInputRef,
liveInputTerminalHandles,
pendingLiveInputHandleRef,
pendingLiveInputTextRef,
schedulePendingLiveInputCommit,
sendLiveTerminalInputRef,
setLiveInputCapture,
waitForPendingLiveInputFlush
]
)
}
@@ -0,0 +1,184 @@
import { createElement, type RefObject } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import type { TextInput } from 'react-native'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
import { TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS } from './terminal-live-text-commit'
import { useTerminalLiveInputCommit } from './use-terminal-live-input-commit'
type TerminalLiveInputCommitHarness = {
readonly captures: readonly string[]
readonly handlers: ReturnType<typeof useTerminalLiveInputCommit<string>>
readonly sent: readonly string[]
readonly unmount: () => void
}
type TerminalLiveInputCommitHarnessOptions = {
readonly sendResult?: boolean
}
function suppressReactTestRendererDeprecationWarning(): () => void {
const originalConsoleError = console.error
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
const firstArg = args[0]
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
return
}
originalConsoleError(...args)
})
return () => consoleErrorSpy.mockRestore()
}
function createTerminalLiveInputCommitHarness({
sendResult = true
}: TerminalLiveInputCommitHarnessOptions = {}): TerminalLiveInputCommitHarness {
const activeHandle = 'terminal-a'
const activeHandleRef: RefObject<string | null> = { current: activeHandle }
const activeSessionTabTypeRef: RefObject<string | null> = { current: 'terminal' }
const captures: string[] = []
const liveInputRef: RefObject<TextInput | null> = { current: null }
const liveInputTerminalHandles = new Set([activeHandle])
const liveInputTerminalHandlesRef: RefObject<Set<string>> = {
current: new Set([activeHandle])
}
const sent: string[] = []
const sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender> = {
current: async (_handle, bytes) => {
sent.push(bytes)
return sendResult
}
}
let handlers: ReturnType<typeof useTerminalLiveInputCommit<string>> | null = null
let renderer: ReactTestRenderer | null = null
function Harness(): null {
handlers = useTerminalLiveInputCommit({
activeHandle,
activeHandleRef,
activeSessionTabType: 'terminal',
activeSessionTabTypeRef,
liveInputRef,
liveInputTerminalHandles,
liveInputTerminalHandlesRef,
sendLiveTerminalInputRef,
setLiveInputCapture: (text) => captures.push(text)
})
return null
}
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
try {
act(() => {
renderer = create(createElement(Harness))
})
} finally {
restoreConsoleError()
}
if (!handlers || !renderer) {
throw new Error('terminal live input hook did not render')
}
return {
captures,
handlers,
sent,
unmount: () => {
act(() => renderer?.unmount())
}
}
}
describe('terminal live input commit hook', () => {
afterEach(() => {
vi.useRealTimers()
})
it('Given Hangul pending text When the old idle window elapses Then does not send jamo to the terminal', async () => {
// Given
vi.useFakeTimers()
const { captures, handlers, sent } = createTerminalLiveInputCommitHarness()
// When
handlers.handleLiveInputChange('ㅎ')
await vi.advanceTimersByTimeAsync(1_000)
// Then
expect(captures).toEqual(['ㅎ'])
expect(sent).toEqual([])
})
it('Given Hangul pending text When submit is requested Then sends composed text before carriage return', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('한')
// When
handlers.handleLiveInputSubmit()
// Then
await vi.waitFor(() => expect(sent).toEqual(['한', '\r']))
})
it('Given Hangul pending text When an external terminal send is requested Then flushes composed text first', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('한')
// When
const flushed = await handlers.flushPendingLiveInputBeforeExternalSend('terminal-a')
// Then
expect(flushed).toBe(true)
expect(sent).toEqual(['한'])
})
it('Given pending text cannot be sent When an external terminal send is requested Then reports failure', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness({ sendResult: false })
handlers.handleLiveInputChange('한')
// When
const flushed = await handlers.flushPendingLiveInputBeforeExternalSend('terminal-a')
// Then
expect(flushed).toBe(false)
expect(sent).toEqual(['한'])
})
it('Given Chinese and Vietnamese IME text When the settle window elapses Then sends the committed text', async () => {
// Given
vi.useFakeTimers()
const { captures, handlers, sent } = createTerminalLiveInputCommitHarness()
// When
handlers.handleLiveInputChange('你好')
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS - 1)
// Then
expect(captures).toEqual(['你好'])
expect(sent).toEqual([])
// When
await vi.advanceTimersByTimeAsync(1)
await vi.waitFor(() => expect(sent).toEqual(['你好']))
handlers.handleLiveInputChange('tiếng Việt')
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS)
// Then
await vi.waitFor(() => expect(sent).toEqual(['你好', 'tiếng Việt']))
})
it('Given deferred IME text When the hook unmounts Then cancels the pending commit timer', async () => {
// Given
vi.useFakeTimers()
const { handlers, sent, unmount } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('é')
// When
unmount()
await vi.advanceTimersByTimeAsync(1_000)
// Then
expect(sent).toEqual([])
})
})
@@ -0,0 +1,236 @@
import { useCallback, useEffect, type RefObject } from 'react'
import type { TextInput } from 'react-native'
import {
getTerminalLiveSpecialKeyDecision,
getTerminalLiveSubmitSequence,
getTerminalLiveTextChangeDecision
} from './terminal-live-text-commit'
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input'
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
import { normalizeTerminalTextInput } from './terminal-text-input-normalization'
import { useTerminalLivePendingInputFlush } from './use-terminal-live-pending-input-flush'
import {
useTerminalLiveAccessoryInputCommit,
type TerminalLiveAccessoryInputCommitResult
} from './use-terminal-live-accessory-input-commit'
type TerminalLiveInputKeyPressEvent = {
readonly nativeEvent: {
readonly key: string
}
}
type TerminalLiveInputCommitOptions<TTabType extends string> = {
readonly activeHandle: string | null
readonly activeHandleRef: RefObject<string | null>
readonly activeSessionTabType: TTabType | null | undefined
readonly activeSessionTabTypeRef: RefObject<TTabType | null>
readonly liveInputRef: RefObject<TextInput | null>
readonly liveInputTerminalHandles: ReadonlySet<string>
readonly liveInputTerminalHandlesRef: RefObject<Set<string>>
readonly sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender>
readonly setLiveInputCapture: (text: string) => void
}
type TerminalLiveInputCommitHandlers = {
readonly clearPendingLiveInputCommit: () => void
readonly flushPendingLiveInputBeforeExternalSend: (handle: string) => Promise<boolean>
readonly handleLiveInputAccessoryBytes: (
input: TerminalLiveAccessoryInput
) => Promise<TerminalLiveAccessoryInputCommitResult>
readonly handleLiveInputChange: (text: string) => void
readonly handleLiveInputKeyPress: (event: TerminalLiveInputKeyPressEvent) => void
readonly handleLiveInputSubmit: () => void
}
export function useTerminalLiveInputCommit<TTabType extends string>({
activeHandle,
activeHandleRef,
activeSessionTabType,
activeSessionTabTypeRef,
liveInputRef,
liveInputTerminalHandles,
liveInputTerminalHandlesRef,
sendLiveTerminalInputRef,
setLiveInputCapture
}: TerminalLiveInputCommitOptions<TTabType>): TerminalLiveInputCommitHandlers {
const {
clearPendingLiveInputCommit,
flushPendingLiveInputText,
pendingLiveInputHandleRef,
pendingLiveInputTextRef,
schedulePendingLiveInputCommit,
waitForPendingLiveInputFlush
} = useTerminalLivePendingInputFlush({
activeHandleRef,
activeSessionTabTypeRef,
liveInputRef,
liveInputTerminalHandlesRef,
sendLiveTerminalInputRef,
setLiveInputCapture
})
useEffect(() => {
const pendingHandle = pendingLiveInputHandleRef.current
if (!pendingHandle) {
return
}
if (
!activeHandle ||
pendingHandle !== activeHandle ||
activeSessionTabType !== 'terminal' ||
!liveInputTerminalHandles.has(activeHandle)
) {
clearPendingLiveInputCommit()
}
}, [activeHandle, activeSessionTabType, clearPendingLiveInputCommit, liveInputTerminalHandles])
const flushPendingLiveInputBeforeExternalSend = useCallback(
async (handle: string): Promise<boolean> => {
const pendingHandle = pendingLiveInputHandleRef.current
if (pendingHandle && pendingHandle !== handle) {
clearPendingLiveInputCommit()
return waitForPendingLiveInputFlush()
}
if (pendingHandle === handle && pendingLiveInputTextRef.current.length > 0) {
return flushPendingLiveInputText(handle)
}
return waitForPendingLiveInputFlush()
},
[clearPendingLiveInputCommit, flushPendingLiveInputText, waitForPendingLiveInputFlush]
)
const handleLiveInputChange = useCallback(
(text: string) => {
if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) {
clearPendingLiveInputCommit()
return
}
const normalizedText = normalizeTerminalTextInput(text)
const decision = getTerminalLiveTextChangeDecision(normalizedText)
switch (decision.kind) {
case 'ignore':
clearPendingLiveInputCommit()
return
case 'send-now':
clearPendingLiveInputCommit()
void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () =>
sendLiveTerminalInputRef.current(activeHandle, decision.text)
)
return
case 'defer':
// Why: React Native does not expose composition events here, so keep
// probable IME text in the native field until the commit timer settles.
setLiveInputCapture(decision.text)
schedulePendingLiveInputCommit(activeHandle, decision.text, decision.delayMs)
return
default:
decision satisfies never
}
},
[
activeHandle,
clearPendingLiveInputCommit,
liveInputTerminalHandles,
schedulePendingLiveInputCommit,
sendLiveTerminalInputRef,
setLiveInputCapture,
waitForPendingLiveInputFlush
]
)
const handleLiveInputKeyPress = useCallback(
(event: TerminalLiveInputKeyPressEvent) => {
if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) {
return
}
const pendingText =
pendingLiveInputHandleRef.current === activeHandle ? pendingLiveInputTextRef.current : ''
if (pendingLiveInputHandleRef.current && pendingLiveInputHandleRef.current !== activeHandle) {
clearPendingLiveInputCommit()
}
const decision = getTerminalLiveSpecialKeyDecision({
key: event.nativeEvent.key,
pendingText
})
switch (decision.kind) {
case 'ignore':
case 'local-edit':
return
case 'send-now':
clearPendingLiveInputCommit()
void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () =>
sendLiveTerminalInputRef.current(activeHandle, decision.bytes)
)
return
case 'flush-then-send':
void sendTerminalLiveControlAfterPendingFlush(
() => flushPendingLiveInputText(activeHandle),
() => sendLiveTerminalInputRef.current(activeHandle, decision.bytes)
)
return
default:
decision satisfies never
}
},
[
activeHandle,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
liveInputTerminalHandles,
sendLiveTerminalInputRef,
waitForPendingLiveInputFlush
]
)
const handleLiveInputAccessoryBytes = useTerminalLiveAccessoryInputCommit({
activeHandle,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
liveInputRef,
liveInputTerminalHandles,
pendingLiveInputHandleRef,
pendingLiveInputTextRef,
schedulePendingLiveInputCommit,
sendLiveTerminalInputRef,
setLiveInputCapture,
waitForPendingLiveInputFlush
})
const handleLiveInputSubmit = useCallback(() => {
if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) {
return
}
const pendingText =
pendingLiveInputHandleRef.current === activeHandle ? pendingLiveInputTextRef.current : ''
const sequence = getTerminalLiveSubmitSequence(pendingText)
if (sequence.length === 2) {
void sendTerminalLiveControlAfterPendingFlush(
() => flushPendingLiveInputText(activeHandle),
() => sendLiveTerminalInputRef.current(activeHandle, sequence[1])
)
return
}
clearPendingLiveInputCommit()
void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () =>
sendLiveTerminalInputRef.current(activeHandle, sequence[0])
)
}, [
activeHandle,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
liveInputTerminalHandles,
sendLiveTerminalInputRef,
waitForPendingLiveInputFlush
])
return {
clearPendingLiveInputCommit,
flushPendingLiveInputBeforeExternalSend,
handleLiveInputAccessoryBytes,
handleLiveInputChange,
handleLiveInputKeyPress,
handleLiveInputSubmit
}
}
@@ -0,0 +1,139 @@
import { useCallback, useEffect, useRef, type RefObject } from 'react'
import type { TextInput } from 'react-native'
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
import {
queueTerminalLivePendingFlush,
waitForTerminalLivePendingFlush
} from './terminal-live-pending-flush-state'
type TerminalLivePendingInputFlushOptions<TTabType extends string> = {
readonly activeHandleRef: RefObject<string | null>
readonly activeSessionTabTypeRef: RefObject<TTabType | null>
readonly liveInputRef: RefObject<TextInput | null>
readonly liveInputTerminalHandlesRef: RefObject<Set<string>>
readonly sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender>
readonly setLiveInputCapture: (text: string) => void
}
type TerminalLivePendingInputFlush = {
readonly clearPendingLiveInputCommit: () => void
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean>
readonly pendingLiveInputHandleRef: RefObject<string | null>
readonly pendingLiveInputTextRef: RefObject<string>
readonly schedulePendingLiveInputCommit: (
handle: string,
text: string,
delayMs: number | null
) => void
readonly waitForPendingLiveInputFlush: () => Promise<boolean>
}
export function useTerminalLivePendingInputFlush<TTabType extends string>({
activeHandleRef,
activeSessionTabTypeRef,
liveInputRef,
liveInputTerminalHandlesRef,
sendLiveTerminalInputRef,
setLiveInputCapture
}: TerminalLivePendingInputFlushOptions<TTabType>): TerminalLivePendingInputFlush {
const liveInputCommitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pendingLiveInputFlushRef = useRef<Promise<boolean> | null>(null)
const pendingLiveInputTextRef = useRef('')
const pendingLiveInputHandleRef = useRef<string | null>(null)
const clearPendingLiveInputCommit = useCallback(() => {
if (liveInputCommitTimerRef.current) {
clearTimeout(liveInputCommitTimerRef.current)
liveInputCommitTimerRef.current = null
}
pendingLiveInputTextRef.current = ''
pendingLiveInputHandleRef.current = null
setLiveInputCapture('')
liveInputRef.current?.setNativeProps({ text: '' })
}, [liveInputRef, setLiveInputCapture])
const waitForPendingLiveInputFlush = useCallback(async (): Promise<boolean> => {
return waitForTerminalLivePendingFlush(pendingLiveInputFlushRef)
}, [])
const flushPendingLiveInputText = useCallback(
async (expectedHandle: string | null): Promise<boolean> => {
const existingFlush = pendingLiveInputFlushRef.current
if (liveInputCommitTimerRef.current) {
clearTimeout(liveInputCommitTimerRef.current)
liveInputCommitTimerRef.current = null
}
const handle = pendingLiveInputHandleRef.current
const text = pendingLiveInputTextRef.current
pendingLiveInputHandleRef.current = null
pendingLiveInputTextRef.current = ''
setLiveInputCapture('')
liveInputRef.current?.setNativeProps({ text: '' })
if (!handle || text.length === 0) {
return existingFlush ?? false
}
if (
(expectedHandle !== null && handle !== expectedHandle) ||
handle !== activeHandleRef.current ||
activeSessionTabTypeRef.current !== 'terminal' ||
!liveInputTerminalHandlesRef.current.has(handle)
) {
return false
}
return queueTerminalLivePendingFlush(pendingLiveInputFlushRef, () =>
sendLiveTerminalInputRef.current(handle, text)
)
},
[
activeHandleRef,
activeSessionTabTypeRef,
liveInputRef,
liveInputTerminalHandlesRef,
sendLiveTerminalInputRef,
setLiveInputCapture
]
)
const schedulePendingLiveInputCommit = useCallback(
(handle: string, text: string, delayMs: number | null) => {
if (liveInputCommitTimerRef.current) {
clearTimeout(liveInputCommitTimerRef.current)
}
pendingLiveInputHandleRef.current = handle
pendingLiveInputTextRef.current = text
if (delayMs === null) {
liveInputCommitTimerRef.current = null
return
}
liveInputCommitTimerRef.current = setTimeout(() => {
liveInputCommitTimerRef.current = null
void flushPendingLiveInputText(handle)
}, delayMs)
},
[flushPendingLiveInputText]
)
useEffect(() => {
return () => {
if (liveInputCommitTimerRef.current) {
clearTimeout(liveInputCommitTimerRef.current)
liveInputCommitTimerRef.current = null
}
pendingLiveInputHandleRef.current = null
pendingLiveInputTextRef.current = ''
pendingLiveInputFlushRef.current = null
}
}, [])
return {
clearPendingLiveInputCommit,
flushPendingLiveInputText,
pendingLiveInputHandleRef,
pendingLiveInputTextRef,
schedulePendingLiveInputCommit,
waitForPendingLiveInputFlush
}
}
@@ -0,0 +1,35 @@
import { describe, expect, it, vi } from 'vitest'
import { handleTerminalBinaryFrame } from './rpc-client-terminal-binary-frame'
import { encodeTerminalStreamFrame, TerminalStreamOpcode } from './terminal-stream-protocol'
function encodeFrame(opcode: TerminalStreamOpcode, streamId: number, payload: unknown): Uint8Array {
const body =
typeof payload === 'string'
? new TextEncoder().encode(payload)
: new TextEncoder().encode(JSON.stringify(payload))
return encodeTerminalStreamFrame({
opcode,
streamId,
seq: 1,
payload: body
})
}
describe('handleTerminalBinaryFrame', () => {
it('routes terminal metadata frames to the stream listener', () => {
const listener = vi.fn()
const recordValidatedInboundTraffic = vi.fn()
handleTerminalBinaryFrame(
encodeFrame(TerminalStreamOpcode.Metadata, 42, { cwd: '/repo/src' }),
{
terminalSnapshots: new Map(),
getListener: (streamId) => (streamId === 42 ? listener : undefined),
recordValidatedInboundTraffic
}
)
expect(recordValidatedInboundTraffic).toHaveBeenCalledTimes(1)
expect(listener).toHaveBeenCalledWith({ type: 'metadata', streamId: 42, cwd: '/repo/src' })
})
})
@@ -0,0 +1,116 @@
import {
TerminalStreamOpcode,
decodeTerminalStreamFrame,
decodeTerminalStreamJson,
decodeTerminalStreamText
} from './terminal-stream-protocol'
export type TerminalSnapshotState = {
streamId: number
meta: Record<string, unknown>
chunks: string[]
}
type StreamingListener = (result: unknown) => void
type TerminalBinaryFrameOptions = {
terminalSnapshots: Map<number, TerminalSnapshotState>
getListener: (streamId: number) => StreamingListener | undefined
recordValidatedInboundTraffic: () => void
}
export function handleTerminalBinaryFrame(
bytes: Uint8Array,
options: TerminalBinaryFrameOptions
): void {
const frame = decodeTerminalStreamFrame(bytes)
if (!frame) {
return
}
const listener = options.getListener(frame.streamId)
if (!listener) {
options.recordValidatedInboundTraffic()
return
}
if (frame.opcode === TerminalStreamOpcode.Output) {
options.recordValidatedInboundTraffic()
listener({
type: 'data',
streamId: frame.streamId,
chunk: decodeTerminalStreamText(frame.payload)
})
return
}
if (frame.opcode === TerminalStreamOpcode.SnapshotStart) {
const meta = decodeTerminalStreamJson<Record<string, unknown>>(frame.payload)
if (!meta) {
return
}
options.recordValidatedInboundTraffic()
options.terminalSnapshots.set(frame.streamId, {
streamId: frame.streamId,
meta,
chunks: []
})
return
}
if (frame.opcode === TerminalStreamOpcode.SnapshotChunk) {
options.recordValidatedInboundTraffic()
const snapshot = options.terminalSnapshots.get(frame.streamId)
if (!snapshot) {
return
}
snapshot.chunks.push(decodeTerminalStreamText(frame.payload))
return
}
if (frame.opcode === TerminalStreamOpcode.SnapshotEnd) {
options.recordValidatedInboundTraffic()
const snapshot = options.terminalSnapshots.get(frame.streamId)
if (!snapshot) {
return
}
options.terminalSnapshots.delete(frame.streamId)
const kind = snapshot.meta.kind === 'resized' ? 'resized' : 'scrollback'
listener({
...snapshot.meta,
type: kind,
streamId: frame.streamId,
serialized: snapshot.chunks.join('')
})
return
}
if (frame.opcode === TerminalStreamOpcode.Resized) {
const meta = decodeTerminalStreamJson<Record<string, unknown>>(frame.payload)
if (!meta) {
return
}
options.recordValidatedInboundTraffic()
listener({
...meta,
type: 'resized',
streamId: frame.streamId
})
return
}
if (frame.opcode === TerminalStreamOpcode.Metadata) {
const meta = decodeTerminalStreamJson<Record<string, unknown>>(frame.payload)
if (!meta) {
return
}
options.recordValidatedInboundTraffic()
listener({
...meta,
type: 'metadata',
streamId: frame.streamId
})
return
}
if (frame.opcode === TerminalStreamOpcode.Error) {
options.recordValidatedInboundTraffic()
listener({
type: 'error',
streamId: frame.streamId,
message: decodeTerminalStreamText(frame.payload)
})
}
}
@@ -0,0 +1,163 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { connect } from './rpc-client'
import { encodeTerminalStreamFrame, TerminalStreamOpcode } from './terminal-stream-protocol'
vi.mock('./e2ee', () => ({
generateKeyPair: () => ({
publicKey: new Uint8Array(32),
secretKey: new Uint8Array(32)
}),
deriveSharedKey: () => new Uint8Array(32),
publicKeyFromBase64: () => new Uint8Array(32),
publicKeyToBase64: () => 'client-public-key',
encrypt: (plaintext: string) => `encrypted:${plaintext}`,
decrypt: (raw: string) => (raw === 'undecryptable' ? null : raw.replace(/^encrypted:/, '')),
decryptBytes: (bytes: Uint8Array) => bytes
}))
class MockWebSocket {
static CONNECTING = 0
static OPEN = 1
static CLOSED = 3
readonly CONNECTING = MockWebSocket.CONNECTING
readonly OPEN = MockWebSocket.OPEN
readonly CLOSED = MockWebSocket.CLOSED
readyState = MockWebSocket.CONNECTING
onopen: (() => void) | null = null
onclose: (() => void) | null = null
onmessage: ((event: { data: unknown }) => void) | null = null
sent: string[] = []
close = vi.fn(() => {
this.readyState = MockWebSocket.CLOSED
this.onclose?.()
})
constructor(readonly endpoint: string) {
mockSockets.push(this)
}
send(payload: string): void {
this.sent.push(payload)
}
open(): void {
this.readyState = MockWebSocket.OPEN
this.onopen?.()
}
receive(payload: unknown): void {
this.onmessage?.({ data: payload })
}
}
const mockSockets: MockWebSocket[] = []
const originalWebSocket = globalThis.WebSocket
function authenticate(socket: MockWebSocket): void {
socket.open()
socket.receive(JSON.stringify({ type: 'e2ee_ready' }))
socket.receive('encrypted:{"type":"e2ee_authenticated"}')
}
function sentRequest(socket: MockWebSocket, method: string): { id: string } {
for (const payload of socket.sent) {
const decoded = JSON.parse(payload.replace(/^encrypted:/, '')) as {
id: string
method: string
}
if (decoded.method === method) {
return { id: decoded.id }
}
}
throw new Error(`Request not sent: ${method}`)
}
function sentRequests(socket: MockWebSocket, method: string): Array<{ id: string }> {
return socket.sent
.map(
(payload) => JSON.parse(payload.replace(/^encrypted:/, '')) as { id: string; method: string }
)
.filter((request) => request.method === method)
}
function encryptedStreamingReady(id: string, streamId: number): string {
return `encrypted:${JSON.stringify({
id,
ok: true,
streaming: true,
result: { type: 'subscribed', streamId }
})}`
}
function unauthorizedResponsePayload(id: string): string {
return `encrypted:${JSON.stringify({
id,
ok: false,
error: { code: 'unauthorized', message: 'Unauthorized' }
})}`
}
function encodeTerminalOutput(streamId: number, chunk: string): Uint8Array {
return encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Output,
streamId,
seq: 1,
payload: new TextEncoder().encode(chunk)
})
}
describe('rpc-client terminal reconnect streams', () => {
beforeEach(() => {
vi.useFakeTimers()
mockSockets.length = 0
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket
})
afterEach(() => {
globalThis.WebSocket = originalWebSocket
vi.useRealTimers()
})
it('re-subscribes active streams after auth retry without routing stale stream ids', async () => {
const client = connect('ws://desktop.invalid', 'token', 'server-key')
const first = mockSockets[0]!
const terminalEvents: unknown[] = []
authenticate(first)
client.subscribe('terminal.subscribe', { terminal: 'term-1' }, (event) => {
terminalEvents.push(event)
})
const initialSubscribe = sentRequest(first, 'terminal.subscribe')
first.receive(encryptedStreamingReady(initialSubscribe.id, 76))
const request = client.sendRequest('status.get').catch(() => undefined)
await Promise.resolve()
first.receive(unauthorizedResponsePayload(sentRequest(first, 'status.get').id))
await request
await vi.advanceTimersByTimeAsync(500)
const second = mockSockets.at(-1)!
authenticate(second)
expect(sentRequests(second, 'terminal.subscribe')).toHaveLength(1)
const resumedSubscribe = sentRequest(second, 'terminal.subscribe')
second.receive(encryptedStreamingReady(resumedSubscribe.id, 77))
second.receive(encodeTerminalOutput(77, 'after-reconnect'))
second.receive(encodeTerminalOutput(76, 'stale-before-reconnect'))
await Promise.resolve()
await Promise.resolve()
expect(terminalEvents).toContainEqual({
type: 'data',
streamId: 77,
chunk: 'after-reconnect'
})
expect(terminalEvents).not.toContainEqual({
type: 'data',
streamId: 76,
chunk: 'stale-before-reconnect'
})
client.close()
})
})
-51
View File
@@ -906,57 +906,6 @@ describe('mobile rpc-client connection timeout', () => {
client.close()
})
it('re-subscribes active streams after an auth-retry reconnect', async () => {
const client = connect('ws://desktop.invalid', 'token', 'server-key')
const first = mockSockets[0]!
const terminalEvents: unknown[] = []
authenticate(first)
expect(client.getState()).toBe('connected')
// An active terminal subscription on the live connection.
client.subscribe('terminal.subscribe', { terminal: 'term-1' }, (event) => {
terminalEvents.push(event)
})
expect(sentRequests(first, 'terminal.subscribe')).toHaveLength(1)
// Mid-session transient unauthorized — handleAuthRejection retries
// (budget not exhausted) and reconnects rather than latching.
const request = client.sendRequest('status.get').catch(() => undefined)
await Promise.resolve()
const id = sentRequest(first, 'status.get').id
first.receive(unauthorizedResponsePayload(id))
await request
expect(client.getState()).toBe('reconnecting')
// Fresh socket authenticates; the replay loop must re-send the still
// active terminal subscription (issue #5200 frozen-terminal regression).
await vi.advanceTimersByTimeAsync(500)
const second = mockSockets[mockSockets.length - 1]!
expect(second).not.toBe(first)
authenticate(second)
expect(client.getState()).toBe('connected')
expect(sentRequests(second, 'terminal.subscribe')).toHaveLength(1)
const resumedSubscribe = sentRequest(second, 'terminal.subscribe')
second.receive(
`encrypted:${JSON.stringify({
id: resumedSubscribe.id,
ok: true,
streaming: true,
result: { type: 'subscribed', streamId: 77 }
})}`
)
second.receive(encodeTerminalOutput(77, 'after-reconnect'))
await Promise.resolve()
await Promise.resolve()
expect(terminalEvents).toContainEqual({
type: 'data',
streamId: 77,
chunk: 'after-reconnect'
})
client.close()
})
})
it('rejects requests waiting for reconnect after the retry cap', async () => {
+30 -90
View File
@@ -15,11 +15,9 @@ import {
decryptBytes
} from './e2ee'
import {
TerminalStreamOpcode,
decodeTerminalStreamFrame,
decodeTerminalStreamJson,
decodeTerminalStreamText
} from './terminal-stream-protocol'
handleTerminalBinaryFrame,
type TerminalSnapshotState
} from './rpc-client-terminal-binary-frame'
import {
decodeBrowserScreencastFrame,
type BrowserScreencastFrame
@@ -63,12 +61,6 @@ type StreamRequest = {
sent?: boolean
}
type TerminalSnapshotState = {
streamId: number
meta: Record<string, unknown>
chunks: string[]
}
export type RpcClient = {
sendRequest: (
method: string,
@@ -490,6 +482,7 @@ export function connect(
pendingBrowserScreencastRequestId = id
activeBrowserScreencastRequestId = null
}
resetTerminalStreamRoutingForRequest(id)
if (
sendEncrypted({ id, deviceToken, method: stream.method, params: stream.params })
) {
@@ -708,7 +701,7 @@ export function connect(
sharedKey = null
activeBrowserScreencastRequestId = null
pendingBrowserScreencastRequestId = null
streamListeners.forEach((stream) => (stream.sent = false))
markStreamsForReplay()
if (handshakeTimer) {
clearTimeout(handshakeTimer)
handshakeTimer = null
@@ -759,7 +752,7 @@ export function connect(
ws = null
sharedKey = null
// Why: close cleanup stale-bails here, so mark active streams for replay.
streamListeners.forEach((stream) => (stream.sent = false))
markStreamsForReplay()
rejectAllPending(reason)
if (closing) {
closing.close()
@@ -900,6 +893,25 @@ export function connect(
}
}
function markStreamsForReplay(): void {
for (const [id, stream] of streamListeners) {
stream.sent = false
resetTerminalStreamRoutingForRequest(id)
}
}
function resetTerminalStreamRoutingForRequest(id: string): void {
const terminalStreamIds = terminalStreamIdsByRequest.get(id)
if (!terminalStreamIds) {
return
}
for (const streamId of terminalStreamIds) {
terminalStreamListeners.delete(streamId)
terminalSnapshots.delete(streamId)
}
terminalStreamIdsByRequest.delete(id)
}
function emitStreamError(stream: StreamRequest, message: string, error?: unknown): void {
if (stream.cancelled) {
return
@@ -942,7 +954,11 @@ export function connect(
handleBrowserBinaryFrame(browserFrame)
return
}
handleTerminalBinaryFrame(bytes)
handleTerminalBinaryFrame(bytes, {
terminalSnapshots,
getListener: (streamId) => terminalStreamListeners.get(streamId),
recordValidatedInboundTraffic
})
}
function handleBrowserBinaryFrame(frame: BrowserScreencastFrame) {
@@ -956,82 +972,6 @@ export function connect(
stream.onBinaryFrame?.(frame)
}
function handleTerminalBinaryFrame(bytes: Uint8Array): void {
const frame = decodeTerminalStreamFrame(bytes)
if (!frame) {
return
}
const listener = terminalStreamListeners.get(frame.streamId)
if (!listener) {
recordValidatedInboundTraffic()
return
}
if (frame.opcode === TerminalStreamOpcode.Output) {
recordValidatedInboundTraffic()
listener({
type: 'data',
streamId: frame.streamId,
chunk: decodeTerminalStreamText(frame.payload)
})
return
}
if (frame.opcode === TerminalStreamOpcode.SnapshotStart) {
const meta = decodeTerminalStreamJson<Record<string, unknown>>(frame.payload)
if (!meta) {
return
}
recordValidatedInboundTraffic()
terminalSnapshots.set(frame.streamId, { streamId: frame.streamId, meta, chunks: [] })
return
}
if (frame.opcode === TerminalStreamOpcode.SnapshotChunk) {
recordValidatedInboundTraffic()
const snapshot = terminalSnapshots.get(frame.streamId)
if (!snapshot) {
return
}
snapshot.chunks.push(decodeTerminalStreamText(frame.payload))
return
}
if (frame.opcode === TerminalStreamOpcode.SnapshotEnd) {
recordValidatedInboundTraffic()
const snapshot = terminalSnapshots.get(frame.streamId)
if (!snapshot) {
return
}
terminalSnapshots.delete(frame.streamId)
const kind = snapshot.meta.kind === 'resized' ? 'resized' : 'scrollback'
listener({
...snapshot.meta,
type: kind,
streamId: frame.streamId,
serialized: snapshot.chunks.join('')
})
return
}
if (frame.opcode === TerminalStreamOpcode.Resized) {
const meta = decodeTerminalStreamJson<Record<string, unknown>>(frame.payload)
if (!meta) {
return
}
recordValidatedInboundTraffic()
listener({
...meta,
type: 'resized',
streamId: frame.streamId
})
return
}
if (frame.opcode === TerminalStreamOpcode.Error) {
recordValidatedInboundTraffic()
listener({
type: 'error',
streamId: frame.streamId,
message: decodeTerminalStreamText(frame.payload)
})
}
}
function sendEncrypted(request: unknown): boolean {
if (ws && ws.readyState === WebSocket.OPEN && sharedKey) {
ws.send(encrypt(JSON.stringify(request), sharedKey))
@@ -8,7 +8,8 @@ export enum TerminalStreamOpcode {
SnapshotChunk = 3,
SnapshotEnd = 4,
Resized = 5,
Error = 6
Error = 6,
Metadata = 12
}
export type TerminalStreamFrame = {
@@ -74,6 +75,7 @@ function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode {
value === TerminalStreamOpcode.SnapshotChunk ||
value === TerminalStreamOpcode.SnapshotEnd ||
value === TerminalStreamOpcode.Resized ||
value === TerminalStreamOpcode.Error
value === TerminalStreamOpcode.Error ||
value === TerminalStreamOpcode.Metadata
)
}
+58
View File
@@ -4,6 +4,64 @@ Goal: (1) significantly improve Windows startup time (~1 min cold start reported
(2) fix OpenCode-driven UI freezes, (3) improve overall Windows performance.
All changes must be proven with before/after benchmark numbers.
## Phase 2 (2026-07-02, branch Jinwoo-H/windows-performance-improvement) — terminal interaction latency
Complaints: slow workspace switching, slow tab create/switch (terminal-related), occasional crashes.
Harness: `tools/benchmarks/terminal-perf-bench.mjs` (CDP-driven dev app, renderer-clock phase
timings; scenarios tab-create / tab-switch / workspace-switch; local git fixture).
Main-process spawn attribution: `ORCA_PTY_SPAWN_TIMING=1` → `[pty-spawn-timing]` lines
(pty.ts handler phases: preflight/auth/host_env/options/provider_spawn).
Findings (baseline, this machine):
- Workspace switch: every hide disposed each pane's WebGL context; resume recreated it —
~5ms macOS, 100-500ms/pane Windows ANGLE (the comment in terminal-visibility-resume.ts
admitted this). Premise (16-context budget) stale since #7064 raised budget to 128.
- Tab create: ~550ms steady state; main handler only ~115ms (host_env≈50ms, daemon
provider_spawn≈68ms). Remainder is renderer-side (xterm open + WebGL context for the new
pane + React mount). First-ever spawn paid +2.7s inside provider_spawn = daemon's first
ConPTY (native module + conpty.dll + OpenConsole + Defender), lazily on the user's first terminal.
- Tab switch: paint settle median 80-99ms; longtasks 64-151ms — every light tab resume runs
scheduleTerminalWebglAtlasRecovery: 3× (frame/120ms/500ms) global shared-atlas clear +
refresh of EVERY pane in EVERY manager. Parse-time recovery (pty-connection.ts
recoverWebglAtlasAfterParse / hiddenOutputNeedsAtlasRecoveryAfterParse) already covers
risky output including hidden. CAUTION: #7058 changed this area and was reverted (#7073) —
left as follow-up.
- LocalPtyProvider spawned without useConptyDll while the daemon path used it (legacy system
ConPTY corruption + perf differences on degraded-mode/fresh-local spawns).
Fixes on this branch (PR #7080 merged in — WebGL release on dispose + stale pty:exit synthesis):
- A/D: WebGL context retention across hide/show and the suspended-pane atlas recovery scoping
were reverted/parked for more terminal lifecycle testing. Hidden workspaces return to the
previous dispose-on-hide behavior.
- B: useConptyDll for LocalPtyProvider spawns (local-pty-utils.ts) — parity with daemon.
- F: daemon boots a throwaway `cmd.exe /c exit` ConPTY (windows-conpty-warmup.ts) so the
first user terminal doesn't pay the ~2.7s first-ConPTY cost.
Follow-ups (documented, not in this branch):
- Gate/scope the light-tab-switch atlas burst (see #7058/#7073 history first). Residual
tab-switch cost besides the burst: debounced ResizeObserver re-fit can reflow scrollback
when column count changed while hidden.
- Renderer-side tab-create cost (~400ms): mount chain runs new Terminal() + 5 eager addons
+ synchronous attachWebgl (pane-lifecycle.ts:108) before the spawn IPC (deferred one rAF,
pty-connection.ts:5158). Candidate: defer WebGL attach for brand-new panes.
- Cold-restore respawn fan-out: reconnectPersistedTerminals does NOT spawn; the fan-out is
Terminal.tsx mounting a TerminalPane per restored tab at once — each fires connectPanePty
→ rAF-deferred spawn IPC (pty-connection.ts:5158) with no concurrency cap. Cap belongs at
that renderer connect layer, not in reconnectPersistedTerminals.
- First terminal opened immediately after launch also waits on the one-time daemon-init
barrier (pty:spawn awaits getLocalPtyStartupPromise, ipc/pty.ts:2518; measured
preflight=0 in the bench because hydration had finished first, but an early Ctrl+T pays
it). In-daemon Windows shell resolution (pwsh -Version probe, PowerShell exe-chain
existsSync/statSync scan) is uncached per spawn; the conpty warm-up spawns cmd.exe so it
does not warm PowerShell resolution. Note the warm-up and an early first spawn serialize
on the daemon's single thread — the 1255ms post-fix first-spawn number is mostly queueing
behind the in-flight warm-up, not unwarmed cost.
- node-pty ≥1.2.0-beta defers conpty connect (spawn returns pid=0 fast) — would stop spawn
storms serializing the daemon loop.
Pre-existing Windows-only test failures (also on main, CI is ubuntu-only): 5 attribution-shim
PATH assertions in src/main/ipc/pty.test.ts (path-separator artifacts).
## Status
- [x] Benchmark harness for startup time (`tools/benchmarks/startup-time-bench.mjs`)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.4.117-rc.3",
"version": "1.4.121-rc.1",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
+18
View File
@@ -73,6 +73,24 @@ describe('orca emulator CLI handlers', () => {
})
})
it('uses a wider client timeout for emulator attach recovery', async () => {
queueFixtures(
callMock,
okFixture('req_attach', {
attached: true,
info: { deviceUdid: 'device-1', streamUrl: 'http://127.0.0.1:3102/stream.mjpeg' }
})
)
await main(['emulator', 'attach', 'device-1', '--worktree', 'all'], '/repo/project')
expect(callMock).toHaveBeenCalledWith(
'emulator.attach',
{ device: 'device-1', worktree: undefined, focus: false },
{ timeoutMs: 180_000 }
)
})
it('rejects relative APK paths for remote runtimes', async () => {
remoteMock.mockReturnValue(true)
+7 -1
View File
@@ -128,7 +128,13 @@ export const EMULATOR_HANDLERS: Record<string, CommandHandler> = {
const target = await getEmulatorCommandTarget(flags, cwd, client)
const device = getOptionalStringFlag(flags, 'device')
const focus = flags.get('focus') === true
const res = await client.call('emulator.attach', { device, worktree: target.worktree, focus })
// Why: attach may cold-boot or recycle a wedged simulator (shutdown + boot +
// helper restart), which can legitimately exceed the 60s default budget.
const res = await client.call(
'emulator.attach',
{ device, worktree: target.worktree, focus },
{ timeoutMs: 180_000 }
)
printResult(res, json, (r: unknown) => {
const result = r as EmulatorAttachResult
const info = result.info ?? result
+50 -11
View File
@@ -21,6 +21,8 @@ import {
hookDefinitionHasManagedCommand,
removeManagedCommands,
wrapPosixHookCommand,
wrapWindowsCmdHookCommand,
wrapWindowsGitBashHookCommand,
wrapWindowsHookCommand,
writeManagedScript,
writeHooksJson,
@@ -339,19 +341,19 @@ describe('wrapPosixHookCommand', () => {
)
})
const qualifiedWindowsPowerShellCommand =
/^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
function decodeWindowsHookCommand(command: string): string {
const encodedCommand = command.match(/ -EncodedCommand (\S+)$/)?.[1]
expect(encodedCommand).toBeTruthy()
return Buffer.from(encodedCommand!, 'base64').toString('utf16le')
}
describe('wrapWindowsHookCommand', () => {
const qualifiedPowerShellCommand =
/^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
function decodeWindowsHookCommand(command: string): string {
const encodedCommand = command.match(/ -EncodedCommand (\S+)$/)?.[1]
expect(encodedCommand).toBeTruthy()
return Buffer.from(encodedCommand!, 'base64').toString('utf16le')
}
it('invokes the .cmd through an encoded PowerShell command', () => {
const command = wrapWindowsHookCommand('C:\\Users\\alice\\.orca\\agent-hooks\\codex-hook.cmd')
expect(command).toMatch(qualifiedPowerShellCommand)
expect(command).toMatch(qualifiedWindowsPowerShellCommand)
expect(command).not.toMatch(/^powershell\b/i)
expect(decodeWindowsHookCommand(command)).toBe(
"& 'C:\\Users\\alice\\.orca\\agent-hooks\\codex-hook.cmd'; exit $LASTEXITCODE"
@@ -363,7 +365,7 @@ describe('wrapWindowsHookCommand', () => {
// the whole path inside the encoded command so shells do not split it.
it('preserves spaces in the script path (user profile with space case)', () => {
const cmd = wrapWindowsHookCommand('C:\\Users\\Jorge Silva\\.orca\\agent-hooks\\codex-hook.cmd')
expect(cmd).toMatch(qualifiedPowerShellCommand)
expect(cmd).toMatch(qualifiedWindowsPowerShellCommand)
expect(decodeWindowsHookCommand(cmd)).toBe(
"& 'C:\\Users\\Jorge Silva\\.orca\\agent-hooks\\codex-hook.cmd'; exit $LASTEXITCODE"
)
@@ -395,6 +397,43 @@ describe('wrapWindowsHookCommand', () => {
)
})
describe('wrapWindowsCmdHookCommand', () => {
it('returns a bare .cmd path when cmd.exe can invoke it safely', () => {
const scriptPath = 'C:\\Users\\alice\\.orca\\agent-hooks\\codex-hook.cmd'
expect(wrapWindowsCmdHookCommand(scriptPath)).toBe(scriptPath)
})
it('falls back to the encoded launcher when cmd.exe would split or expand the path', () => {
const scriptPath = 'C:\\Users\\Jane Doe\\%ORCA_TEST%\\codex-hook.cmd'
const command = wrapWindowsCmdHookCommand(scriptPath)
expect(command).toMatch(qualifiedWindowsPowerShellCommand)
expect(decodeWindowsHookCommand(command)).toBe(`& '${scriptPath}'; exit $LASTEXITCODE`)
})
})
describe('wrapWindowsGitBashHookCommand', () => {
it('returns a forward-slash .cmd path when Git Bash can execute it safely', () => {
expect(
wrapWindowsGitBashHookCommand('C:\\Users\\alice\\.orca\\agent-hooks\\claude-hook.cmd')
).toBe('C:/Users/alice/.orca/agent-hooks/claude-hook.cmd')
})
it('falls back to the encoded launcher when bash would split the path', () => {
const scriptPath = 'C:\\Users\\Jane Doe\\.orca\\agent-hooks\\claude-hook.cmd'
const command = wrapWindowsGitBashHookCommand(scriptPath)
expect(command).toMatch(qualifiedWindowsPowerShellCommand)
expect(decodeWindowsHookCommand(command)).toBe(`& '${scriptPath}'; exit $LASTEXITCODE`)
})
it('falls back to the encoded launcher when bash metacharacters are present', () => {
const scriptPath = 'C:\\Users\\alice & bob\\.orca\\agent-hooks\\claude-hook.cmd'
const command = wrapWindowsGitBashHookCommand(scriptPath)
expect(command).toMatch(qualifiedWindowsPowerShellCommand)
expect(command).not.toContain('& bob')
expect(decodeWindowsHookCommand(command)).toBe(`& '${scriptPath}'; exit $LASTEXITCODE`)
})
})
describe('buildWindowsAgentHookPostCommand', () => {
it('posts hook stdin through bounded curl without spawning PowerShell', () => {
const command = buildWindowsAgentHookPostCommand('codex')
+18
View File
@@ -163,6 +163,24 @@ export function wrapWindowsHookCommand(scriptPath: string): string {
return `${getWindowsPowerShellExecutablePath()} -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${encodedCommand}`
}
export const WINDOWS_CMD_SAFE_PATH = /^[A-Za-z0-9_.:\\~-]+$/
export function wrapWindowsCmdHookCommand(scriptPath: string): string {
// Why: skip the ~360ms PowerShell interpreter startup when the path is safe
// for cmd.exe to invoke directly. The fallback keeps the encoded PowerShell
// launcher for paths with spaces or metacharacters.
return WINDOWS_CMD_SAFE_PATH.test(scriptPath) ? scriptPath : wrapWindowsHookCommand(scriptPath)
}
export const WINDOWS_GIT_BASH_SAFE_PATH = /^[A-Za-z0-9_.:/~-]+$/
export function wrapWindowsGitBashHookCommand(scriptPath: string): string {
const bashPath = scriptPath.replaceAll('\\', '/')
// Why: Claude Code's Windows hook runner is Git Bash/MSYS. It can execute a
// bare .cmd via forward slashes, but shell metacharacters must stay encoded.
return WINDOWS_GIT_BASH_SAFE_PATH.test(bashPath) ? bashPath : wrapWindowsHookCommand(scriptPath)
}
export function buildWindowsAgentHookPostCommand(source: AgentHookSource): string {
// Why: Codex runs these hooks inline on every turn. PowerShell startup alone
// makes trusted Windows hooks visibly slow, so mirror the POSIX curl path.
+183 -1
View File
@@ -21,7 +21,9 @@ function createMockWebContents() {
detach: vi.fn(() => {
debuggerAttached = false
}),
sendCommand: vi.fn(async () => ({})),
sendCommand: vi.fn(
async (_method?: string, _params?: Record<string, unknown>, _sessionId?: string) => ({})
),
on: vi.fn((event: string, handler: DebuggerListener) => {
const arr = listeners.get(event) ?? []
arr.push(handler)
@@ -41,6 +43,8 @@ function createMockWebContents() {
debugger: debuggerObj,
isDestroyed: () => destroyed,
focus: vi.fn(),
reload: vi.fn(),
reloadIgnoringCache: vi.fn(),
getTitle: vi.fn(() => 'Example'),
getURL: vi.fn(() => 'https://example.com')
},
@@ -89,6 +93,22 @@ describe('CdpWsProxy', () => {
})
}
type SendCommandCall = [string, Record<string, unknown>?, string?]
function getSendCommandCalls(): SendCommandCall[] {
const calls = mock.webContents.debugger.sendCommand.mock.calls as unknown as [
string,
Record<string, unknown>?,
string?
][]
return calls
}
function getSendCommandMethods(): string[] {
const calls = getSendCommandCalls()
return calls.map((call) => call[0])
}
it('starts on a random port and returns ws:// URL', () => {
expect(endpoint).toMatch(/^ws:\/\/127\.0\.0\.1:\d+$/)
expect(proxy.getPort()).toBeGreaterThan(0)
@@ -311,6 +331,168 @@ describe('CdpWsProxy', () => {
client.close()
})
it('primes lifecycle events for Page.navigate', async () => {
const client = await connect()
const response = await sendAndReceive(client, {
id: 11,
method: 'Page.navigate',
params: { url: 'https://example.com/next' }
})
expect(response.id).toBe(11)
expect(response.result).toEqual({})
expect(getSendCommandMethods()).toEqual([
'Page.enable',
'Page.addScriptToEvaluateOnNewDocument',
'Network.enable',
'Page.enable',
'Page.setLifecycleEventsEnabled',
'Page.navigate'
])
client.close()
})
it('primes lifecycle events for Page.reload and preserves response id', async () => {
const client = await connect()
const response = await sendAndReceive(client, {
id: 12,
method: 'Page.reload'
})
expect(response.id).toBe(12)
expect(response.result).toEqual({})
expect(getSendCommandMethods()).toEqual([
'Page.enable',
'Page.addScriptToEvaluateOnNewDocument',
'Network.enable',
'Page.enable',
'Page.setLifecycleEventsEnabled'
])
expect(mock.webContents.reload).toHaveBeenCalledTimes(1)
expect(getSendCommandMethods()).not.toContain('Page.reload')
client.close()
})
it('preserves explicit Page.navigate session during lifecycle priming', async () => {
const client = await connect()
await sendAndReceive(client, {
id: 14,
method: 'Page.navigate',
params: { url: 'https://example.com/frame' },
sessionId: 'iframe-session-123'
})
expect(getSendCommandCalls().slice(2)).toEqual([
['Network.enable', {}, 'iframe-session-123'],
['Page.enable', {}, 'iframe-session-123'],
['Page.setLifecycleEventsEnabled', { enabled: true }, 'iframe-session-123'],
['Page.navigate', { url: 'https://example.com/frame' }, 'iframe-session-123']
])
client.close()
})
it('forwards explicit Page.reload session after lifecycle priming', async () => {
const client = await connect()
await sendAndReceive(client, {
id: 15,
method: 'Page.reload',
params: { ignoreCache: true },
sessionId: 'iframe-session-123'
})
expect(getSendCommandCalls().slice(2)).toEqual([
['Network.enable', {}, 'iframe-session-123'],
['Page.enable', {}, 'iframe-session-123'],
['Page.setLifecycleEventsEnabled', { enabled: true }, 'iframe-session-123'],
['Page.reload', { ignoreCache: true }, 'iframe-session-123']
])
expect(mock.webContents.reloadIgnoringCache).not.toHaveBeenCalled()
expect(mock.webContents.reload).not.toHaveBeenCalled()
client.close()
})
it('rejects root Page.reload params that direct webContents reload cannot honor', async () => {
const client = await connect()
const response = await sendAndReceive(client, {
id: 16,
method: 'Page.reload',
params: { loaderId: 'stale-loader' }
})
expect(response).toEqual({
id: 16,
error: {
code: -32000,
message: 'Page.reload parameter "loaderId" is not supported for Orca tab reloads'
}
})
expect(mock.webContents.reload).not.toHaveBeenCalled()
expect(mock.webContents.reloadIgnoringCache).not.toHaveBeenCalled()
expect(getSendCommandMethods()).not.toContain('Network.enable')
client.close()
})
it('still reloads when lifecycle priming stalls', async () => {
const client = await connect()
mock.webContents.debugger.sendCommand.mockImplementation((method?: string) => {
if (method === 'Network.enable') {
return new Promise(() => {})
}
return Promise.resolve({})
})
const responsePromise = sendAndReceive(client, {
id: 17,
method: 'Page.reload'
})
await expect(responsePromise).resolves.toEqual({ id: 17, result: {} })
expect(mock.webContents.reload).toHaveBeenCalledTimes(1)
client.close()
})
it('does not reload after the requesting client disconnects during priming', async () => {
const client = await connect()
mock.webContents.debugger.sendCommand.mockImplementation((method?: string) => {
if (method === 'Network.enable') {
return new Promise(() => {})
}
return Promise.resolve({})
})
client.send(JSON.stringify({ id: 18, method: 'Page.reload' }))
client.close()
await new Promise((resolve) => setTimeout(resolve, 1_100))
expect(mock.webContents.reload).not.toHaveBeenCalled()
expect(mock.webContents.reloadIgnoringCache).not.toHaveBeenCalled()
})
it('forwards Runtime.evaluate without lifecycle priming', async () => {
const client = await connect()
const response = await sendAndReceive(client, {
id: 13,
method: 'Runtime.evaluate',
params: { expression: 'document.readyState' }
})
expect(response.id).toBe(13)
expect(response.result).toEqual({})
expect(getSendCommandMethods()).toEqual([
'Page.enable',
'Page.addScriptToEvaluateOnNewDocument',
'Runtime.evaluate'
])
client.close()
})
// ── Page.frameNavigated interception ──
// ── Cleanup ──
+104 -13
View File
@@ -6,6 +6,8 @@ import { captureScreenshot } from './cdp-screenshot'
import { ANTI_DETECTION_SCRIPT } from './anti-detection'
import { acquireElectronDebugger, type ElectronDebuggerLease } from './electron-debugger-lease'
const LIFECYCLE_PRIMING_TIMEOUT_MS = 1_000
export class CdpWsProxy {
private httpServer: Server | null = null
private wss: WebSocketServer | null = null
@@ -298,13 +300,39 @@ export class CdpWsProxy {
}
// Why: agent-browser waits for network idle to detect navigation completion.
// Electron webview CDP subscriptions silently lapse after cross-process swaps.
// Page.reload needs the same priming: forwarding it unprimed closed the tab (#7031).
if (msg.method === 'Page.navigate' && !this.webContents.isDestroyed()) {
void this.navigateWithLifecycleEnsured(client, clientId, msg.params ?? {})
void this.navigateWithLifecycle(client, clientId, msg.params ?? {}, msg.sessionId)
return
}
// Why: CDP Page.reload can destroy Electron webview targets during process swaps.
// Use the same direct webContents reload path as Orca's own browser.reload.
if (msg.method === 'Page.reload' && !this.webContents.isDestroyed()) {
void this.reloadWithLifecycle(client, clientId, msg.params ?? {}, msg.sessionId)
return
}
this.forwardCommand(client, clientId, msg.method, msg.params ?? {}, msg.sessionId)
}
private resolveDebuggerSessionId(msgSessionId?: string): string | undefined {
return msgSessionId && msgSessionId !== this.clientSessionId ? msgSessionId : undefined
}
private isActiveClient(client: WebSocket): boolean {
return this.client === client && client.readyState === WebSocket.OPEN
}
private sendDebuggerCommand(
method: string,
params: Record<string, unknown>,
sessionId?: string
): Promise<unknown> {
const command = sessionId
? this.webContents.debugger.sendCommand(method, params, sessionId)
: this.webContents.debugger.sendCommand(method, params)
return Promise.resolve(command)
}
private forwardCommand(
client: WebSocket,
clientId: number,
@@ -316,10 +344,9 @@ export class CdpWsProxy {
this.sendError(clientId, 'Browser tab is no longer available', client)
return
}
const sessionId =
msgSessionId && msgSessionId !== this.clientSessionId ? msgSessionId : undefined
const sessionId = this.resolveDebuggerSessionId(msgSessionId)
try {
Promise.resolve(this.webContents.debugger.sendCommand(method, params, sessionId))
this.sendDebuggerCommand(method, params, sessionId)
.then((result) => {
this.sendResult(clientId, result, client)
})
@@ -331,21 +358,85 @@ export class CdpWsProxy {
}
}
private async navigateWithLifecycleEnsured(
private async navigateWithLifecycle(
client: WebSocket,
clientId: number,
params: Record<string, unknown>
params: Record<string, unknown>,
msgSessionId?: string
): Promise<void> {
await this.primePageLifecycle(this.resolveDebuggerSessionId(msgSessionId))
if (!this.isActiveClient(client)) {
return
}
this.forwardCommand(client, clientId, 'Page.navigate', params, msgSessionId)
}
private async reloadWithLifecycle(
client: WebSocket,
clientId: number,
params: Record<string, unknown>,
msgSessionId?: string
): Promise<void> {
const sessionId = this.resolveDebuggerSessionId(msgSessionId)
const unsupportedParam = sessionId ? null : this.getUnsupportedRootReloadParam(params)
if (unsupportedParam) {
this.sendError(
clientId,
`Page.reload parameter "${unsupportedParam}" is not supported for Orca tab reloads`,
client
)
return
}
await this.primePageLifecycle(sessionId)
if (!this.isActiveClient(client)) {
return
}
if (sessionId) {
this.forwardCommand(client, clientId, 'Page.reload', params, msgSessionId)
return
}
if (this.webContents.isDestroyed()) {
this.sendError(clientId, 'Browser tab is no longer available', client)
return
}
try {
const dbg = this.webContents.debugger
if (params.ignoreCache === true) {
this.webContents.reloadIgnoringCache()
} else {
this.webContents.reload()
}
this.sendResult(clientId, {}, client)
} catch (err) {
this.sendError(clientId, err instanceof Error ? err.message : String(err), client)
}
}
private getUnsupportedRootReloadParam(params: Record<string, unknown>): string | null {
return Object.keys(params).find((key) => key !== 'ignoreCache') ?? null
}
private async primePageLifecycle(sessionId?: string): Promise<void> {
let timeout: ReturnType<typeof setTimeout> | null = null
const priming = (async (): Promise<void> => {
// Why: without Network.enable, agent-browser never sees network idle → goto times out.
await dbg.sendCommand('Network.enable', {})
await dbg.sendCommand('Page.enable', {})
await dbg.sendCommand('Page.setLifecycleEventsEnabled', { enabled: true })
} catch {
/* best-effort */
await this.sendDebuggerCommand('Network.enable', {}, sessionId)
await this.sendDebuggerCommand('Page.enable', {}, sessionId)
await this.sendDebuggerCommand('Page.setLifecycleEventsEnabled', { enabled: true }, sessionId)
})().catch(() => {})
try {
await Promise.race([
priming,
new Promise<void>((resolve) => {
timeout = setTimeout(resolve, LIFECYCLE_PRIMING_TIMEOUT_MS)
timeout.unref?.()
})
])
} finally {
if (timeout) {
clearTimeout(timeout)
}
}
this.forwardCommand(client, clientId, 'Page.navigate', params)
}
private handleScreenshot(
+7 -16
View File
@@ -15,6 +15,7 @@ vi.mock('electron', () => ({
}))
import type { SFTPWrapper } from 'ssh2'
import { createManagedCommandMatcher } from '../agent-hooks/installer-utils'
import { ClaudeHookService } from './hook-service'
import { OPENCLAUDE_HOOK_SETTINGS } from './hook-settings'
@@ -23,6 +24,8 @@ const OPENCLAUDE_SCRIPT_FILE_NAME =
process.platform === 'win32' ? 'openclaude-hook.cmd' : 'openclaude-hook.sh'
const WINDOWS_POWERSHELL_LAUNCHER =
/^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
const isClaudeManagedCommand = createManagedCommandMatcher(CLAUDE_SCRIPT_FILE_NAME)
const isOpenClaudeManagedCommand = createManagedCommandMatcher(OPENCLAUDE_SCRIPT_FILE_NAME)
type FakeFs = {
files: Map<string, string>
@@ -162,23 +165,13 @@ describe('ClaudeHookService.install', () => {
definition.hooks.map((hook) => hook.command)
)
expect(legacyCommands).toContain('/usr/local/bin/user-hook')
expect(
legacyCommands.some((command: string) =>
process.platform === 'win32'
? WINDOWS_POWERSHELL_LAUNCHER.test(command)
: command.includes(CLAUDE_SCRIPT_FILE_NAME)
)
).toBe(true)
expect(legacyCommands.some((command: string) => isClaudeManagedCommand(command))).toBe(true)
expect(
legacyCommands.some((command: string) =>
command.includes('/Users/old/.orca/agent-hooks/claude-hook.sh')
)
).toBe(false)
expect(legacy.hooks.StopFailure[0].hooks[0].command).toMatch(
process.platform === 'win32'
? WINDOWS_POWERSHELL_LAUNCHER
: new RegExp(CLAUDE_SCRIPT_FILE_NAME)
)
expect(isClaudeManagedCommand(legacy.hooks.StopFailure[0].hooks[0].command)).toBe(true)
expect(
readFileSync(join(tmpHome, '.orca', 'agent-hooks', CLAUDE_SCRIPT_FILE_NAME), 'utf-8')
).toContain('DEVIN_PROJECT_DIR')
@@ -351,10 +344,8 @@ describe('OpenClaudeHookService-compatible install', () => {
const parsed = JSON.parse(readFileSync(openClaudeSettings, 'utf-8'))
for (const event of ['UserPromptSubmit', 'Stop', 'StopFailure']) {
const command = parsed.hooks[event][0].hooks[0].command as string
if (process.platform === 'win32') {
expect(command).toMatch(WINDOWS_POWERSHELL_LAUNCHER)
} else {
expect(command).toContain(OPENCLAUDE_SCRIPT_FILE_NAME)
expect(isOpenClaudeManagedCommand(command)).toBe(true)
if (process.platform !== 'win32') {
expect(command).toMatch(/^if \[ -x /)
}
}
+4 -9
View File
@@ -6,7 +6,7 @@ import {
getSharedManagedScriptPath,
removeManagedCommands,
wrapPosixHookCommand,
wrapWindowsHookCommand,
wrapWindowsGitBashHookCommand,
type HookDefinition,
type HooksConfig
} from '../agent-hooks/installer-utils'
@@ -75,14 +75,9 @@ export function getRemoteConfigPath(remoteHome: string, settings = CLAUDE_HOOK_S
}
export function getManagedCommand(scriptPath: string): string {
if (process.platform === 'win32') {
// Why: Claude Code runs hooks through Git Bash on Windows. Forward slashes
// alone don't survive a path with spaces — bash splits at the space and
// tries to execute `C:/Users/Jorge` as a command. Wrapping in
// `cmd.exe /d /c call "..."` keeps the path as one argument. #6078.
return wrapWindowsHookCommand(scriptPath)
}
return wrapPosixHookCommand(scriptPath)
return process.platform === 'win32'
? wrapWindowsGitBashHookCommand(scriptPath)
: wrapPosixHookCommand(scriptPath)
}
export function getRemoteManagedCommand(scriptPath: string): string {
+52 -2
View File
@@ -277,6 +277,49 @@ describe('CodexAccountService config sync', () => {
)
})
it('rewrites relative path config values when syncing into managed homes', async () => {
const canonicalConfigPath = join(testState.fakeHomeDir, '.codex', 'config.toml')
writeFileSync(
canonicalConfigPath,
'model_instructions_file = "instructions.md"\nsandbox_mode = "danger-full-access"\n',
'utf-8'
)
const managedHomePath = createManagedHome(
testState.userDataDir,
'account-1',
'approval_policy = "on-request"\n',
'{"account":"managed"}\n'
)
const settings = createSettings({
codexManagedAccounts: [
{
id: 'account-1',
email: 'user@example.com',
managedHomePath,
providerAccountId: null,
workspaceLabel: null,
workspaceAccountId: null,
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
}
],
activeCodexManagedAccountId: 'account-1'
})
const store = createStore(settings)
const rateLimits = createRateLimits()
const runtimeHome = createRuntimeHome()
const { CodexAccountService } = await import('./service')
new CodexAccountService(store as never, rateLimits as never, runtimeHome as never)
const managedConfig = readFileSync(join(managedHomePath, 'config.toml'), 'utf-8')
expect(managedConfig).toContain(
`model_instructions_file = '${join(testState.fakeHomeDir, '.codex', 'instructions.md')}'`
)
expect(managedConfig).toContain('sandbox_mode = "danger-full-access"')
})
it('does not rewrite managed configs that already match canonical config', async () => {
const canonicalConfigPath = join(testState.fakeHomeDir, '.codex', 'config.toml')
const canonicalConfig = 'approval_policy = "never"\nsandbox_mode = "danger-full-access"\n'
@@ -706,7 +749,11 @@ describe('CodexAccountService config sync', () => {
const wslManagedHomePath = join(testState.userDataDir, 'wsl-managed-home')
const wslConfigPath = join(testState.userDataDir, 'wsl-config.toml')
const wslLinuxHomePath = '/home/alice/.local/share/orca/codex-accounts/account-id-for-test/home'
writeFileSync(wslConfigPath, 'sandbox_mode = "danger-full-access"\n', 'utf-8')
writeFileSync(
wslConfigPath,
'sandbox_mode = "danger-full-access"\nmodel_instructions_file = "instructions.md"\n',
'utf-8'
)
const execFileSyncMock = vi.fn((_command: string, args: string[]) => {
const script = decodeEncodedWslBashCommand(String(args.at(-1)))
@@ -731,8 +778,11 @@ describe('CodexAccountService config sync', () => {
'-ic',
`export CODEX_HOME='${wslLinuxHomePath}'; exec codex login`
])
// Why: codex login runs inside WSL, so the rewritten path must be the
// Linux-side ~/.codex, not a Windows UNC path.
expect(readFileSync(join(wslManagedHomePath, 'config.toml'), 'utf-8')).toBe(
'sandbox_mode = "danger-full-access"\n'
'sandbox_mode = "danger-full-access"\n' +
"model_instructions_file = '/home/alice/.codex/instructions.md'\n"
)
const child = new EventEmitter() as EventEmitter & {
stdout: PassThrough
+23 -7
View File
@@ -15,6 +15,7 @@ import type {
} from '../../shared/types'
import type { CodexRuntimeHomeService } from './runtime-home-service'
import { writeFileAtomically } from './fs-utils'
import { rewriteRelativePathConfigValues } from '../codex/codex-config-path-reference-rewrite'
import { resolveCodexCommand } from '../codex-cli/command'
import type { Store } from '../persistence'
import type { RateLimitService } from '../rate-limits/service'
@@ -47,6 +48,13 @@ type ResolvedCodexIdentity = {
workspaceAccountId: string | null
}
type CanonicalCodexConfig = {
contents: string
/** Home the config was read from, in the path style Codex sees at runtime
* (Linux-side for WSL); relative path-valued settings resolve against it. */
sourceHomePath: string
}
export type CodexAccountAddTarget = {
runtime?: 'host' | 'wsl'
wslDistro?: string | null
@@ -452,25 +460,31 @@ export class CodexAccountService {
// Why: Orca account switching is meant to swap Codex credentials and quota
// identity, not silently fork the user's sandbox/config defaults. Syncing
// one canonical config into every managed home keeps auth isolated per
// account while preserving consistent Codex behavior.
this.writeManagedConfig(trustedManagedHomePath, canonicalConfig)
// account while preserving consistent Codex behavior. Managed homes are
// real CODEX_HOMEs for `codex login`, so relative path-valued settings
// must keep resolving against the home the config was read from.
this.writeManagedConfig(
trustedManagedHomePath,
rewriteRelativePathConfigValues(canonicalConfig.contents, canonicalConfig.sourceHomePath)
)
}
private readCanonicalConfig(): string | null {
const primaryConfigPath = join(homedir(), '.codex', 'config.toml')
private readCanonicalConfig(): CanonicalCodexConfig | null {
const sourceHomePath = join(homedir(), '.codex')
const primaryConfigPath = join(sourceHomePath, 'config.toml')
if (!existsSync(primaryConfigPath)) {
return null
}
try {
return readFileSync(primaryConfigPath, 'utf-8')
return { contents: readFileSync(primaryConfigPath, 'utf-8'), sourceHomePath }
} catch (error) {
console.warn('[codex-accounts] Failed to read canonical config:', error)
return null
}
}
private readCanonicalConfigForManagedHome(managedHomePath: string): string | null {
private readCanonicalConfigForManagedHome(managedHomePath: string): CanonicalCodexConfig | null {
const wslInfo = parseWslUncPath(managedHomePath)
if (!wslInfo) {
return this.readCanonicalConfig()
@@ -488,7 +502,9 @@ export class CodexAccountService {
}
try {
return readFileSync(configPath, 'utf-8')
// Why: the config is read over UNC but consumed by Codex inside WSL, so
// path rewrites must anchor to the Linux-side ~/.codex, not the UNC path.
return { contents: readFileSync(configPath, 'utf-8'), sourceHomePath: `${wslHome}/.codex` }
} catch (error) {
console.warn('[codex-accounts] Failed to read WSL canonical config:', error)
return null
+135
View File
@@ -108,6 +108,141 @@ describe('syncSystemConfigIntoManagedCodexHome', () => {
expect(readFileSync(getSystemConfigPath(), 'utf-8')).toContain('codex_hooks = true')
})
it('preserves system-home relative path references in the runtime config copy', () => {
writeFileSync(
getSystemConfigPath(),
[
'model_instructions_file = "instructions.md"',
"model_catalog_json = 'catalogs/models.json'",
'experimental_compact_prompt_file = "prompts/compact.md"',
'experimental_instructions_file = "legacy-instructions.md"',
'log_dir = "logs"',
'sqlite_home = "state"',
'',
'[agents.reviewer]',
'config_file = "agents/reviewer.toml"',
'',
'[model_providers.qwen.auth]',
'cwd = "auth"',
'',
'[[skills.config]]',
'path = "skills/local"',
''
].join('\r\n'),
'utf-8'
)
syncSystemConfigIntoManagedCodexHome()
const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
expect(runtimeConfig).toContain(
`model_instructions_file = '${join(getSystemCodexHomePath(), 'instructions.md')}'`
)
expect(runtimeConfig).toContain(
`model_catalog_json = '${join(getSystemCodexHomePath(), 'catalogs', 'models.json')}'`
)
expect(runtimeConfig).toContain(
`experimental_compact_prompt_file = '${join(
getSystemCodexHomePath(),
'prompts',
'compact.md'
)}'`
)
expect(runtimeConfig).toContain(
`experimental_instructions_file = '${join(
getSystemCodexHomePath(),
'legacy-instructions.md'
)}'`
)
expect(runtimeConfig).toContain(`log_dir = '${join(getSystemCodexHomePath(), 'logs')}'`)
expect(runtimeConfig).toContain(`sqlite_home = '${join(getSystemCodexHomePath(), 'state')}'`)
expect(runtimeConfig).toContain(
`config_file = '${join(getSystemCodexHomePath(), 'agents', 'reviewer.toml')}'`
)
expect(runtimeConfig).toContain(`cwd = '${join(getSystemCodexHomePath(), 'auth')}'`)
expect(runtimeConfig).toContain(`path = '${join(getSystemCodexHomePath(), 'skills', 'local')}'`)
})
it('rewrites profile and debug lockfile path references', () => {
writeFileSync(
getSystemConfigPath(),
[
'[profiles.fast]',
'model_catalog_json = "catalogs/fast.json"',
'',
'[debug.config_lockfile]',
'load_path = "locks/config.lock.toml"',
'export_dir = "locks"',
''
].join('\n'),
'utf-8'
)
syncSystemConfigIntoManagedCodexHome()
const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
expect(runtimeConfig).toContain(
`model_catalog_json = '${join(getSystemCodexHomePath(), 'catalogs', 'fast.json')}'`
)
expect(runtimeConfig).toContain(
`load_path = '${join(getSystemCodexHomePath(), 'locks', 'config.lock.toml')}'`
)
expect(runtimeConfig).toContain(`export_dir = '${join(getSystemCodexHomePath(), 'locks')}'`)
})
it('does not treat lines inside multiline arrays as headers or path keys', () => {
writeFileSync(
getSystemConfigPath(),
['notify = [', ' ["custom", 1]', ']', 'log_dir = "logs"', ''].join('\n'),
'utf-8'
)
syncSystemConfigIntoManagedCodexHome()
const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
expect(runtimeConfig).toContain(' ["custom", 1]')
expect(runtimeConfig).toContain(`log_dir = '${join(getSystemCodexHomePath(), 'logs')}'`)
})
it('escapes control characters instead of emitting them raw in rewritten paths', () => {
writeFileSync(getSystemConfigPath(), 'log_dir = "logs\\bdir"\n', 'utf-8')
syncSystemConfigIntoManagedCodexHome()
const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
expect(runtimeConfig).toContain('log_dir = "')
expect(runtimeConfig).toContain('\\u0008')
expect(runtimeConfig).not.toContain('\b')
})
it('leaves values with lone-surrogate unicode escapes untouched', () => {
writeFileSync(getSystemConfigPath(), 'log_dir = "logs\\uD800dir"\n', 'utf-8')
syncSystemConfigIntoManagedCodexHome()
const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
expect(runtimeConfig).toContain('log_dir = "logs\\uD800dir"')
})
it('leaves absolute, home-prefixed, env-shaped, and URL path references untouched', () => {
const passthroughLines = [
'model_instructions_file = "~/notes/instructions.md"',
'model_catalog_json = "$CODEX_ASSETS/models.json"',
'experimental_instructions_file = "%USERPROFILE%\\\\instructions.md"',
'log_dir = "/var/log/codex"',
"sqlite_home = 'C:\\Users\\example\\state'",
'experimental_compact_prompt_file = "file://server/prompts/compact.md"'
]
writeFileSync(getSystemConfigPath(), `${passthroughLines.join('\n')}\n`, 'utf-8')
syncSystemConfigIntoManagedCodexHome()
const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
for (const line of passthroughLines) {
expect(runtimeConfig).toContain(line)
}
})
it('drops deprecated codex_hooks when the new hooks flag already exists', () => {
writeFileSync(
getSystemConfigPath(),
+22 -100
View File
@@ -1,7 +1,14 @@
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { dirname, join } from 'node:path'
import { writeFileAtomically } from '../codex-accounts/fs-utils'
import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths'
import { rewriteRelativePathConfigValues } from './codex-config-path-reference-rewrite'
import {
createTomlLineScanState,
getTomlTableHeader,
isTomlStructuralLine,
updateTomlLineScanState
} from './config-toml-line-scan'
function getRuntimeCodexConfigTomlPath(): string {
return join(getOrcaManagedCodexHomePath(), 'config.toml')
@@ -28,8 +35,9 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe(): void {
return
}
const systemConfig = normalizeDeprecatedCodexHookFeatureFlag(
systemConfigExists ? readFileSync(systemConfigPath, 'utf-8') : ''
const systemConfig = prepareSystemConfigForRuntimeMirror(
systemConfigExists ? readFileSync(systemConfigPath, 'utf-8') : '',
dirname(systemConfigPath)
)
if (!runtimeConfigExists) {
// Why: trust blocks reference a hooks.json path, so system-home hook trust
@@ -45,6 +53,13 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe(): void {
}
}
function prepareSystemConfigForRuntimeMirror(config: string, systemConfigDir: string): string {
return rewriteRelativePathConfigValues(
normalizeDeprecatedCodexHookFeatureFlag(config),
systemConfigDir
)
}
function normalizeDeprecatedCodexHookFeatureFlag(config: string): string {
if (!config.includes('codex_hooks')) {
return config
@@ -145,13 +160,6 @@ type TomlSection = {
start: number
}
type TomlMultilineState = {
basic: boolean
literal: boolean
}
type TomlMultilineMode = 'basic' | 'literal' | null
function stripRuntimeOwnedTomlSections(
config: string,
runtimeProjectHeaders = new Set<string>()
@@ -179,14 +187,12 @@ function getTomlSections(config: string): TomlSection[] {
const sections: TomlSection[] = []
let sectionStart = -1
let sectionHeader: string | null = null
let multilineState: TomlMultilineState = { basic: false, literal: false }
let scanState = createTomlLineScanState()
for (let index = 0; index < lines.length; index += 1) {
const header = isInsideTomlMultilineString(multilineState)
? null
: getTomlTableHeader(lines[index] ?? '')
const header = isTomlStructuralLine(scanState) ? getTomlTableHeader(lines[index] ?? '') : null
if (!header) {
multilineState = updateTomlMultilineState(multilineState, lines[index] ?? '')
scanState = updateTomlLineScanState(scanState, lines[index] ?? '')
continue
}
@@ -199,7 +205,7 @@ function getTomlSections(config: string): TomlSection[] {
}
sectionStart = index
sectionHeader = header
multilineState = updateTomlMultilineState(multilineState, lines[index] ?? '')
scanState = updateTomlLineScanState(scanState, lines[index] ?? '')
}
if (sectionStart !== -1) {
@@ -241,87 +247,3 @@ function joinTomlBlocks(blocks: string[]): string {
const normalizedBlocks = blocks.map((block) => block.trim()).filter((block) => block.length > 0)
return normalizedBlocks.length === 0 ? '' : `${normalizedBlocks.join('\n\n')}\n`
}
function getTomlTableHeader(line: string): string | null {
const match = /^(\s*\[\[?.+\]\]?\s*)(?:#.*)?$/.exec(line)
return match?.[1] ?? null
}
function isInsideTomlMultilineString(state: TomlMultilineState): boolean {
return state.basic || state.literal
}
function updateTomlMultilineState(state: TomlMultilineState, line: string): TomlMultilineState {
let mode: TomlMultilineMode = state.basic ? 'basic' : state.literal ? 'literal' : null
let index = 0
while (index < line.length) {
if (mode === 'basic') {
if (line[index] === '\\') {
index += 2
continue
}
if (line.startsWith('"""', index)) {
mode = null
index += 3
continue
}
index += 1
continue
}
if (mode === 'literal') {
if (line.startsWith("'''", index)) {
mode = null
index += 3
continue
}
index += 1
continue
}
const char = line[index]
if (char === '#') {
break
}
if (line.startsWith('"""', index)) {
mode = 'basic'
index += 3
continue
}
if (line.startsWith("'''", index)) {
mode = 'literal'
index += 3
continue
}
if (char === '"') {
index = skipTomlBasicString(line, index + 1)
continue
}
if (char === "'") {
index = skipTomlLiteralString(line, index + 1)
continue
}
index += 1
}
return { basic: mode === 'basic', literal: mode === 'literal' }
}
function skipTomlBasicString(line: string, startIndex: number): number {
let index = startIndex
while (index < line.length) {
const char = line[index]
if (char === '\\') {
index += 2
continue
}
if (char === '"') {
return index + 1
}
index += 1
}
return index
}
function skipTomlLiteralString(line: string, startIndex: number): number {
const endIndex = line.indexOf("'", startIndex)
return endIndex === -1 ? line.length : endIndex + 1
}
@@ -0,0 +1,251 @@
import { posix as pathPosix, win32 as pathWin32 } from 'node:path'
import {
createTomlLineScanState,
getTomlTableHeader,
isTomlStructuralLine,
updateTomlLineScanState
} from './config-toml-line-scan'
// Why: codex-rs types these settings AbsolutePathBuf and resolves relative
// values against the defining config.toml's directory (= CODEX_HOME for the
// user config). experimental_instructions_file only exists in older Codex
// releases; keeping it is harmless since Codex ignores unknown keys.
const EXACT_PATH_CONFIG_KEYS = new Set([
'debug.config_lockfile.export_dir',
'debug.config_lockfile.load_path',
'experimental_compact_prompt_file',
'experimental_instructions_file',
'log_dir',
'model_catalog_json',
'model_instructions_file',
'skills.config.path',
'sqlite_home'
])
type ParsedTomlString = {
value: string
start: number
end: number
}
// Why: Orca mirrors config.toml into a managed CODEX_HOME, but Codex resolves
// path-valued config settings from the file it read. Keep user-owned assets in
// ~/.codex reachable after the mirror moves the TOML. Best-effort by design:
// values spelled as inline tables, quoted keys, or triple-quoted strings pass
// through unchanged rather than risk corrupting them.
export function rewriteRelativePathConfigValues(config: string, sourceConfigDir: string): string {
const lines = config.split('\n')
let tablePath = ''
let scanState = createTomlLineScanState()
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index] ?? ''
if (isTomlStructuralLine(scanState)) {
const header = getTomlTableHeader(line)
if (header) {
tablePath = getTomlHeaderPath(header)
} else {
lines[index] = rewriteRelativePathConfigLine(line, tablePath, sourceConfigDir)
}
}
scanState = updateTomlLineScanState(scanState, line)
}
return lines.join('\n')
}
function rewriteRelativePathConfigLine(
line: string,
tablePath: string,
sourceConfigDir: string
): string {
const equalsIndex = line.indexOf('=')
if (equalsIndex === -1) {
return line
}
const key = line.slice(0, equalsIndex).trim()
if (!isPathConfigKey(tablePath, key)) {
return line
}
const parsed = parseTomlSingleLineStringValue(line, equalsIndex + 1)
if (!parsed || !shouldRewriteRelativePath(parsed.value)) {
return line
}
// Why: WSL configs are read over UNC paths but consumed inside Linux, so
// join semantics must follow the source home's path style, not the host's.
const path = sourceConfigDir.startsWith('/') ? pathPosix : pathWin32
const absolutePath = path.join(sourceConfigDir, parsed.value)
return `${line.slice(0, parsed.start)}${quoteTomlPath(absolutePath)}${line.slice(parsed.end)}`
}
function isPathConfigKey(tablePath: string, key: string): boolean {
const normalizedKey = normalizeTomlPathExpression(key)
const fullPath = tablePath
? `${normalizeTomlPathExpression(tablePath)}.${normalizedKey}`
: normalizedKey
if (EXACT_PATH_CONFIG_KEYS.has(fullPath)) {
return true
}
return (
/^agents\..+\.config_file$/.test(fullPath) ||
/^model_providers\..+\.auth\.cwd$/.test(fullPath) ||
// Why: profiles mirror the top-level file settings that Codex reads (and
// can abort on) during config load.
/^profiles\..+\.(?:experimental_compact_prompt_file|model_catalog_json|model_instructions_file)$/.test(
fullPath
)
)
}
function normalizeTomlPathExpression(value: string): string {
return value.replace(/\s+/g, '')
}
function shouldRewriteRelativePath(value: string): boolean {
const trimmed = value.trim()
if (!trimmed || trimmed.startsWith('~') || trimmed.startsWith('$') || trimmed.startsWith('%')) {
return false
}
if (pathWin32.isAbsolute(trimmed) || pathPosix.isAbsolute(trimmed)) {
return false
}
return !/^[A-Za-z][A-Za-z0-9+.-]*:/.test(trimmed)
}
function quoteTomlPath(value: string): string {
return canUseTomlLiteralString(value) ? `'${value}'` : quoteTomlBasicString(value)
}
// Why: TOML literal strings allow tab but no other control chars and no
// single quote; anything else must go through an escaped basic string.
function canUseTomlLiteralString(value: string): boolean {
for (const char of value) {
if (char === "'") {
return false
}
const codePoint = char.codePointAt(0) ?? 0
if ((codePoint < 0x20 && codePoint !== 0x09) || codePoint === 0x7f) {
return false
}
}
return true
}
function quoteTomlBasicString(value: string): string {
let quoted = '"'
for (const char of value) {
if (char === '"' || char === '\\') {
quoted += `\\${char}`
continue
}
const codePoint = char.codePointAt(0) ?? 0
if (codePoint < 0x20 || codePoint === 0x7f) {
quoted += `\\u${codePoint.toString(16).toUpperCase().padStart(4, '0')}`
continue
}
quoted += char
}
return `${quoted}"`
}
function parseTomlSingleLineStringValue(line: string, offset: number): ParsedTomlString | null {
let index = offset
while (line[index] === ' ' || line[index] === '\t') {
index += 1
}
if (line.startsWith('"""', index) || line.startsWith("'''", index)) {
return null
}
const quote = line[index]
if (quote !== '"' && quote !== "'") {
return null
}
const start = index
index += 1
let value = ''
while (index < line.length) {
const char = line[index]
if (char === quote) {
return { value, start, end: index + 1 }
}
if (quote === '"' && char === '\\') {
const escaped = parseTomlBasicStringEscape(line, index)
if (!escaped) {
return null
}
value += escaped.value
index = escaped.nextIndex
continue
}
value += char
index += 1
}
return null
}
function parseTomlBasicStringEscape(
line: string,
slashIndex: number
): { value: string; nextIndex: number } | null {
const escaped = line[slashIndex + 1]
switch (escaped) {
case 'b':
return { value: '\b', nextIndex: slashIndex + 2 }
case 't':
return { value: '\t', nextIndex: slashIndex + 2 }
case 'n':
return { value: '\n', nextIndex: slashIndex + 2 }
case 'f':
return { value: '\f', nextIndex: slashIndex + 2 }
case 'r':
return { value: '\r', nextIndex: slashIndex + 2 }
case '"':
case '\\':
return { value: escaped, nextIndex: slashIndex + 2 }
case 'u':
return parseTomlUnicodeEscape(line, slashIndex + 2, 4)
case 'U':
return parseTomlUnicodeEscape(line, slashIndex + 2, 8)
default:
return null
}
}
function parseTomlUnicodeEscape(
line: string,
start: number,
length: number
): { value: string; nextIndex: number } | null {
const raw = line.slice(start, start + length)
if (!new RegExp(`^[0-9a-fA-F]{${length}}$`).test(raw)) {
return null
}
const codePoint = Number.parseInt(raw, 16)
// Why: TOML escapes must be Unicode scalar values; String.fromCodePoint
// accepts lone surrogates, which would round-trip into invalid TOML.
if (codePoint >= 0xd800 && codePoint <= 0xdfff) {
return null
}
try {
return { value: String.fromCodePoint(codePoint), nextIndex: start + length }
} catch {
return null
}
}
function getTomlHeaderPath(header: string): string {
const trimmed = header.trim()
if (trimmed.startsWith('[[') && trimmed.endsWith(']]')) {
return trimmed.slice(2, -2).trim()
}
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
return trimmed.slice(1, -1).trim()
}
return ''
}
+115
View File
@@ -0,0 +1,115 @@
// Why: Orca edits Codex config.toml byte-preservingly (no TOML dependency), so
// every editor must agree on which lines sit inside multiline strings or
// arrays vs real TOML structure. Keep the line-scanner in one place to avoid
// drift.
export type TomlLineScanState = {
basic: boolean
literal: boolean
arrayDepth: number
}
type TomlMultilineMode = 'basic' | 'literal' | null
export function createTomlLineScanState(): TomlLineScanState {
return { basic: false, literal: false, arrayDepth: 0 }
}
// Why: lines inside multiline strings or unclosed arrays can look exactly like
// `[section]` headers or `key = value` pairs but are data, not structure.
export function isTomlStructuralLine(state: TomlLineScanState): boolean {
return !state.basic && !state.literal && state.arrayDepth === 0
}
export function updateTomlLineScanState(state: TomlLineScanState, line: string): TomlLineScanState {
let mode: TomlMultilineMode = state.basic ? 'basic' : state.literal ? 'literal' : null
let arrayDepth = state.arrayDepth
let index = 0
while (index < line.length) {
if (mode === 'basic') {
if (line[index] === '\\') {
index += 2
continue
}
if (line.startsWith('"""', index)) {
mode = null
index += 3
continue
}
index += 1
continue
}
if (mode === 'literal') {
if (line.startsWith("'''", index)) {
mode = null
index += 3
continue
}
index += 1
continue
}
const char = line[index]
if (char === '#') {
break
}
if (line.startsWith('"""', index)) {
mode = 'basic'
index += 3
continue
}
if (line.startsWith("'''", index)) {
mode = 'literal'
index += 3
continue
}
if (char === '"') {
index = skipTomlBasicString(line, index + 1)
continue
}
if (char === "'") {
index = skipTomlLiteralString(line, index + 1)
continue
}
// Why: table-header brackets balance within their line, so a depth that
// stays positive across lines means a multiline array is still open.
if (char === '[') {
arrayDepth += 1
index += 1
continue
}
if (char === ']') {
arrayDepth = Math.max(0, arrayDepth - 1)
index += 1
continue
}
index += 1
}
return { basic: mode === 'basic', literal: mode === 'literal', arrayDepth }
}
export function getTomlTableHeader(line: string): string | null {
const match = /^(\s*\[\[?.+\]\]?\s*)(?:#.*)?$/.exec(line)
return match?.[1] ?? null
}
function skipTomlBasicString(line: string, startIndex: number): number {
let index = startIndex
while (index < line.length) {
const char = line[index]
if (char === '\\') {
index += 2
continue
}
if (char === '"') {
return index + 1
}
index += 1
}
return index
}
function skipTomlLiteralString(line: string, startIndex: number): number {
const endIndex = line.indexOf("'", startIndex)
return endIndex === -1 ? line.length : endIndex + 1
}
+14 -97
View File
@@ -11,6 +11,11 @@ import { dirname, join } from 'node:path'
import { createHash, randomUUID } from 'node:crypto'
import { escapeRegex } from '../../shared/string-utils'
import { copyFileWithWindowsRetry, renameFileWithWindowsRetry } from '../codex-accounts/fs-utils'
import {
createTomlLineScanState,
isTomlStructuralLine,
updateTomlLineScanState
} from './config-toml-line-scan'
// Why: Codex 0.129+ gates each hook on a `trusted_hash` entry in
// ~/.codex/config.toml under [hooks.state."<key>"]. Without it the hook is in
@@ -476,16 +481,14 @@ export function normalizeHookTrustKeyForLookup(key: string): string {
function findTrustBlockRanges(content: string, key: string): TrustBlockRange[] {
const ranges: TrustBlockRange[] = []
let cursor = 0
let multilineState: TomlMultilineState = { basic: false, literal: false }
let scanState = createTomlLineScanState()
while (cursor < content.length) {
const newlineIdx = content.indexOf('\n', cursor)
const lineEnd = newlineIdx === -1 ? content.length : newlineIdx
const rawLine = content.slice(cursor, lineEnd)
const line = rawLine.replace(/\r$/, '')
const nextCursor = newlineIdx === -1 ? content.length : newlineIdx + 1
const headerKey = isInsideTomlMultilineString(multilineState)
? null
: parseHookStateHeaderKey(line)
const headerKey = isTomlStructuralLine(scanState) ? parseHookStateHeaderKey(line) : null
if (
headerKey !== null &&
normalizeHookTrustKeyForLookup(headerKey) === normalizeHookTrustKeyForLookup(key)
@@ -498,7 +501,7 @@ function findTrustBlockRanges(content: string, key: string): TrustBlockRange[] {
cursor = Math.max(blockEnd, nextCursor)
continue
}
multilineState = updateTomlMultilineState(multilineState, line)
scanState = updateTomlLineScanState(scanState, line)
cursor = nextCursor
}
return ranges
@@ -596,13 +599,13 @@ function skipTomlInlineWhitespace(line: string, startIndex: number): number {
// a flat regex misclassifies both cases.
function findNextTableHeader(text: string): number {
let cursor = 0
let multilineState: TomlMultilineState = { basic: false, literal: false }
let scanState = createTomlLineScanState()
while (cursor < text.length) {
const newlineIdx = text.indexOf('\n', cursor)
const lineEnd = newlineIdx === -1 ? text.length : newlineIdx
const rawLine = text.slice(cursor, lineEnd)
const line = rawLine.replace(/\r$/, '')
if (!isInsideTomlMultilineString(multilineState)) {
if (isTomlStructuralLine(scanState)) {
const trimmed = line.trimStart()
// Why: stop at both `[table]` and `[[array.of.tables]]` — both end our
// block. Skipping `[[ ]]` here would let our slice consume past array
@@ -611,7 +614,7 @@ function findNextTableHeader(text: string): number {
return cursor
}
}
multilineState = updateTomlMultilineState(multilineState, line)
scanState = updateTomlLineScanState(scanState, line)
if (newlineIdx === -1) {
return -1
}
@@ -678,92 +681,6 @@ function isCompleteTableHeader(line: string): boolean {
return false
}
type TomlMultilineState = {
basic: boolean
literal: boolean
}
type TomlMultilineMode = 'basic' | 'literal' | null
function isInsideTomlMultilineString(state: TomlMultilineState): boolean {
return state.basic || state.literal
}
function updateTomlMultilineState(state: TomlMultilineState, line: string): TomlMultilineState {
let mode: TomlMultilineMode = state.basic ? 'basic' : state.literal ? 'literal' : null
let index = 0
while (index < line.length) {
if (mode === 'basic') {
if (line[index] === '\\') {
index += 2
continue
}
if (line.startsWith('"""', index)) {
mode = null
index += 3
continue
}
index++
continue
}
if (mode === 'literal') {
if (line.startsWith("'''", index)) {
mode = null
index += 3
continue
}
index++
continue
}
const char = line[index]
if (char === '#') {
break
}
if (line.startsWith('"""', index)) {
mode = 'basic'
index += 3
continue
}
if (line.startsWith("'''", index)) {
mode = 'literal'
index += 3
continue
}
if (char === '"') {
index = skipTomlBasicString(line, index + 1)
continue
}
if (char === "'") {
index = skipTomlLiteralString(line, index + 1)
continue
}
index++
}
return { basic: mode === 'basic', literal: mode === 'literal' }
}
function skipTomlBasicString(line: string, startIndex: number): number {
let index = startIndex
while (index < line.length) {
const char = line[index]
if (char === '\\') {
index += 2
continue
}
if (char === '"') {
return index + 1
}
index++
}
return index
}
function skipTomlLiteralString(line: string, startIndex: number): number {
const endIndex = line.indexOf("'", startIndex)
return endIndex === -1 ? line.length : endIndex + 1
}
// Why: same atomic-rename + .bak rotation pattern as writeHooksJson — a
// half-written config.toml can brick a user's Codex install, so write to
// tmp and rename. Random-suffix tmp name avoids cross-process races on
@@ -830,14 +747,14 @@ export function readHookTrustEntries(configPath: string): Map<string, CodexHookT
// Why: walk line-by-line so `[hooks.state."..."]` inside a `"""..."""` or
// `'''...'''` multi-line string isn't mistaken for a real header.
let cursor = 0
let multilineState: TomlMultilineState = { basic: false, literal: false }
let scanState = createTomlLineScanState()
while (cursor < content.length) {
const newlineIdx = content.indexOf('\n', cursor)
const lineEnd = newlineIdx === -1 ? content.length : newlineIdx
const rawLine = content.slice(cursor, lineEnd)
const line = rawLine.replace(/\r$/, '')
const nextCursor = newlineIdx === -1 ? content.length : newlineIdx + 1
const key = isInsideTomlMultilineString(multilineState) ? null : parseHookStateHeaderKey(line)
const key = isTomlStructuralLine(scanState) ? parseHookStateHeaderKey(line) : null
if (key !== null) {
// Why: block ends at the next *real* header (multi-line aware).
const after = content.slice(nextCursor)
@@ -863,7 +780,7 @@ export function readHookTrustEntries(configPath: string): Map<string, CodexHookT
cursor = nextCursor
continue
}
multilineState = updateTomlMultilineState(multilineState, line)
scanState = updateTomlLineScanState(scanState, line)
cursor = nextCursor
}
return result
+4 -11
View File
@@ -13,7 +13,7 @@ import {
readHooksJson,
removeManagedCommands,
wrapPosixHookCommand,
wrapWindowsHookCommand,
wrapWindowsCmdHookCommand,
writeHooksJson,
writeManagedScript,
type HookCommandConfig,
@@ -120,17 +120,10 @@ function getManagedScriptPath(): string {
return getSharedManagedScriptPath(getManagedScriptFileName())
}
// Why: a Windows script path is cmd-safe when it holds only characters cmd.exe
// passes through untouched (drive letter, backslash, dot, dash, underscore).
// Spaces or cmd metacharacters force the encoded launcher; `cmd.exe /C` splits
// bare `.cmd` paths at spaces before the script can run.
const WINDOWS_CMD_SAFE_PATH = /^[A-Za-z0-9_.:\\~-]+$/
function getManagedCommand(scriptPath: string): string {
if (process.platform !== 'win32') {
return wrapPosixHookCommand(scriptPath)
}
return WINDOWS_CMD_SAFE_PATH.test(scriptPath) ? scriptPath : wrapWindowsHookCommand(scriptPath)
return process.platform === 'win32'
? wrapWindowsCmdHookCommand(scriptPath)
: wrapPosixHookCommand(scriptPath)
}
function getSystemConfigPath(): string {
+3
View File
@@ -8,6 +8,7 @@
*/
import { startDaemon, type DaemonHandle } from './daemon-main'
import { createPtySubprocess } from './pty-subprocess'
import { warmWindowsConptyOnce } from './windows-conpty-warmup'
import { warmPwshAvailabilityCache } from '../pwsh'
export function parseArgs(argv: string[]): { socketPath: string; tokenPath: string } {
@@ -84,6 +85,8 @@ async function main(): Promise<void> {
if (process.send) {
process.send({ type: 'ready' })
}
warmWindowsConptyOnce()
}
// Only auto-run when executed directly (not imported for testing)

Some files were not shown because too many files have changed in this diff Show More