diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml index 157a1ba773e..0d31113a0e0 100644 --- a/.github/workflows/mobile.yml +++ b/.github/workflows/mobile.yml @@ -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: diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index df152e9fb31..b792dd9cf47 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -1,6 +1,6 @@ name: Cut Release -# Why: single entry point for manually and scheduled cutting releases. +# Why: single entry point for manually cutting releases. # Replaces the old local `pnpm release:*` scripts and the standalone scheduled # RC workflow so releases are always reproducible from CI and can never be # accidentally tagged against an uncommitted or non-main working tree. @@ -37,16 +37,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) diff --git a/README.md b/README.md index e5f11ec0b90..f5cace99b1e 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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) --- diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index 5ee9f1a3017..c106b166feb 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -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'), diff --git a/config/scripts/repro-windows-apphang-terminal-activation.mjs b/config/scripts/repro-windows-apphang-terminal-activation.mjs new file mode 100644 index 00000000000..73e7bd4a13f --- /dev/null +++ b/config/scripts/repro-windows-apphang-terminal-activation.mjs @@ -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) +}) diff --git a/config/scripts/verify-windows-inner-signature.mjs b/config/scripts/verify-windows-inner-signature.mjs new file mode 100644 index 00000000000..e0c5e303dbc --- /dev/null +++ b/config/scripts/verify-windows-inner-signature.mjs @@ -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 || ''}.`, + signature + } + } + + if (!subjectAllowed && !thumbprintAllowed) { + return { + ok: false, + message: `Unexpected Windows inner executable signer: ${signerSubject || ''}.`, + signature + } + } + + return { ok: true, signature } +} + +export function formatSignatureSummary(signature) { + return [ + `Status: ${signature.status ?? ''}`, + `Subject: ${normalizeSignerSubject(signature.signerSubject) || ''}`, + `Issuer: ${signature.signerIssuer ?? ''}`, + `Thumbprint: ${normalizeThumbprint(signature.signerThumbprint) || ''}`, + `NotBefore: ${signature.notBefore ?? ''}`, + `NotAfter: ${signature.notAfter ?? ''}` + ].join('\n') +} + +export function validateExecutablePath(executablePath) { + if (typeof executablePath !== 'string' || executablePath.trim() === '') { + throw new Error('Usage: node config/scripts/verify-windows-inner-signature.mjs ') + } + + 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 ?? ''}.` + ) + } + + 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() +} diff --git a/config/scripts/verify-windows-inner-signature.test.mjs b/config/scripts/verify-windows-inner-signature.test.mjs new file mode 100644 index 00000000000..d833fb64774 --- /dev/null +++ b/config/scripts/verify-windows-inner-signature.test.mjs @@ -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/) + }) + }) +}) diff --git a/config/scripts/windows-apphang-repro/apphang-report-summary.mjs b/config/scripts/windows-apphang-repro/apphang-report-summary.mjs new file mode 100644 index 00000000000..b6d222cb4de --- /dev/null +++ b/config/scripts/windows-apphang-repro/apphang-report-summary.mjs @@ -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 + } +} diff --git a/config/scripts/windows-apphang-repro/electron-dev-session.mjs b/config/scripts/windows-apphang-repro/electron-dev-session.mjs new file mode 100644 index 00000000000..89fddf4ef99 --- /dev/null +++ b/config/scripts/windows-apphang-repro/electron-dev-session.mjs @@ -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) } + } +} diff --git a/config/scripts/windows-apphang-repro/repro-timing.mjs b/config/scripts/windows-apphang-repro/repro-timing.mjs new file mode 100644 index 00000000000..9f335d61435 --- /dev/null +++ b/config/scripts/windows-apphang-repro/repro-timing.mjs @@ -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)}`) +} diff --git a/config/scripts/windows-apphang-repro/terminal-activation-scenario.mjs b/config/scripts/windows-apphang-repro/terminal-activation-scenario.mjs new file mode 100644 index 00000000000..db92c7cdf31 --- /dev/null +++ b/config/scripts/windows-apphang-repro/terminal-activation-scenario.mjs @@ -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 +} diff --git a/config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs b/config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs new file mode 100644 index 00000000000..cd38f400d47 --- /dev/null +++ b/config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs @@ -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)) + } +} diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 955567a21fa..a7c4761a0ec 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -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", diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 79f778f0a38..ff7c9fd7520 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 3.2m + + downloads: 3.4m @@ -15,7 +15,7 @@ downloads downloads - 3.2m - 3.2m + 3.4m + 3.4m diff --git a/docs/assets/wechat-qr.png b/docs/assets/wechat-qr.png index 3c71c4952ea..bf270d857e8 100644 Binary files a/docs/assets/wechat-qr.png and b/docs/assets/wechat-qr.png differ diff --git a/mobile/app.json b/mobile/app.json index 9394a9a63dc..3a09a5531c8 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -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", diff --git a/mobile/app/h/[hostId]/files/preview/[worktreeId].tsx b/mobile/app/h/[hostId]/files/preview/[worktreeId].tsx index 8ab79117ce8..65412d51958 100644 --- a/mobile/app/h/[hostId]/files/preview/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/files/preview/[worktreeId].tsx @@ -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[] }>() diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 1912f7a04a4..a934334b8f2 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -1,8 +1,6 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { Animated, AppState, Linking, type AppStateStatus } from 'react-native' import * as Clipboard from 'expo-clipboard' -import { ImageManipulator, SaveFormat } from 'expo-image-manipulator' -import { File as FsFile, Paths } from 'expo-file-system' import { BackHandler, FlatList, @@ -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 -// Why: clipboard images are re-encoded as lossless PNG, so high-res screenshots and -// photos can exceed the upload byte budget; resize the raster down to fit before upload. -// The image is staged to a temp file first because the iOS ImageManipulator loader -// (Data(contentsOf:)) cannot decode large base64 data URIs — it needs a file:// URI. -const resizeMobileClipboardImage: MobileClipboardImageResizer = async (source, target) => { - const base64 = source.replace(CLIPBOARD_IMAGE_DATA_URL_PREFIX_RE, '') - const file = new FsFile(Paths.cache, `orca-clip-resize-${Date.now()}.png`) - let context: ReturnType | null = null - let rendered: Awaited< - ReturnType['renderAsync']> - > | null = null - let resultUri: string | null = null - try { - file.create({ overwrite: true }) - file.write(base64, { encoding: 'base64' }) - context = ImageManipulator.manipulate(file.uri) - context.resize({ width: target.width, height: target.height }) - rendered = await context.renderAsync() - const result = await rendered.saveAsync({ format: SaveFormat.PNG, base64: true }) - resultUri = result.uri - // Why: empty base64 would pass the downstream base64 check and upload a corrupt - // image, so fail loudly here instead of silently sending an invalid payload. - if (!result.base64) { - throw new Error('Failed to encode resized clipboard image') - } - return { data: result.base64, width: result.width, height: result.height } - } finally { - rendered?.release() - context?.release() - if (resultUri) { - try { - new FsFile(resultUri).delete() - } catch { - // Best-effort cleanup; ImageManipulator saves into cache for every retry. - } - } - try { - file.delete() - } catch { - // Best-effort cleanup; the OS reclaims the cache directory regardless. - } - } -} +const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450 function getActiveTabIdForHandle( tabs: MobileSessionTab[], @@ -835,6 +788,21 @@ function FileReader({ return renderSourceText(doc.content) } +function updateTerminalCwdFromStreamEvent( + handle: string, + data: Record, + terminalCwd: Map +): 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('orca-browser') const [liveInputCapture, setLiveInputCapture] = useState('') - const [liveInputTerminalHandles, setLiveInputTerminalHandles] = useState>( - () => new Set() - ) - const liveInputTerminalHandlesRef = useRef>(new Set()) - const defaultedLiveInputTerminalHandlesRef = useRef>(new Set()) + const { + clearTerminalLiveInputDefault, + defaultTerminalHandlesToLiveInput, + liveInputTerminalHandles, + liveInputTerminalHandlesRef, + pruneTerminalHandlesFromLiveInput, + toggleTerminalLiveInput + } = useTerminalLiveInputModePreference({ hostId, worktreeId }) const [activeHandle, setActiveHandle] = useState(null) const [activeSessionTabId, setActiveSessionTabId] = useState(null) const activeSessionTabIdRef = useRef(null) @@ -1023,6 +994,7 @@ export default function SessionScreen() { const terminalGestureInputBucketsRef = useRef>(new Map()) const terminalGestureInputQueuesRef = useRef>(new Map()) const terminalGestureInputInFlightRef = useRef>(new Set()) + const terminalCwdRef = useRef>(new Map()) const initialModesSeenRef = useRef>(new Set()) const deviceTokenRef = useRef(null) const clientRef = useRef(null) @@ -1034,6 +1006,7 @@ export default function SessionScreen() { const terminalRefs = useRef>(new Map()) const liveInputRef = useRef(null) const liveInputFocusTimerRef = useRef | null>(null) + const sendLiveTerminalInputRef = useRef(async () => false) const sessionTabActionSheetKeyboardHideSubRef = useRef | null>(null) @@ -1068,6 +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) | null>(null) + const initialEmptySessionAutoCreateRef = useRef(null) const markdownSaveSeqRef = useRef>(new Map()) const markdownSaveInFlightRef = useRef>(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) => { - 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 => { 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 => { - if (activated) { - return - } - await fetchSessionTabs() - if (activated) { - return - } - const opened = sessionTabsRef.current.find( - (tab): tab is Extract => - '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({ + 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 => { + 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) diff --git a/mobile/package.json b/mobile/package.json index 734519e9747..9d75502765f 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -60,10 +60,12 @@ "devDependencies": { "@types/react": "^19.2.14", "@types/react-native": "^0.73.0", + "@types/react-test-renderer": "19.1.0", "@types/ws": "^8.18.1", "expo-module-scripts": "^55.0.2", "oxfmt": "^0.52.0", "oxlint": "^1.71.0", + "react-test-renderer": "19.2.6", "tsx": "^4.22.4", "typescript": "^5.9.3", "vite": "^8.0.16", diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 7785a4d05c6..aecf79fe0ee 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -67,7 +67,7 @@ importers: version: 55.0.22(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) expo-router: specifier: ^55.0.14 - version: 55.0.14(ce047ddbdcb481fe003f0510f5198c7e) + version: 55.0.14(e0a8113e3689d84edb7af23d00a72ffa) expo-secure-store: specifier: ^55.0.13 version: 55.0.13(expo@55.0.23) @@ -135,18 +135,24 @@ importers: '@types/react-native': specifier: ^0.73.0 version: 0.73.0(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) + '@types/react-test-renderer': + specifier: 19.1.0 + version: 19.1.0 '@types/ws': specifier: ^8.18.1 version: 8.18.1 expo-module-scripts: specifier: ^55.0.2 - version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6) + version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6) oxfmt: specifier: ^0.52.0 version: 0.52.0 oxlint: specifier: ^1.71.0 version: 1.71.0 + react-test-renderer: + specifier: 19.2.6 + version: 19.2.6(react@19.2.6) tsx: specifier: ^4.22.4 version: 4.22.4 @@ -1724,48 +1730,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-arm64-musl@0.52.0': resolution: {integrity: sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxfmt/binding-linux-ppc64-gnu@0.52.0': resolution: {integrity: sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-riscv64-gnu@0.52.0': resolution: {integrity: sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-riscv64-musl@0.52.0': resolution: {integrity: sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxfmt/binding-linux-s390x-gnu@0.52.0': resolution: {integrity: sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-x64-gnu@0.52.0': resolution: {integrity: sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-x64-musl@0.52.0': resolution: {integrity: sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxfmt/binding-openharmony-arm64@0.52.0': resolution: {integrity: sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ==} @@ -1838,48 +1852,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-arm64-musl@1.71.0': resolution: {integrity: sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxlint/binding-linux-ppc64-gnu@1.71.0': resolution: {integrity: sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-riscv64-gnu@1.71.0': resolution: {integrity: sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-riscv64-musl@1.71.0': resolution: {integrity: sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxlint/binding-linux-s390x-gnu@1.71.0': resolution: {integrity: sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxlint/binding-linux-x64-gnu@1.71.0': resolution: {integrity: sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-x64-musl@1.71.0': resolution: {integrity: sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxlint/binding-openharmony-arm64@1.71.0': resolution: {integrity: sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw==} @@ -2341,36 +2363,42 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.1.3': resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.1.3': resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.1.3': resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.1.3': resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.1.3': resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.1.3': resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==} @@ -4632,24 +4660,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -5424,6 +5456,11 @@ packages: peerDependencies: react: ^19.2.0 + react-test-renderer@19.2.6: + resolution: {integrity: sha512-GbS6V23YduFTPiWJ5xICbKEjRcqx1Z90js/V5miqhz7qp/d6xSe9Dd6NjSQODFRdzdsqRMPW82E/sFpPRbY5Mw==} + peerDependencies: + react: ^19.2.6 + react@19.2.6: resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} engines: {node: '>=0.10.0'} @@ -7825,7 +7862,7 @@ snapshots: ws: 8.21.0 zod: 3.25.76 optionalDependencies: - expo-router: 55.0.14(ce047ddbdcb481fe003f0510f5198c7e) + expo-router: 55.0.14(e0a8113e3689d84edb7af23d00a72ffa) react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) transitivePeerDependencies: - '@expo/dom-webview' @@ -8093,7 +8130,7 @@ snapshots: react: 19.2.6 optionalDependencies: '@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-dom@19.2.6(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) - expo-router: 55.0.14(ce047ddbdcb481fe003f0510f5198c7e) + expo-router: 55.0.14(e0a8113e3689d84edb7af23d00a72ffa) react-dom: 19.2.6(react@19.2.6) transitivePeerDependencies: - supports-color @@ -9055,14 +9092,14 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6)': + '@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: jest-matcher-utils: 30.3.0 picocolors: 1.1.1 pretty-format: 30.3.0 react: 19.2.6 react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) - react-test-renderer: 19.2.0(react@19.2.6) + react-test-renderer: 19.2.6(react@19.2.6) redent: 3.0.0 optionalDependencies: jest: 29.7.0(@types/node@25.9.3) @@ -10651,7 +10688,7 @@ snapshots: expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) expo-json-utils: 55.0.2 - expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6): + expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6): dependencies: '@babel/cli': 7.28.6(@babel/core@7.29.7) '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.7) @@ -10659,7 +10696,7 @@ snapshots: '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7) '@expo/npm-proofread': 1.0.1 '@expo/spawn-async': 1.7.2 - '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6) + '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6) '@tsconfig/node18': 18.2.6 '@types/jest': 29.5.14 babel-plugin-dynamic-import-node: 2.3.3 @@ -10734,7 +10771,7 @@ snapshots: - supports-color - typescript - expo-router@55.0.14(ce047ddbdcb481fe003f0510f5198c7e): + expo-router@55.0.14(e0a8113e3689d84edb7af23d00a72ffa): dependencies: '@expo/log-box': 55.0.12(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) '@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-dom@19.2.6(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) @@ -10771,7 +10808,7 @@ snapshots: use-latest-callback: 0.2.6(react@19.2.6) vaul: 1.1.2(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) optionalDependencies: - '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6) + '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6) react-dom: 19.2.6(react@19.2.6) react-native-gesture-handler: 2.31.2(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) react-native-reanimated: 4.3.0(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) @@ -12948,6 +12985,12 @@ snapshots: react-is: 19.2.6 scheduler: 0.27.0 + react-test-renderer@19.2.6(react@19.2.6): + dependencies: + react: 19.2.6 + react-is: 19.2.6 + scheduler: 0.27.0 + react@19.2.6: {} readdirp@3.6.0: diff --git a/mobile/src/files/MobileFileMarkdownPreview.tsx b/mobile/src/files/MobileFileMarkdownPreview.tsx index b6e32f47e99..0923cf1f9a0 100644 --- a/mobile/src/files/MobileFileMarkdownPreview.tsx +++ b/mobile/src/files/MobileFileMarkdownPreview.tsx @@ -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} /> )} diff --git a/mobile/src/files/MobileFilePreviewBody.tsx b/mobile/src/files/MobileFilePreviewBody.tsx new file mode 100644 index 00000000000..75f08a329cd --- /dev/null +++ b/mobile/src/files/MobileFilePreviewBody.tsx @@ -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 ( + + + {preview.message} + + ) + } + if (preview.status === 'error' || preview.status === 'waiting') { + return ( + + {preview.message} + + Retry + + + ) + } + if (preview.status === 'empty') { + return options.editable ? ( + + ) : ( + + Empty file + + ) + } + if (preview.kind === 'image') { + return ( + + + + + + ) + } + if (preview.kind === 'markdown') { + return options.editable ? ( + + ) : ( + + ) + } + if (preview.kind === 'html') { + return options.editable ? ( + + ) : ( + + ) + } + if (options.editable) { + return + } + return ( + + ) +} + +function EditablePreviewSource(options: { + title: string + draftContent: string + saveError: string + lineColumn: MobileFilePreviewLineColumn | null + onDraftChange: (content: string) => void +}) { + return ( + + ) +} diff --git a/mobile/src/files/MobileFilePreviewEditableSource.tsx b/mobile/src/files/MobileFilePreviewEditableSource.tsx new file mode 100644 index 00000000000..38e1ce6ba64 --- /dev/null +++ b/mobile/src/files/MobileFilePreviewEditableSource.tsx @@ -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 ( + + {saveError ? {saveError} : null} + setSelection(null)} + accessibilityLabel={`${title} editor`} + /> + + ) +} diff --git a/mobile/src/files/MobileFilePreviewScreen.test.ts b/mobile/src/files/MobileFilePreviewScreen.test.ts new file mode 100644 index 00000000000..1595454a12a --- /dev/null +++ b/mobile/src/files/MobileFilePreviewScreen.test.ts @@ -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) + }) +}) diff --git a/mobile/src/files/MobileFilePreviewScreen.tsx b/mobile/src/files/MobileFilePreviewScreen.tsx index 13bd7054769..0879e02ec7e 100644 --- a/mobile/src/files/MobileFilePreviewScreen.tsx +++ b/mobile/src/files/MobileFilePreviewScreen.tsx @@ -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(() => 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(null) const { width, height } = useWindowDimensions() + const routePreviewSource = useMemo( + () => (previewParams ? previewSourceFromRoute(previewParams) : null), + [previewParams] + ) + const [previewSource, setPreviewSource] = useState( + 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 ( @@ -97,7 +241,7 @@ export function MobileFilePreviewScreen({ route }: Props) { [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} + {isEditableTerminalArtifact ? ( + void saveArtifact()} + disabled={!canSaveArtifact || saving} + accessibilityLabel="Save terminal artifact" + > + + + ) : null} - {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 - })} + + setPreview({ status: 'error', message: 'Unable to load preview', reconnect: false }) + } + onRetry={retry} + /> ) } -function renderPreviewBody( - preview: MobileFilePreviewResult, - options: { - relativePath: string - title: string - imageWidth: number - imageHeight: number - onImageError: () => void - onRetry: () => void - } -) { - if (preview.status === 'loading') { - return ( - - - {preview.message} - - ) - } - - if (preview.status === 'error' || preview.status === 'waiting') { - return ( - - {preview.message} - - Retry - - - ) - } - - if (preview.status === 'empty') { - return ( - - Empty file - - ) - } - - if (preview.kind === 'image') { - return ( - - - - - - ) - } - - if (preview.kind === 'markdown') { - return ( - - ) - } - - if (preview.kind === 'html') { - return ( - - ) - } - - return ( - - ) +function saveErrorMessageFromPreviewResult(result: MobileFilePreviewResult): string { + return result.status === 'error' || result.status === 'waiting' + ? result.message + : 'Unable to save file' } diff --git a/mobile/src/files/MobileFilePreviewSourceText.tsx b/mobile/src/files/MobileFilePreviewSourceText.tsx index 408870d01b8..01ca75d9c87 100644 --- a/mobile/src/files/MobileFilePreviewSourceText.tsx +++ b/mobile/src/files/MobileFilePreviewSourceText.tsx @@ -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(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 ( - + {truncated ? ( ) : null} diff --git a/mobile/src/files/mobile-file-preview-editability.ts b/mobile/src/files/mobile-file-preview-editability.ts new file mode 100644 index 00000000000..ff0fa18d3cb --- /dev/null +++ b/mobile/src/files/mobile-file-preview-editability.ts @@ -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 { + return preserveDirtyDraft && (result.status === 'error' || result.status === 'waiting') +} diff --git a/mobile/src/files/mobile-file-preview-line-column.test.ts b/mobile/src/files/mobile-file-preview-line-column.test.ts new file mode 100644 index 00000000000..460d8c8219f --- /dev/null +++ b/mobile/src/files/mobile-file-preview-line-column.test.ts @@ -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) + }) +}) diff --git a/mobile/src/files/mobile-file-preview-line-column.ts b/mobile/src/files/mobile-file-preview-line-column.ts new file mode 100644 index 00000000000..171d106a703 --- /dev/null +++ b/mobile/src/files/mobile-file-preview-line-column.ts @@ -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 +} diff --git a/mobile/src/files/mobile-file-preview-request.test.ts b/mobile/src/files/mobile-file-preview-request.test.ts index d2690111311..f7f77fabd19 100644 --- a/mobile/src/files/mobile-file-preview-request.test.ts +++ b/mobile/src/files/mobile-file-preview-request.test.ts @@ -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({ diff --git a/mobile/src/files/mobile-file-preview-request.ts b/mobile/src/files/mobile-file-preview-request.ts index d7bdc4d3b65..ff6cd755c50 100644 --- a/mobile/src/files/mobile-file-preview-request.ts +++ b/mobile/src/files/mobile-file-preview-request.ts @@ -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 +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 { - 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 { + 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 { + 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 } diff --git a/mobile/src/files/mobile-file-preview-response.ts b/mobile/src/files/mobile-file-preview-response.ts new file mode 100644 index 00000000000..0b7b1b23be8 --- /dev/null +++ b/mobile/src/files/mobile-file-preview-response.ts @@ -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' +} diff --git a/mobile/src/files/mobile-file-preview-route.test.ts b/mobile/src/files/mobile-file-preview-route.test.ts index b4cab941805..ac1888ed46f 100644 --- a/mobile/src/files/mobile-file-preview-route.test.ts +++ b/mobile/src/files/mobile-file-preview-route.test.ts @@ -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, diff --git a/mobile/src/files/mobile-file-preview-route.ts b/mobile/src/files/mobile-file-preview-route.ts index b758c1214cf..bbc3d0e8f88 100644 --- a/mobile/src/files/mobile-file-preview-route.ts +++ b/mobile/src/files/mobile-file-preview-route.ts @@ -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) } diff --git a/mobile/src/files/mobile-file-preview-source.test.ts b/mobile/src/files/mobile-file-preview-source.test.ts new file mode 100644 index 00000000000..b5fec782cfc --- /dev/null +++ b/mobile/src/files/mobile-file-preview-source.test.ts @@ -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'])) + }) +}) diff --git a/mobile/src/files/mobile-file-preview-source.ts b/mobile/src/files/mobile-file-preview-source.ts new file mode 100644 index 00000000000..b1b23757a6e --- /dev/null +++ b/mobile/src/files/mobile-file-preview-source.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]) +} diff --git a/mobile/src/files/mobile-file-preview-styles.ts b/mobile/src/files/mobile-file-preview-styles.ts index 0e7c2c5db22..5b0df61caa5 100644 --- a/mobile/src/files/mobile-file-preview-styles.ts +++ b/mobile/src/files/mobile-file-preview-styles.ts @@ -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 } }) diff --git a/mobile/src/files/mobile-terminal-artifact-grant-refresh.ts b/mobile/src/files/mobile-terminal-artifact-grant-refresh.ts new file mode 100644 index 00000000000..a8864119be4 --- /dev/null +++ b/mobile/src/files/mobile-terminal-artifact-grant-refresh.ts @@ -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 + +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 { + 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' + ) +} diff --git a/mobile/src/files/terminal-artifact-grant-error.ts b/mobile/src/files/terminal-artifact-grant-error.ts new file mode 100644 index 00000000000..25a33fb8402 --- /dev/null +++ b/mobile/src/files/terminal-artifact-grant-error.ts @@ -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') + ) +} diff --git a/mobile/src/session/mobile-image-attachment.test.ts b/mobile/src/session/mobile-image-attachment.test.ts index 3a94348aab4..1bf3df59e74 100644 --- a/mobile/src/session/mobile-image-attachment.test.ts +++ b/mobile/src/session/mobile-image-attachment.test.ts @@ -118,4 +118,30 @@ describe('attachMobileImageToTerminal', () => { const sendCall = client.calls.find((c) => c.method === 'terminal.send') expect(sendCall?.params).not.toHaveProperty('client') }) + + it('waits for pending live input before sending the image payload', async () => { + const client = clientWithResponses([ + { + id: 'start', + ok: false, + error: { code: 'method_not_found', message: 'no' }, + _meta: { runtimeId: 'r' } + }, + ok('save', '/tmp/pending.png') + ]) + const beforeTerminalSend = vi.fn(async () => false) + + const sent = await attachMobileImageToTerminal('library', { + client, + terminal: 'term-pending', + deviceToken: null, + getConnectionId: async () => null, + pickImage: vi.fn().mockResolvedValue({ base64: 'DDDD' }), + beforeTerminalSend + }) + + expect(sent).toBe(false) + expect(beforeTerminalSend).toHaveBeenCalledWith('term-pending') + expect(client.calls.some((call) => call.method === 'terminal.send')).toBe(false) + }) }) diff --git a/mobile/src/session/mobile-image-attachment.ts b/mobile/src/session/mobile-image-attachment.ts index 507b58eb671..b7d14d9a04d 100644 --- a/mobile/src/session/mobile-image-attachment.ts +++ b/mobile/src/session/mobile-image-attachment.ts @@ -16,6 +16,7 @@ export type AttachMobileImageDeps = { // start — lets the UI show a sending spinner only for the transfer, not the // (potentially long) time the picker is open. readonly onUploadStart?: () => void + readonly beforeTerminalSend?: (terminal: string) => Promise } // Uploads a picked image to the host and pastes the resulting file path into the @@ -30,7 +31,8 @@ export async function attachMobileImageToTerminal( deviceToken, getConnectionId, pickImage, - onUploadStart + onUploadStart, + beforeTerminalSend }: AttachMobileImageDeps ): Promise { const picked = await pickImage(source) @@ -45,6 +47,9 @@ export async function attachMobileImageToTerminal( // Why: a generated image path is terminal image injection, so it's always // bracketed (matching desktop paste) regardless of terminal mode. const payload = buildMobileImagePastePayload(imagePath) + if (beforeTerminalSend && !(await beforeTerminalSend(terminal))) { + return false + } await client.sendRequest('terminal.send', { terminal, text: payload, diff --git a/mobile/src/session/mobile-terminal-file-tap-open.test.ts b/mobile/src/session/mobile-terminal-file-tap-open.test.ts new file mode 100644 index 00000000000..fec1b5b663c --- /dev/null +++ b/mobile/src/session/mobile-terminal-file-tap-open.test.ts @@ -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() + }) +}) diff --git a/mobile/src/session/mobile-terminal-file-tap-open.ts b/mobile/src/session/mobile-terminal-file-tap-open.ts new file mode 100644 index 00000000000..8606467ee80 --- /dev/null +++ b/mobile/src/session/mobile-terminal-file-tap-open.ts @@ -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 = { + client: Pick + hostId: string + worktreeId: string + worktreeName?: string + terminalHandle?: string | null + pathText: string + cwd?: string | null + line: number | null + column: number | null + pushPreviewRoute: (href: ReturnType) => void + openBrowser: (url: string) => void + triggerOpenFeedback: () => void + fetchSessionTabs: () => Promise + 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( + options: OpenMobileTerminalFileTapOptions +): 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( + options: OpenMobileTerminalFileTapOptions +): Promise { + 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( + options: OpenMobileTerminalFileTapOptions, + openedPath: string +): void { + let activated = false + const activateOpenedTab = async (): Promise => { + 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) +} diff --git a/mobile/src/session/use-mobile-image-attachment.ts b/mobile/src/session/use-mobile-image-attachment.ts index 3894928f4f5..417dd8d699b 100644 --- a/mobile/src/session/use-mobile-image-attachment.ts +++ b/mobile/src/session/use-mobile-image-attachment.ts @@ -24,6 +24,7 @@ type UseMobileImageAttachmentArgs = { readonly showToast: ShowToast readonly onSuccess: () => void readonly onError: () => void + readonly beforeTerminalSend?: (terminal: string) => Promise } type MobileImageAttachment = { @@ -46,7 +47,8 @@ export function useMobileImageAttachment({ getActiveWorktreeConnectionId, showToast, onSuccess, - onError + onError, + beforeTerminalSend }: UseMobileImageAttachmentArgs): MobileImageAttachment { const [isAttaching, setIsAttaching] = useState(false) const attachImage = useCallback( @@ -61,7 +63,8 @@ export function useMobileImageAttachment({ deviceToken: deviceTokenRef.current, getConnectionId: getActiveWorktreeConnectionId, pickImage: pickMobileImage, - onUploadStart: () => setIsAttaching(true) + onUploadStart: () => setIsAttaching(true), + beforeTerminalSend }) // Cancelled picker: no error, no toast. if (sent) { @@ -88,6 +91,7 @@ export function useMobileImageAttachment({ }, [ activeHandle, + beforeTerminalSend, canSend, client, connState, diff --git a/mobile/src/session/use-mobile-terminal-paste.ts b/mobile/src/session/use-mobile-terminal-paste.ts new file mode 100644 index 00000000000..e3148be67f0 --- /dev/null +++ b/mobile/src/session/use-mobile-terminal-paste.ts @@ -0,0 +1,200 @@ +import { useCallback, type RefObject } from 'react' +import * as Clipboard from 'expo-clipboard' +import { File as FsFile, Paths } from 'expo-file-system' +import { ImageManipulator, SaveFormat } from 'expo-image-manipulator' +import type { TerminalModes } from '../terminal/TerminalWebView' +import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState } from '../transport/types' +import { + buildMobileImagePastePayload, + prepareMobileClipboardImageBase64, + saveMobileClipboardImageAsTempFile, + type MobileClipboardImageResizer +} from './mobile-clipboard-image' + +const CLIPBOARD_IMAGE_DATA_URL_PREFIX_RE = /^data:image\/[a-z0-9.+-]+;base64,/i + +// Why: clipboard images are re-encoded as lossless PNG, so high-res screenshots and +// photos can exceed the upload byte budget; resize the raster down to fit before upload. +// The iOS ImageManipulator loader cannot decode large base64 data URIs, so use a file. +const resizeMobileClipboardImage: MobileClipboardImageResizer = async (source, target) => { + const base64 = source.replace(CLIPBOARD_IMAGE_DATA_URL_PREFIX_RE, '') + const file = new FsFile(Paths.cache, `orca-clip-resize-${Date.now()}.png`) + let context: ReturnType | null = null + let rendered: Awaited< + ReturnType['renderAsync']> + > | null = null + let resultUri: string | null = null + try { + file.create({ overwrite: true }) + file.write(base64, { encoding: 'base64' }) + context = ImageManipulator.manipulate(file.uri) + context.resize({ width: target.width, height: target.height }) + rendered = await context.renderAsync() + const result = await rendered.saveAsync({ format: SaveFormat.PNG, base64: true }) + resultUri = result.uri + // Why: empty base64 would pass the downstream base64 check and upload a corrupt + // image, so fail loudly here instead of silently sending an invalid payload. + if (!result.base64) { + throw new Error('Failed to encode resized clipboard image') + } + return { data: result.base64, width: result.width, height: result.height } + } finally { + rendered?.release() + context?.release() + if (resultUri) { + try { + new FsFile(resultUri).delete() + } catch { + // Best-effort cleanup; ImageManipulator saves into cache for every retry. + } + } + try { + file.delete() + } catch { + // Best-effort cleanup; the OS reclaims the cache directory regardless. + } + } +} + +function buildMobileTerminalClipboardTextPayload( + text: string, + modes: TerminalModes | undefined +): string { + const wrap = modes?.bracketedPasteMode === true && !modes.altScreen + // Why: strip embedded bracketed-paste markers so copied text cannot terminate + // paste mode early and turn trailing bytes into shell commands. + // eslint-disable-next-line no-control-regex -- intentional bracketed-paste marker stripping + const sanitized = wrap ? text.replace(/\x1b\[20[01]~/g, '') : text + return wrap ? `\x1b[200~${sanitized}\x1b[201~` : sanitized +} + +type UseMobileTerminalPasteOptions = { + readonly activeHandle: string | null + readonly activeHandleRef: RefObject + readonly activeSessionTabTypeRef: RefObject + readonly canSend: boolean + readonly client: RpcClient | null + readonly clientRef: RefObject + readonly connState: ConnectionState + readonly connStateRef: RefObject + readonly deviceTokenRef: RefObject + readonly flushPendingLiveInputBeforeExternalSend: (handle: string) => Promise + readonly getActiveWorktreeConnectionId: () => Promise + readonly onError: () => void + readonly onSuccess: () => void + readonly ptyModesRef: RefObject> + readonly refreshCanPaste: () => void + readonly showToast: (message: string, durationMs?: number) => void +} + +export function useMobileTerminalPaste({ + activeHandle, + activeHandleRef, + activeSessionTabTypeRef, + canSend, + client, + clientRef, + connState, + connStateRef, + deviceTokenRef, + flushPendingLiveInputBeforeExternalSend, + getActiveWorktreeConnectionId, + onError, + onSuccess, + ptyModesRef, + refreshCanPaste, + showToast +}: UseMobileTerminalPasteOptions): () => Promise { + return useCallback(async () => { + if (!client || !activeHandle || !canSend) { + return + } + const targetHandle = activeHandle + try { + const text = await Clipboard.getStringAsync() + let payload: string | null = null + if (text.length > 0) { + payload = buildMobileTerminalClipboardTextPayload( + text, + ptyModesRef.current.get(targetHandle) + ) + } else { + const image = await Clipboard.getImageAsync({ format: 'png' }) + if (!image) { + refreshCanPaste() + return + } + const connectionId = await getActiveWorktreeConnectionId() + const base64 = await prepareMobileClipboardImageBase64(image, resizeMobileClipboardImage) + const imagePath = await saveMobileClipboardImageAsTempFile(client, base64, { + connectionId + }) + payload = buildMobileImagePastePayload(imagePath) + } + + const wrappedBytes = new TextEncoder().encode(payload).byteLength + if (wrappedBytes > 256 * 1024) { + onError() + // eslint-disable-next-line no-console + console.warn('[mobile-clip] paste oversized', { wrappedBytes }) + showToast('Paste too large (max 256 KiB)', 1500) + return + } + // Why: paste lives in the accessory row and must not overtake pending IME text. + const flushedPendingInput = await flushPendingLiveInputBeforeExternalSend(targetHandle) + if (!flushedPendingInput) { + return + } + const currentClient = clientRef.current + if ( + !currentClient || + connStateRef.current !== 'connected' || + targetHandle !== activeHandleRef.current || + activeSessionTabTypeRef.current !== 'terminal' + ) { + return + } + await currentClient.sendRequest('terminal.send', { + terminal: targetHandle, + text: payload, + enter: false, + ...(deviceTokenRef.current + ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } + : {}) + }) + onSuccess() + refreshCanPaste() + } catch (e) { + onError() + const err = e as { name?: string; message?: string } + const isDisconnected = connState !== 'connected' + // eslint-disable-next-line no-console + console.warn('[mobile-clip] paste failed', { name: err.name, message: err.message }) + if (isDisconnected) { + showToast('Paste failed (disconnected)', 1500) + } else if (err.message === 'Clipboard image is too large') { + showToast('Image too large to paste', 1500) + } else { + showToast('Paste failed', 1500) + } + } + }, [ + activeHandle, + activeHandleRef, + activeSessionTabTypeRef, + canSend, + client, + clientRef, + connState, + connStateRef, + deviceTokenRef, + flushPendingLiveInputBeforeExternalSend, + getActiveWorktreeConnectionId, + onError, + onSuccess, + ptyModesRef, + refreshCanPaste, + showToast + ]) +} diff --git a/mobile/src/session/use-terminal-live-input-mode-preference.test.ts b/mobile/src/session/use-terminal-live-input-mode-preference.test.ts new file mode 100644 index 00000000000..d95fab23698 --- /dev/null +++ b/mobile/src/session/use-terminal-live-input-mode-preference.test.ts @@ -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 + readonly unmount: () => void +} + +type Deferred = { + readonly promise: Promise + readonly resolve: (value: T) => void +} + +function createDeferred(): Deferred { + let resolve: ((value: T) => void) | null = null + const promise = new Promise((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 | 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() + 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() + 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() + }) +}) diff --git a/mobile/src/session/use-terminal-live-input-mode-preference.ts b/mobile/src/session/use-terminal-live-input-mode-preference.ts new file mode 100644 index 00000000000..dd59449b816 --- /dev/null +++ b/mobile/src/session/use-terminal-live-input-mode-preference.ts @@ -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>( + () => new Set() + ) + const liveInputTerminalHandlesRef = useRef>(new Set()) + const defaultedLiveInputTerminalHandlesRef = useRef>(new Set()) + const disabledLiveInputTerminalHandlesRef = useRef>(new Set()) + const disabledLiveInputHydratedRef = useRef(false) + const pendingDisabledLiveInputHydrationEditsRef = useRef>(new Map()) + const pendingLiveInputDefaultHandlesRef = useRef>(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) => { + 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 + } +} diff --git a/mobile/src/storage/preferences.test.ts b/mobile/src/storage/preferences.test.ts index c0f8596900a..1e5aee95de1 100644 --- a/mobile/src/storage/preferences.test.ts +++ b/mobile/src/storage/preferences.test.ts @@ -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() diff --git a/mobile/src/storage/preferences.ts b/mobile/src/storage/preferences.ts index 5151397a7f0..d5b0db3a6e7 100644 --- a/mobile/src/storage/preferences.ts +++ b/mobile/src/storage/preferences.ts @@ -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 + 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 { + 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> { + const preference = await readDisabledTerminalLiveInputHandlesPreference(hostId, worktreeId) + return preference.handles +} + +export async function saveDisabledTerminalLiveInputHandles( + hostId: string, + worktreeId: string, + handles: ReadonlySet +): Promise { + 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 diff --git a/mobile/src/terminal/terminal-file-url-tap.ts b/mobile/src/terminal/terminal-file-url-tap.ts new file mode 100644 index 00000000000..b27260b61cb --- /dev/null +++ b/mobile/src/terminal/terminal-file-url-tap.ts @@ -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 } +} diff --git a/mobile/src/terminal/terminal-live-accessory-input.ts b/mobile/src/terminal/terminal-live-accessory-input.ts new file mode 100644 index 00000000000..5f483ebc0cf --- /dev/null +++ b/mobile/src/terminal/terminal-live-accessory-input.ts @@ -0,0 +1,21 @@ +import type { TerminalLiveAccessoryLocalEdit } from './terminal-live-text-commit' + +export type TerminalLiveAccessoryInput = { + readonly bytes: string + readonly localEdit?: TerminalLiveAccessoryLocalEdit +} + +type TerminalLiveAccessoryKey = { + readonly bytes: string + readonly id: string +} + +export function createTerminalLiveAccessoryInput( + key: TerminalLiveAccessoryKey +): TerminalLiveAccessoryInput { + if (key.id === 'backspace' || key.id === 'delete') { + return { bytes: key.bytes, localEdit: key.id } + } + + return { bytes: key.bytes } +} diff --git a/mobile/src/terminal/terminal-live-accessory-raw-send-target.test.ts b/mobile/src/terminal/terminal-live-accessory-raw-send-target.test.ts new file mode 100644 index 00000000000..5e484c354bc --- /dev/null +++ b/mobile/src/terminal/terminal-live-accessory-raw-send-target.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { getTerminalLiveAccessoryRawSendTarget } from './terminal-live-accessory-raw-send-target' + +describe('terminal live accessory raw send target', () => { + it('Given the original terminal is still active When raw fallback resumes Then returns that handle', () => { + // Given + const targetHandle = 'terminal-a' + + // When + const sendTarget = getTerminalLiveAccessoryRawSendTarget({ + targetHandle, + activeHandle: targetHandle, + activeSessionTabType: 'terminal' + }) + + // Then + expect(sendTarget).toBe(targetHandle) + }) + + it('Given the active terminal changed while waiting When raw fallback resumes Then suppresses the send', () => { + // Given + const targetHandle = 'terminal-a' + + // When + const sendTarget = getTerminalLiveAccessoryRawSendTarget({ + targetHandle, + activeHandle: 'terminal-b', + activeSessionTabType: 'terminal' + }) + + // Then + expect(sendTarget).toBeNull() + }) + + it('Given the target is not an active terminal tab When raw fallback resumes Then suppresses the send', () => { + // Given + const targetHandle = 'terminal-a' + + // When + const inactiveTabTarget = getTerminalLiveAccessoryRawSendTarget({ + targetHandle, + activeHandle: targetHandle, + activeSessionTabType: 'browser' + }) + + // Then + expect(inactiveTabTarget).toBeNull() + }) +}) diff --git a/mobile/src/terminal/terminal-live-accessory-raw-send-target.ts b/mobile/src/terminal/terminal-live-accessory-raw-send-target.ts new file mode 100644 index 00000000000..f87138d210e --- /dev/null +++ b/mobile/src/terminal/terminal-live-accessory-raw-send-target.ts @@ -0,0 +1,17 @@ +type TerminalLiveAccessoryRawSendTargetInput = { + readonly targetHandle: string + readonly activeHandle: string | null + readonly activeSessionTabType: TTabType | null +} + +export function getTerminalLiveAccessoryRawSendTarget({ + targetHandle, + activeHandle, + activeSessionTabType +}: TerminalLiveAccessoryRawSendTargetInput): string | null { + if (targetHandle !== activeHandle || activeSessionTabType !== 'terminal') { + return null + } + + return targetHandle +} diff --git a/mobile/src/terminal/terminal-live-control-send-order.test.ts b/mobile/src/terminal/terminal-live-control-send-order.test.ts new file mode 100644 index 00000000000..8f1797c7ca0 --- /dev/null +++ b/mobile/src/terminal/terminal-live-control-send-order.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest' +import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order' + +describe('terminal live control send order', () => { + it('Given a failed pending text flush When control bytes follow Then skips the control bytes', async () => { + // Given + const events: string[] = [] + const flushPendingText = vi.fn(async () => { + events.push('flush') + return false + }) + const sendControlBytes = vi.fn(async () => { + events.push('control') + return true + }) + + // When + const result = await sendTerminalLiveControlAfterPendingFlush( + flushPendingText, + sendControlBytes + ) + + // Then + expect(result).toBe(false) + expect(sendControlBytes).not.toHaveBeenCalled() + expect(events).toEqual(['flush']) + }) + + it('Given a successful pending text flush When control bytes follow Then sends them afterward', async () => { + // Given + const events: string[] = [] + const flushPendingText = vi.fn(async () => { + events.push('flush') + return true + }) + const sendControlBytes = vi.fn(async () => { + events.push('control') + return true + }) + + // When + const result = await sendTerminalLiveControlAfterPendingFlush( + flushPendingText, + sendControlBytes + ) + + // Then + expect(result).toBe(true) + expect(events).toEqual(['flush', 'control']) + }) + + it('Given a failed control byte send When pending text flushed Then reports failure', async () => { + // Given + const events: string[] = [] + const flushPendingText = vi.fn(async () => { + events.push('flush') + return true + }) + const sendControlBytes = vi.fn(async () => { + events.push('control') + return false + }) + + // When + const result = await sendTerminalLiveControlAfterPendingFlush( + flushPendingText, + sendControlBytes + ) + + // Then + expect(result).toBe(false) + expect(events).toEqual(['flush', 'control']) + }) +}) diff --git a/mobile/src/terminal/terminal-live-control-send-order.ts b/mobile/src/terminal/terminal-live-control-send-order.ts new file mode 100644 index 00000000000..185b49ee48e --- /dev/null +++ b/mobile/src/terminal/terminal-live-control-send-order.ts @@ -0,0 +1,12 @@ +export type TerminalLiveAsyncSendStep = () => Promise + +export async function sendTerminalLiveControlAfterPendingFlush( + flushPendingText: TerminalLiveAsyncSendStep, + sendControlBytes: TerminalLiveAsyncSendStep +): Promise { + const flushed = await flushPendingText() + if (!flushed) { + return false + } + return sendControlBytes() +} diff --git a/mobile/src/terminal/terminal-live-input-sender.ts b/mobile/src/terminal/terminal-live-input-sender.ts new file mode 100644 index 00000000000..c3538d1f723 --- /dev/null +++ b/mobile/src/terminal/terminal-live-input-sender.ts @@ -0,0 +1 @@ +export type TerminalLiveInputSender = (handle: string, bytes: string) => Promise diff --git a/mobile/src/terminal/terminal-live-input.test.ts b/mobile/src/terminal/terminal-live-input.test.ts index db3e96236bc..95b4960f928 100644 --- a/mobile/src/terminal/terminal-live-input.test.ts +++ b/mobile/src/terminal/terminal-live-input.test.ts @@ -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']), diff --git a/mobile/src/terminal/terminal-live-input.ts b/mobile/src/terminal/terminal-live-input.ts index dd5f8e6a709..96f279a4855 100644 --- a/mobile/src/terminal/terminal-live-input.ts +++ b/mobile/src/terminal/terminal-live-input.ts @@ -118,6 +118,48 @@ export function defaultTerminalLiveInputHandles( } } +export function filterTerminalLiveInputDefaultCandidates( + terminalHandles: readonly string[], + disabledHandles: ReadonlySet +): string[] { + return terminalHandles.filter((handle) => !disabledHandles.has(handle)) +} + +export function applyDisabledTerminalLiveInputHandles( + enabledHandles: ReadonlySet, + defaultedHandles: ReadonlySet, + disabledHandles: ReadonlySet +): TerminalLiveInputDefaultResult { + let nextEnabledHandles: Set | null = null + let nextDefaultedHandles: Set | 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, defaultedHandles: ReadonlySet, diff --git a/mobile/src/terminal/terminal-live-pending-flush-state.test.ts b/mobile/src/terminal/terminal-live-pending-flush-state.test.ts new file mode 100644 index 00000000000..27dd4147d09 --- /dev/null +++ b/mobile/src/terminal/terminal-live-pending-flush-state.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest' +import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order' +import { + queueTerminalLivePendingFlush, + waitForTerminalLivePendingFlush, + type TerminalLivePendingFlushState +} from './terminal-live-pending-flush-state' + +describe('terminal live pending flush state', () => { + it('Given no in-flight flush When waiting for the barrier Then allows control input', async () => { + // Given + const state: TerminalLivePendingFlushState = { current: null } + + // When / Then + await expect(waitForTerminalLivePendingFlush(state)).resolves.toBe(true) + }) + + it('Given an in-flight flush When control input waits Then control is held until flush succeeds', async () => { + // Given + const events: string[] = [] + let resolveFlush: (value: boolean) => void = () => {} + const flushPromise = new Promise((resolve) => { + resolveFlush = resolve + }) + const state: TerminalLivePendingFlushState = { current: flushPromise } + + // When + const controlSend = sendTerminalLiveControlAfterPendingFlush( + () => waitForTerminalLivePendingFlush(state), + async () => { + events.push('control') + return true + } + ) + await Promise.resolve() + + // Then + expect(events).toEqual([]) + resolveFlush(true) + await expect(controlSend).resolves.toBe(true) + expect(events).toEqual(['control']) + }) + + it('Given an in-flight flush fails When control input waits Then control is skipped', async () => { + // Given + const events: string[] = [] + let resolveFlush: (value: boolean) => void = () => {} + const flushPromise = new Promise((resolve) => { + resolveFlush = resolve + }) + const state: TerminalLivePendingFlushState = { current: flushPromise } + + // When + const controlSend = sendTerminalLiveControlAfterPendingFlush( + () => waitForTerminalLivePendingFlush(state), + async () => { + events.push('control') + return true + } + ) + resolveFlush(false) + + // Then + await expect(controlSend).resolves.toBe(false) + expect(events).toEqual([]) + }) + + it('Given a queued pending flush When it resolves or rejects Then clears the barrier', async () => { + // Given + const resolvedState: TerminalLivePendingFlushState = { current: null } + const rejectedState: TerminalLivePendingFlushState = { current: null } + + // When + await expect(queueTerminalLivePendingFlush(resolvedState, async () => true)).resolves.toBe(true) + await expect( + queueTerminalLivePendingFlush(rejectedState, async () => { + throw new Error('send failed') + }) + ).resolves.toBe(false) + await Promise.resolve() + + // Then + expect(resolvedState.current).toBeNull() + expect(rejectedState.current).toBeNull() + }) + + it('Given a current pending snapshot while another flush is in flight When queued Then sends current text after prior success', async () => { + // Given + const events: string[] = [] + let resolveFirstFlush: (value: boolean) => void = () => {} + const firstFlush = new Promise((resolve) => { + resolveFirstFlush = resolve + }) + const state: TerminalLivePendingFlushState = { current: firstFlush } + + // When + const secondFlush = queueTerminalLivePendingFlush(state, async () => { + events.push('second-flush') + return true + }) + const controlSend = sendTerminalLiveControlAfterPendingFlush( + () => waitForTerminalLivePendingFlush(state), + async () => { + events.push('control') + return true + } + ) + await Promise.resolve() + + // Then + expect(events).toEqual([]) + resolveFirstFlush(true) + await expect(secondFlush).resolves.toBe(true) + await expect(controlSend).resolves.toBe(true) + expect(events).toEqual(['second-flush', 'control']) + }) + + it('Given a prior pending flush fails When another snapshot is queued Then skips the current text and control', async () => { + // Given + const events: string[] = [] + let resolveFirstFlush: (value: boolean) => void = () => {} + const firstFlush = new Promise((resolve) => { + resolveFirstFlush = resolve + }) + const state: TerminalLivePendingFlushState = { current: firstFlush } + + // When + const secondFlush = queueTerminalLivePendingFlush(state, async () => { + events.push('second-flush') + return true + }) + const controlSend = sendTerminalLiveControlAfterPendingFlush( + () => waitForTerminalLivePendingFlush(state), + async () => { + events.push('control') + return true + } + ) + resolveFirstFlush(false) + + // Then + await expect(secondFlush).resolves.toBe(false) + await expect(controlSend).resolves.toBe(false) + expect(events).toEqual([]) + }) +}) diff --git a/mobile/src/terminal/terminal-live-pending-flush-state.ts b/mobile/src/terminal/terminal-live-pending-flush-state.ts new file mode 100644 index 00000000000..ea760fcee54 --- /dev/null +++ b/mobile/src/terminal/terminal-live-pending-flush-state.ts @@ -0,0 +1,29 @@ +export type TerminalLivePendingFlushState = { + current: Promise | null +} + +export function waitForTerminalLivePendingFlush( + state: TerminalLivePendingFlushState +): Promise { + return state.current ?? Promise.resolve(true) +} + +export function queueTerminalLivePendingFlush( + state: TerminalLivePendingFlushState, + sendPendingText: () => Promise +): Promise { + const previousFlush = state.current + const flushPromise = (async () => { + if (previousFlush && !(await previousFlush)) { + return false + } + return sendPendingText() + })().catch(() => false) + state.current = flushPromise + void flushPromise.then(() => { + if (state.current === flushPromise) { + state.current = null + } + }) + return flushPromise +} diff --git a/mobile/src/terminal/terminal-live-text-commit.test.ts b/mobile/src/terminal/terminal-live-text-commit.test.ts new file mode 100644 index 00000000000..00435fb2230 --- /dev/null +++ b/mobile/src/terminal/terminal-live-text-commit.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, it } from 'vitest' +import { + TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS, + getTerminalLiveAccessoryBytesDecision, + getTerminalLiveAccessoryLocalEditText, + getTerminalLiveDeferredTextDelayMs, + getTerminalLiveSpecialKeyDecision, + getTerminalLiveSubmitSequence, + getTerminalLiveTextChangeDecision, + isTerminalLiveTextHangulCandidate, + isTerminalLiveTextImeCandidate +} from './terminal-live-text-commit' + +describe('terminal live text commit', () => { + it('Given Korean IME changes When live text changes Then defers candidates and submits only final text before carriage return', () => { + // Given + const koreanCompositionSteps = ['ㅎ', '하', '한'] as const + + // When + const decisions = koreanCompositionSteps.map(getTerminalLiveTextChangeDecision) + const sentImmediately = decisions.filter((decision) => decision.kind === 'send-now') + const submitSequence = getTerminalLiveSubmitSequence('한') + const composedWordDecision = getTerminalLiveTextChangeDecision('한글') + const composedWordSubmitSequence = getTerminalLiveSubmitSequence('한글') + + // Then + expect(decisions).toEqual([ + { kind: 'defer', text: 'ㅎ', delayMs: null }, + { kind: 'defer', text: '하', delayMs: null }, + { kind: 'defer', text: '한', delayMs: null } + ]) + expect(sentImmediately).toEqual([]) + expect(isTerminalLiveTextHangulCandidate('ㅎ')).toBe(true) + expect(getTerminalLiveDeferredTextDelayMs('ㅎ')).toBeNull() + expect(submitSequence).toEqual(['한', '\r']) + expect(composedWordDecision).toEqual({ + kind: 'defer', + text: '한글', + delayMs: null + }) + expect(composedWordSubmitSequence).toEqual(['한글', '\r']) + }) + + it('Given non-Hangul IME text When live text changes Then keeps the bounded settle timer', () => { + // Given + const text = 'あ' + + // When + const decision = getTerminalLiveTextChangeDecision(text) + + // Then + expect(isTerminalLiveTextImeCandidate(text)).toBe(true) + expect(isTerminalLiveTextHangulCandidate(text)).toBe(false) + expect(getTerminalLiveDeferredTextDelayMs(text)).toBe(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS) + expect(decision).toEqual({ kind: 'defer', text, delayMs: TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS }) + }) + + it('Given Chinese and Vietnamese IME text When live text changes Then does not use Hangul-only indefinite deferral', () => { + // Given + const nonHangulImeTexts = ['你好', 'tiếng Việt'] as const + + for (const text of nonHangulImeTexts) { + // When + const decision = getTerminalLiveTextChangeDecision(text) + + // Then + expect(isTerminalLiveTextImeCandidate(text)).toBe(true) + expect(isTerminalLiveTextHangulCandidate(text)).toBe(false) + expect(getTerminalLiveDeferredTextDelayMs(text)).toBe(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS) + expect(decision).toEqual({ + kind: 'defer', + text, + delayMs: TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS + }) + } + }) + + it('Given ASCII text When live text changes Then sends immediately', () => { + // Given + const text = 'abc123' + + // When + const decision = getTerminalLiveTextChangeDecision(text) + + // Then + expect(isTerminalLiveTextImeCandidate(text)).toBe(false) + expect(decision).toEqual({ kind: 'send-now', text }) + }) + + it('Given empty text When live text changes Then ignores the change', () => { + // Given + const text = '' + + // When + const decision = getTerminalLiveTextChangeDecision(text) + + // Then + expect(isTerminalLiveTextImeCandidate(text)).toBe(false) + expect(decision).toEqual({ kind: 'ignore' }) + }) + + it('Given pending text When Backspace or Delete is pressed Then keeps edits local', () => { + // Given + const pendingText = '한' + + // When + const backspaceDecision = getTerminalLiveSpecialKeyDecision({ key: 'Backspace', pendingText }) + const deleteDecision = getTerminalLiveSpecialKeyDecision({ key: 'Delete', pendingText }) + + // Then + expect(backspaceDecision).toEqual({ kind: 'local-edit' }) + expect(deleteDecision).toEqual({ kind: 'local-edit' }) + }) + + it('Given no pending text When Backspace or Delete is pressed Then sends terminal bytes', () => { + // Given + const pendingText = '' + + // When + const backspaceDecision = getTerminalLiveSpecialKeyDecision({ key: 'Backspace', pendingText }) + const deleteDecision = getTerminalLiveSpecialKeyDecision({ key: 'Delete', pendingText }) + + // Then + expect(backspaceDecision).toEqual({ kind: 'send-now', bytes: '\x7f' }) + expect(deleteDecision).toEqual({ kind: 'send-now', bytes: '\x1b[3~' }) + }) + + it('Given pending text When a terminal special key is pressed Then flushes pending text before bytes', () => { + // Given + const pendingText = '한' + + // When + const decision = getTerminalLiveSpecialKeyDecision({ key: 'Tab', pendingText }) + + // Then + expect(decision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\t' }) + }) + + it('Given pending text When accessory control bytes are requested Then flushes pending text before bytes', () => { + // Given + const pendingText = '한글' + + // When + const tabDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\t', pendingText }) + const escapeDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\x1b', pendingText }) + const enterDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\r', pendingText }) + + // Then + expect(tabDecision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\t' }) + expect(escapeDecision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\x1b' }) + expect(enterDecision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\r' }) + }) + + it('Given pending text When accessory Backspace or Delete bytes are requested Then keeps edits local', () => { + // Given + const pendingText = '한글' + + // When + const backspaceDecision = getTerminalLiveAccessoryBytesDecision({ + bytes: '\x7f', + localEdit: 'backspace', + pendingText + }) + const ctrlBackspaceDecision = getTerminalLiveAccessoryBytesDecision({ + bytes: '\b', + localEdit: 'backspace', + pendingText + }) + const deleteDecision = getTerminalLiveAccessoryBytesDecision({ + bytes: '\x1b[3~', + localEdit: 'delete', + pendingText + }) + const customDeleteByteDecision = getTerminalLiveAccessoryBytesDecision({ + bytes: '\x7f', + pendingText + }) + const backspaceText = getTerminalLiveAccessoryLocalEditText({ + localEdit: 'backspace', + pendingText + }) + const deleteText = getTerminalLiveAccessoryLocalEditText({ + localEdit: 'delete', + pendingText + }) + + // Then + expect(backspaceDecision).toEqual({ kind: 'local-edit', localEdit: 'backspace' }) + expect(ctrlBackspaceDecision).toEqual({ kind: 'local-edit', localEdit: 'backspace' }) + expect(deleteDecision).toEqual({ kind: 'local-edit', localEdit: 'delete' }) + expect(customDeleteByteDecision).toEqual({ + kind: 'flush-then-send', + pendingText, + bytes: '\x7f' + }) + expect(backspaceText).toBe('한') + expect(deleteText).toBe('한글') + expect(getTerminalLiveDeferredTextDelayMs(backspaceText)).toBeNull() + expect(getTerminalLiveDeferredTextDelayMs(deleteText)).toBeNull() + }) + + it('Given no pending text When accessory bytes are requested Then sends terminal bytes', () => { + // Given + const pendingText = '' + + // When + const tabDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\t', pendingText }) + + // Then + expect(tabDecision).toEqual({ kind: 'send-now', bytes: '\t' }) + }) + + it('Given a non-special key When key decision is requested Then ignores it', () => { + // Given + const key = 'a' + + // When + const decision = getTerminalLiveSpecialKeyDecision({ key, pendingText: '한' }) + + // Then + expect(decision).toEqual({ kind: 'ignore' }) + }) + + it('Given no pending text When submit is requested Then sends only carriage return', () => { + // Given + const pendingText = '' + + // When + const sequence = getTerminalLiveSubmitSequence(pendingText) + + // Then + expect(sequence).toEqual(['\r']) + }) +}) diff --git a/mobile/src/terminal/terminal-live-text-commit.ts b/mobile/src/terminal/terminal-live-text-commit.ts new file mode 100644 index 00000000000..aac74b8931b --- /dev/null +++ b/mobile/src/terminal/terminal-live-text-commit.ts @@ -0,0 +1,145 @@ +import { getTerminalLiveSpecialKeyBytes } from './terminal-live-input' + +// Why: React Native does not expose portable composition events here, so +// non-Hangul IME text gets a short settle window before being sent to the PTY. +export const TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS = 150 + +export type TerminalLiveTextChangeDecision = + | { readonly kind: 'ignore' } + | { readonly kind: 'send-now'; readonly text: string } + | { readonly kind: 'defer'; readonly text: string; readonly delayMs: number | null } + +export type TerminalLiveSpecialKeyDecision = + | { readonly kind: 'ignore' } + | { readonly kind: 'local-edit' } + | { readonly kind: 'send-now'; readonly bytes: string } + | { readonly kind: 'flush-then-send'; readonly pendingText: string; readonly bytes: string } + +export type TerminalLiveSpecialKeyDecisionInput = { + readonly key: string + readonly pendingText: string +} + +export type TerminalLiveAccessoryLocalEdit = 'backspace' | 'delete' + +export type TerminalLiveAccessoryBytesDecision = + | { readonly kind: 'local-edit'; readonly localEdit: TerminalLiveAccessoryLocalEdit } + | { readonly kind: 'send-now'; readonly bytes: string } + | { readonly kind: 'flush-then-send'; readonly pendingText: string; readonly bytes: string } + +export type TerminalLiveAccessoryBytesDecisionInput = { + readonly bytes: string + readonly localEdit?: TerminalLiveAccessoryLocalEdit + readonly pendingText: string +} + +export function isTerminalLiveTextImeCandidate(text: string): boolean { + for (const character of text) { + const codePoint = character.codePointAt(0) + if (codePoint !== undefined && codePoint > 0x7f) { + return true + } + } + return false +} + +function isHangulCodePoint(codePoint: number): boolean { + return ( + (codePoint >= 0x1100 && codePoint <= 0x11ff) || + (codePoint >= 0x3130 && codePoint <= 0x318f) || + (codePoint >= 0xa960 && codePoint <= 0xa97f) || + (codePoint >= 0xac00 && codePoint <= 0xd7af) + ) +} + +export function isTerminalLiveTextHangulCandidate(text: string): boolean { + for (const character of text) { + const codePoint = character.codePointAt(0) + if (codePoint !== undefined && isHangulCodePoint(codePoint)) { + return true + } + } + return false +} + +export function getTerminalLiveTextChangeDecision(text: string): TerminalLiveTextChangeDecision { + if (text.length === 0) { + return { kind: 'ignore' } + } + + if (isTerminalLiveTextHangulCandidate(text)) { + return { kind: 'defer', text, delayMs: null } + } + + if (isTerminalLiveTextImeCandidate(text)) { + return { kind: 'defer', text, delayMs: TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS } + } + + return { kind: 'send-now', text } +} + +export function getTerminalLiveDeferredTextDelayMs(text: string): number | null { + return isTerminalLiveTextHangulCandidate(text) ? null : TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS +} + +export function getTerminalLiveSpecialKeyDecision({ + key, + pendingText +}: TerminalLiveSpecialKeyDecisionInput): TerminalLiveSpecialKeyDecision { + const bytes = getTerminalLiveSpecialKeyBytes(key) + if (bytes === null) { + return { kind: 'ignore' } + } + + if (pendingText.length > 0 && (key === 'Backspace' || key === 'Delete')) { + return { kind: 'local-edit' } + } + + if (pendingText.length > 0) { + return { kind: 'flush-then-send', pendingText, bytes } + } + + return { kind: 'send-now', bytes } +} + +export function getTerminalLiveAccessoryBytesDecision({ + bytes, + localEdit, + pendingText +}: TerminalLiveAccessoryBytesDecisionInput): TerminalLiveAccessoryBytesDecision { + if (pendingText.length > 0 && localEdit) { + return { kind: 'local-edit', localEdit } + } + + if (pendingText.length > 0) { + return { kind: 'flush-then-send', pendingText, bytes } + } + + return { kind: 'send-now', bytes } +} + +export function getTerminalLiveAccessoryLocalEditText({ + localEdit, + pendingText +}: { + readonly localEdit: TerminalLiveAccessoryLocalEdit + readonly pendingText: string +}): string { + if (localEdit === 'delete') { + // Why: accessory Delete mirrors forward-delete at the hidden input's end; + // it stays local but does not remove the pending IME text. + return pendingText + } + + return Array.from(pendingText).slice(0, -1).join('') +} + +export type TerminalLiveSubmitSequence = readonly ['\r'] | readonly [string, '\r'] + +export function getTerminalLiveSubmitSequence(pendingText: string): TerminalLiveSubmitSequence { + if (pendingText.length === 0) { + return ['\r'] + } + + return [pendingText, '\r'] +} diff --git a/mobile/src/terminal/terminal-path-tap-injected.ts b/mobile/src/terminal/terminal-path-tap-injected.ts index 4ce3deb36f1..766c381ea22 100644 --- a/mobile/src/terminal/terminal-path-tap-injected.ts +++ b/mobile/src/terminal/terminal-path-tap-injected.ts @@ -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; } diff --git a/mobile/src/terminal/terminal-path-tap.test.ts b/mobile/src/terminal/terminal-path-tap.test.ts index e31971b6ec6..ebb8e0b9ec3 100644 --- a/mobile/src/terminal/terminal-path-tap.test.ts +++ b/mobile/src/terminal/terminal-path-tap.test.ts @@ -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 +} diff --git a/mobile/src/terminal/terminal-path-tap.ts b/mobile/src/terminal/terminal-path-tap.ts index 7c9d295158b..ac2a8ea0ff3 100644 --- a/mobile/src/terminal/terminal-path-tap.ts +++ b/mobile/src/terminal/terminal-path-tap.ts @@ -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) diff --git a/mobile/src/terminal/terminal-send-rpc-response.test.ts b/mobile/src/terminal/terminal-send-rpc-response.test.ts new file mode 100644 index 00000000000..5ef459a6fde --- /dev/null +++ b/mobile/src/terminal/terminal-send-rpc-response.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import type { RpcResponse } from '../transport/types' +import { isTerminalSendRpcAccepted } from './terminal-send-rpc-response' + +const runtimeMeta = { runtimeId: 'test-runtime' } as const + +describe('terminal send RPC response', () => { + it('Given accepted terminal send response When checked Then reports success', () => { + // Given + const response: RpcResponse = { + id: '1', + ok: true, + result: { send: { handle: 'terminal-1', accepted: true, bytesWritten: 1 } }, + _meta: runtimeMeta + } + + // When / Then + expect(isTerminalSendRpcAccepted(response)).toBe(true) + }) + + it('Given rejected terminal send response When checked Then reports failure', () => { + // Given + const response: RpcResponse = { + id: '1', + ok: true, + result: { send: { handle: 'terminal-1', accepted: false, bytesWritten: 0 } }, + _meta: runtimeMeta + } + + // When / Then + expect(isTerminalSendRpcAccepted(response)).toBe(false) + }) + + it('Given RPC failure or malformed terminal send response When checked Then reports failure', () => { + // Given + const rpcFailure: RpcResponse = { + id: '1', + ok: false, + error: { code: 'terminal_error', message: 'failed' }, + _meta: runtimeMeta + } + const malformedSuccess: RpcResponse = { + id: '2', + ok: true, + result: {}, + _meta: runtimeMeta + } + + // When / Then + expect(isTerminalSendRpcAccepted(rpcFailure)).toBe(false) + expect(isTerminalSendRpcAccepted(malformedSuccess)).toBe(false) + }) +}) diff --git a/mobile/src/terminal/terminal-send-rpc-response.ts b/mobile/src/terminal/terminal-send-rpc-response.ts new file mode 100644 index 00000000000..454c5e32972 --- /dev/null +++ b/mobile/src/terminal/terminal-send-rpc-response.ts @@ -0,0 +1,15 @@ +import type { RpcResponse } from '../transport/types' + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +export function isTerminalSendRpcAccepted(response: RpcResponse): boolean { + if (!response.ok) { + return false + } + if (!isRecord(response.result) || !isRecord(response.result.send)) { + return false + } + return response.result.send.accepted === true +} diff --git a/mobile/src/terminal/terminal-webview-tap-routing.test.ts b/mobile/src/terminal/terminal-webview-tap-routing.test.ts index a4d18c6e56e..9e71a3bf85f 100644 --- a/mobile/src/terminal/terminal-webview-tap-routing.test.ts +++ b/mobile/src/terminal/terminal-webview-tap-routing.test.ts @@ -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) diff --git a/mobile/src/terminal/terminal-webview-url-tap.test.ts b/mobile/src/terminal/terminal-webview-url-tap.test.ts index ba992874f47..bd7ff4bf06b 100644 --- a/mobile/src/terminal/terminal-webview-url-tap.test.ts +++ b/mobile/src/terminal/terminal-webview-url-tap.test.ts @@ -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 });") }) diff --git a/mobile/src/terminal/terminal-webview-url-tap.ts b/mobile/src/terminal/terminal-webview-url-tap.ts index d5fb0516a61..86f87a44198 100644 --- a/mobile/src/terminal/terminal-webview-url-tap.ts +++ b/mobile/src/terminal/terminal-webview-url-tap.ts @@ -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; diff --git a/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts b/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts new file mode 100644 index 00000000000..b6bcb380607 --- /dev/null +++ b/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { getTerminalLiveAccessoryInactiveInputCommitResult } from './use-terminal-live-accessory-input-commit' + +type DeferredBoolean = { + readonly promise: Promise + readonly resolve: (value: boolean) => void +} + +function createDeferredBoolean(): DeferredBoolean { + let resolvePromise: (value: boolean) => void = () => { + throw new Error('deferred promise was resolved before initialization') + } + const promise = new Promise((resolve) => { + resolvePromise = resolve + }) + return { promise, resolve: resolvePromise } +} + +describe('terminal live accessory input commit', () => { + it('Given live input is disabled with an active flush When accessory raw fallback is requested Then waits before allowing raw send', async () => { + // Given + const deferredFlush = createDeferredBoolean() + let settled = false + + // When + const resultPromise = getTerminalLiveAccessoryInactiveInputCommitResult( + () => deferredFlush.promise + ) + void resultPromise.then(() => { + settled = true + }) + await Promise.resolve() + + // Then + expect(settled).toBe(false) + deferredFlush.resolve(true) + await expect(resultPromise).resolves.toEqual({ kind: 'allow-raw' }) + }) + + it('Given live input is disabled with a failed active flush When accessory raw fallback is requested Then suppresses raw send', async () => { + // Given + const waitForPendingLiveInputFlush = async (): Promise => false + + // When + const result = await getTerminalLiveAccessoryInactiveInputCommitResult( + waitForPendingLiveInputFlush + ) + + // Then + expect(result).toEqual({ kind: 'suppress-raw' }) + }) +}) diff --git a/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts b/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts new file mode 100644 index 00000000000..b7e8cc8b214 --- /dev/null +++ b/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts @@ -0,0 +1,124 @@ +import { useCallback, type RefObject } from 'react' +import type { TextInput } from 'react-native' +import { + getTerminalLiveAccessoryBytesDecision, + getTerminalLiveAccessoryLocalEditText, + getTerminalLiveDeferredTextDelayMs +} from './terminal-live-text-commit' +import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input' +import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order' +import type { TerminalLiveInputSender } from './terminal-live-input-sender' + +export type TerminalLiveAccessoryInputCommitResult = + | { readonly kind: 'allow-raw' } + | { readonly kind: 'handled' } + | { readonly kind: 'suppress-raw' } + +type TerminalLiveInputCommitScheduler = ( + handle: string, + text: string, + delayMs: number | null +) => void + +export async function getTerminalLiveAccessoryInactiveInputCommitResult( + waitForPendingLiveInputFlush: () => Promise +): Promise { + return (await waitForPendingLiveInputFlush()) ? { kind: 'allow-raw' } : { kind: 'suppress-raw' } +} + +type TerminalLiveAccessoryInputCommitOptions = { + readonly activeHandle: string | null + readonly clearPendingLiveInputCommit: () => void + readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise + readonly liveInputRef: RefObject + readonly liveInputTerminalHandles: ReadonlySet + readonly pendingLiveInputHandleRef: RefObject + readonly pendingLiveInputTextRef: RefObject + readonly schedulePendingLiveInputCommit: TerminalLiveInputCommitScheduler + readonly sendLiveTerminalInputRef: RefObject + readonly setLiveInputCapture: (text: string) => void + readonly waitForPendingLiveInputFlush: () => Promise +} + +export function useTerminalLiveAccessoryInputCommit({ + activeHandle, + clearPendingLiveInputCommit, + flushPendingLiveInputText, + liveInputRef, + liveInputTerminalHandles, + pendingLiveInputHandleRef, + pendingLiveInputTextRef, + schedulePendingLiveInputCommit, + sendLiveTerminalInputRef, + setLiveInputCapture, + waitForPendingLiveInputFlush +}: TerminalLiveAccessoryInputCommitOptions): ( + input: TerminalLiveAccessoryInput +) => Promise { + return useCallback( + async (input: TerminalLiveAccessoryInput): Promise => { + if (!activeHandle) { + return { kind: 'allow-raw' } + } + if (!liveInputTerminalHandles.has(activeHandle)) { + return getTerminalLiveAccessoryInactiveInputCommitResult(waitForPendingLiveInputFlush) + } + const pendingText = + pendingLiveInputHandleRef.current === activeHandle ? pendingLiveInputTextRef.current : '' + if (pendingLiveInputHandleRef.current && pendingLiveInputHandleRef.current !== activeHandle) { + clearPendingLiveInputCommit() + } + const decision = getTerminalLiveAccessoryBytesDecision({ ...input, pendingText }) + switch (decision.kind) { + case 'send-now': + // Why: raw accessory bytes must wait behind any in-flight IME text + // flush so composed Hangul reaches the PTY before follow-up controls. + return (await waitForPendingLiveInputFlush()) + ? { kind: 'allow-raw' } + : { kind: 'suppress-raw' } + case 'local-edit': { + const editedText = getTerminalLiveAccessoryLocalEditText({ + localEdit: decision.localEdit, + pendingText + }) + if (editedText.length === 0) { + clearPendingLiveInputCommit() + return { kind: 'handled' } + } + // Why: accessory buttons do not emit native TextInput edits, so the + // pending IME buffer must be edited and rescheduled here. + setLiveInputCapture(editedText) + liveInputRef.current?.setNativeProps({ text: editedText }) + schedulePendingLiveInputCommit( + activeHandle, + editedText, + getTerminalLiveDeferredTextDelayMs(editedText) + ) + return { kind: 'handled' } + } + case 'flush-then-send': + await sendTerminalLiveControlAfterPendingFlush( + () => flushPendingLiveInputText(activeHandle), + () => sendLiveTerminalInputRef.current(activeHandle, decision.bytes) + ) + return { kind: 'handled' } + default: + decision satisfies never + return { kind: 'handled' } + } + }, + [ + activeHandle, + clearPendingLiveInputCommit, + flushPendingLiveInputText, + liveInputRef, + liveInputTerminalHandles, + pendingLiveInputHandleRef, + pendingLiveInputTextRef, + schedulePendingLiveInputCommit, + sendLiveTerminalInputRef, + setLiveInputCapture, + waitForPendingLiveInputFlush + ] + ) +} diff --git a/mobile/src/terminal/use-terminal-live-input-commit.test.ts b/mobile/src/terminal/use-terminal-live-input-commit.test.ts new file mode 100644 index 00000000000..005d4b77f15 --- /dev/null +++ b/mobile/src/terminal/use-terminal-live-input-commit.test.ts @@ -0,0 +1,184 @@ +import { createElement, type RefObject } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import type { TextInput } from 'react-native' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { TerminalLiveInputSender } from './terminal-live-input-sender' +import { TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS } from './terminal-live-text-commit' +import { useTerminalLiveInputCommit } from './use-terminal-live-input-commit' + +type TerminalLiveInputCommitHarness = { + readonly captures: readonly string[] + readonly handlers: ReturnType> + readonly sent: readonly string[] + readonly unmount: () => void +} + +type TerminalLiveInputCommitHarnessOptions = { + readonly sendResult?: boolean +} + +function suppressReactTestRendererDeprecationWarning(): () => void { + const originalConsoleError = console.error + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + const firstArg = args[0] + if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) { + return + } + originalConsoleError(...args) + }) + return () => consoleErrorSpy.mockRestore() +} + +function createTerminalLiveInputCommitHarness({ + sendResult = true +}: TerminalLiveInputCommitHarnessOptions = {}): TerminalLiveInputCommitHarness { + const activeHandle = 'terminal-a' + const activeHandleRef: RefObject = { current: activeHandle } + const activeSessionTabTypeRef: RefObject = { current: 'terminal' } + const captures: string[] = [] + const liveInputRef: RefObject = { current: null } + const liveInputTerminalHandles = new Set([activeHandle]) + const liveInputTerminalHandlesRef: RefObject> = { + current: new Set([activeHandle]) + } + const sent: string[] = [] + const sendLiveTerminalInputRef: RefObject = { + current: async (_handle, bytes) => { + sent.push(bytes) + return sendResult + } + } + let handlers: ReturnType> | null = null + let renderer: ReactTestRenderer | null = null + + function Harness(): null { + handlers = useTerminalLiveInputCommit({ + activeHandle, + activeHandleRef, + activeSessionTabType: 'terminal', + activeSessionTabTypeRef, + liveInputRef, + liveInputTerminalHandles, + liveInputTerminalHandlesRef, + sendLiveTerminalInputRef, + setLiveInputCapture: (text) => captures.push(text) + }) + return null + } + + const restoreConsoleError = suppressReactTestRendererDeprecationWarning() + try { + act(() => { + renderer = create(createElement(Harness)) + }) + } finally { + restoreConsoleError() + } + if (!handlers || !renderer) { + throw new Error('terminal live input hook did not render') + } + + return { + captures, + handlers, + sent, + unmount: () => { + act(() => renderer?.unmount()) + } + } +} + +describe('terminal live input commit hook', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('Given Hangul pending text When the old idle window elapses Then does not send jamo to the terminal', async () => { + // Given + vi.useFakeTimers() + const { captures, handlers, sent } = createTerminalLiveInputCommitHarness() + + // When + handlers.handleLiveInputChange('ㅎ') + await vi.advanceTimersByTimeAsync(1_000) + + // Then + expect(captures).toEqual(['ㅎ']) + expect(sent).toEqual([]) + }) + + it('Given Hangul pending text When submit is requested Then sends composed text before carriage return', async () => { + // Given + const { handlers, sent } = createTerminalLiveInputCommitHarness() + handlers.handleLiveInputChange('한') + + // When + handlers.handleLiveInputSubmit() + + // Then + await vi.waitFor(() => expect(sent).toEqual(['한', '\r'])) + }) + + it('Given Hangul pending text When an external terminal send is requested Then flushes composed text first', async () => { + // Given + const { handlers, sent } = createTerminalLiveInputCommitHarness() + handlers.handleLiveInputChange('한') + + // When + const flushed = await handlers.flushPendingLiveInputBeforeExternalSend('terminal-a') + + // Then + expect(flushed).toBe(true) + expect(sent).toEqual(['한']) + }) + + it('Given pending text cannot be sent When an external terminal send is requested Then reports failure', async () => { + // Given + const { handlers, sent } = createTerminalLiveInputCommitHarness({ sendResult: false }) + handlers.handleLiveInputChange('한') + + // When + const flushed = await handlers.flushPendingLiveInputBeforeExternalSend('terminal-a') + + // Then + expect(flushed).toBe(false) + expect(sent).toEqual(['한']) + }) + + it('Given Chinese and Vietnamese IME text When the settle window elapses Then sends the committed text', async () => { + // Given + vi.useFakeTimers() + const { captures, handlers, sent } = createTerminalLiveInputCommitHarness() + + // When + handlers.handleLiveInputChange('你好') + await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS - 1) + + // Then + expect(captures).toEqual(['你好']) + expect(sent).toEqual([]) + + // When + await vi.advanceTimersByTimeAsync(1) + await vi.waitFor(() => expect(sent).toEqual(['你好'])) + handlers.handleLiveInputChange('tiếng Việt') + await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS) + + // Then + await vi.waitFor(() => expect(sent).toEqual(['你好', 'tiếng Việt'])) + }) + + it('Given deferred IME text When the hook unmounts Then cancels the pending commit timer', async () => { + // Given + vi.useFakeTimers() + const { handlers, sent, unmount } = createTerminalLiveInputCommitHarness() + handlers.handleLiveInputChange('é') + + // When + unmount() + await vi.advanceTimersByTimeAsync(1_000) + + // Then + expect(sent).toEqual([]) + }) +}) diff --git a/mobile/src/terminal/use-terminal-live-input-commit.ts b/mobile/src/terminal/use-terminal-live-input-commit.ts new file mode 100644 index 00000000000..2e2dd9e1589 --- /dev/null +++ b/mobile/src/terminal/use-terminal-live-input-commit.ts @@ -0,0 +1,236 @@ +import { useCallback, useEffect, type RefObject } from 'react' +import type { TextInput } from 'react-native' +import { + getTerminalLiveSpecialKeyDecision, + getTerminalLiveSubmitSequence, + getTerminalLiveTextChangeDecision +} from './terminal-live-text-commit' +import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order' +import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input' +import type { TerminalLiveInputSender } from './terminal-live-input-sender' +import { normalizeTerminalTextInput } from './terminal-text-input-normalization' +import { useTerminalLivePendingInputFlush } from './use-terminal-live-pending-input-flush' +import { + useTerminalLiveAccessoryInputCommit, + type TerminalLiveAccessoryInputCommitResult +} from './use-terminal-live-accessory-input-commit' + +type TerminalLiveInputKeyPressEvent = { + readonly nativeEvent: { + readonly key: string + } +} + +type TerminalLiveInputCommitOptions = { + readonly activeHandle: string | null + readonly activeHandleRef: RefObject + readonly activeSessionTabType: TTabType | null | undefined + readonly activeSessionTabTypeRef: RefObject + readonly liveInputRef: RefObject + readonly liveInputTerminalHandles: ReadonlySet + readonly liveInputTerminalHandlesRef: RefObject> + readonly sendLiveTerminalInputRef: RefObject + readonly setLiveInputCapture: (text: string) => void +} + +type TerminalLiveInputCommitHandlers = { + readonly clearPendingLiveInputCommit: () => void + readonly flushPendingLiveInputBeforeExternalSend: (handle: string) => Promise + readonly handleLiveInputAccessoryBytes: ( + input: TerminalLiveAccessoryInput + ) => Promise + readonly handleLiveInputChange: (text: string) => void + readonly handleLiveInputKeyPress: (event: TerminalLiveInputKeyPressEvent) => void + readonly handleLiveInputSubmit: () => void +} + +export function useTerminalLiveInputCommit({ + activeHandle, + activeHandleRef, + activeSessionTabType, + activeSessionTabTypeRef, + liveInputRef, + liveInputTerminalHandles, + liveInputTerminalHandlesRef, + sendLiveTerminalInputRef, + setLiveInputCapture +}: TerminalLiveInputCommitOptions): TerminalLiveInputCommitHandlers { + const { + clearPendingLiveInputCommit, + flushPendingLiveInputText, + pendingLiveInputHandleRef, + pendingLiveInputTextRef, + schedulePendingLiveInputCommit, + waitForPendingLiveInputFlush + } = useTerminalLivePendingInputFlush({ + activeHandleRef, + activeSessionTabTypeRef, + liveInputRef, + liveInputTerminalHandlesRef, + sendLiveTerminalInputRef, + setLiveInputCapture + }) + + useEffect(() => { + const pendingHandle = pendingLiveInputHandleRef.current + if (!pendingHandle) { + return + } + if ( + !activeHandle || + pendingHandle !== activeHandle || + activeSessionTabType !== 'terminal' || + !liveInputTerminalHandles.has(activeHandle) + ) { + clearPendingLiveInputCommit() + } + }, [activeHandle, activeSessionTabType, clearPendingLiveInputCommit, liveInputTerminalHandles]) + + const flushPendingLiveInputBeforeExternalSend = useCallback( + async (handle: string): Promise => { + const pendingHandle = pendingLiveInputHandleRef.current + if (pendingHandle && pendingHandle !== handle) { + clearPendingLiveInputCommit() + return waitForPendingLiveInputFlush() + } + if (pendingHandle === handle && pendingLiveInputTextRef.current.length > 0) { + return flushPendingLiveInputText(handle) + } + return waitForPendingLiveInputFlush() + }, + [clearPendingLiveInputCommit, flushPendingLiveInputText, waitForPendingLiveInputFlush] + ) + + const handleLiveInputChange = useCallback( + (text: string) => { + if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) { + clearPendingLiveInputCommit() + return + } + const normalizedText = normalizeTerminalTextInput(text) + const decision = getTerminalLiveTextChangeDecision(normalizedText) + switch (decision.kind) { + case 'ignore': + clearPendingLiveInputCommit() + return + case 'send-now': + clearPendingLiveInputCommit() + void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () => + sendLiveTerminalInputRef.current(activeHandle, decision.text) + ) + return + case 'defer': + // Why: React Native does not expose composition events here, so keep + // probable IME text in the native field until the commit timer settles. + setLiveInputCapture(decision.text) + schedulePendingLiveInputCommit(activeHandle, decision.text, decision.delayMs) + return + default: + decision satisfies never + } + }, + [ + activeHandle, + clearPendingLiveInputCommit, + liveInputTerminalHandles, + schedulePendingLiveInputCommit, + sendLiveTerminalInputRef, + setLiveInputCapture, + waitForPendingLiveInputFlush + ] + ) + + const handleLiveInputKeyPress = useCallback( + (event: TerminalLiveInputKeyPressEvent) => { + if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) { + return + } + const pendingText = + pendingLiveInputHandleRef.current === activeHandle ? pendingLiveInputTextRef.current : '' + if (pendingLiveInputHandleRef.current && pendingLiveInputHandleRef.current !== activeHandle) { + clearPendingLiveInputCommit() + } + const decision = getTerminalLiveSpecialKeyDecision({ + key: event.nativeEvent.key, + pendingText + }) + switch (decision.kind) { + case 'ignore': + case 'local-edit': + return + case 'send-now': + clearPendingLiveInputCommit() + void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () => + sendLiveTerminalInputRef.current(activeHandle, decision.bytes) + ) + return + case 'flush-then-send': + void sendTerminalLiveControlAfterPendingFlush( + () => flushPendingLiveInputText(activeHandle), + () => sendLiveTerminalInputRef.current(activeHandle, decision.bytes) + ) + return + default: + decision satisfies never + } + }, + [ + activeHandle, + clearPendingLiveInputCommit, + flushPendingLiveInputText, + liveInputTerminalHandles, + sendLiveTerminalInputRef, + waitForPendingLiveInputFlush + ] + ) + + const handleLiveInputAccessoryBytes = useTerminalLiveAccessoryInputCommit({ + activeHandle, + clearPendingLiveInputCommit, + flushPendingLiveInputText, + liveInputRef, + liveInputTerminalHandles, + pendingLiveInputHandleRef, + pendingLiveInputTextRef, + schedulePendingLiveInputCommit, + sendLiveTerminalInputRef, + setLiveInputCapture, + waitForPendingLiveInputFlush + }) + + const handleLiveInputSubmit = useCallback(() => { + if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) { + return + } + const pendingText = + pendingLiveInputHandleRef.current === activeHandle ? pendingLiveInputTextRef.current : '' + const sequence = getTerminalLiveSubmitSequence(pendingText) + if (sequence.length === 2) { + void sendTerminalLiveControlAfterPendingFlush( + () => flushPendingLiveInputText(activeHandle), + () => sendLiveTerminalInputRef.current(activeHandle, sequence[1]) + ) + return + } + clearPendingLiveInputCommit() + void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () => + sendLiveTerminalInputRef.current(activeHandle, sequence[0]) + ) + }, [ + activeHandle, + clearPendingLiveInputCommit, + flushPendingLiveInputText, + liveInputTerminalHandles, + sendLiveTerminalInputRef, + waitForPendingLiveInputFlush + ]) + + return { + clearPendingLiveInputCommit, + flushPendingLiveInputBeforeExternalSend, + handleLiveInputAccessoryBytes, + handleLiveInputChange, + handleLiveInputKeyPress, + handleLiveInputSubmit + } +} diff --git a/mobile/src/terminal/use-terminal-live-pending-input-flush.ts b/mobile/src/terminal/use-terminal-live-pending-input-flush.ts new file mode 100644 index 00000000000..2c6998c99a0 --- /dev/null +++ b/mobile/src/terminal/use-terminal-live-pending-input-flush.ts @@ -0,0 +1,139 @@ +import { useCallback, useEffect, useRef, type RefObject } from 'react' +import type { TextInput } from 'react-native' +import type { TerminalLiveInputSender } from './terminal-live-input-sender' +import { + queueTerminalLivePendingFlush, + waitForTerminalLivePendingFlush +} from './terminal-live-pending-flush-state' + +type TerminalLivePendingInputFlushOptions = { + readonly activeHandleRef: RefObject + readonly activeSessionTabTypeRef: RefObject + readonly liveInputRef: RefObject + readonly liveInputTerminalHandlesRef: RefObject> + readonly sendLiveTerminalInputRef: RefObject + readonly setLiveInputCapture: (text: string) => void +} + +type TerminalLivePendingInputFlush = { + readonly clearPendingLiveInputCommit: () => void + readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise + readonly pendingLiveInputHandleRef: RefObject + readonly pendingLiveInputTextRef: RefObject + readonly schedulePendingLiveInputCommit: ( + handle: string, + text: string, + delayMs: number | null + ) => void + readonly waitForPendingLiveInputFlush: () => Promise +} + +export function useTerminalLivePendingInputFlush({ + activeHandleRef, + activeSessionTabTypeRef, + liveInputRef, + liveInputTerminalHandlesRef, + sendLiveTerminalInputRef, + setLiveInputCapture +}: TerminalLivePendingInputFlushOptions): TerminalLivePendingInputFlush { + const liveInputCommitTimerRef = useRef | null>(null) + const pendingLiveInputFlushRef = useRef | null>(null) + const pendingLiveInputTextRef = useRef('') + const pendingLiveInputHandleRef = useRef(null) + + const clearPendingLiveInputCommit = useCallback(() => { + if (liveInputCommitTimerRef.current) { + clearTimeout(liveInputCommitTimerRef.current) + liveInputCommitTimerRef.current = null + } + pendingLiveInputTextRef.current = '' + pendingLiveInputHandleRef.current = null + setLiveInputCapture('') + liveInputRef.current?.setNativeProps({ text: '' }) + }, [liveInputRef, setLiveInputCapture]) + + const waitForPendingLiveInputFlush = useCallback(async (): Promise => { + return waitForTerminalLivePendingFlush(pendingLiveInputFlushRef) + }, []) + + const flushPendingLiveInputText = useCallback( + async (expectedHandle: string | null): Promise => { + const existingFlush = pendingLiveInputFlushRef.current + if (liveInputCommitTimerRef.current) { + clearTimeout(liveInputCommitTimerRef.current) + liveInputCommitTimerRef.current = null + } + + const handle = pendingLiveInputHandleRef.current + const text = pendingLiveInputTextRef.current + pendingLiveInputHandleRef.current = null + pendingLiveInputTextRef.current = '' + setLiveInputCapture('') + liveInputRef.current?.setNativeProps({ text: '' }) + + if (!handle || text.length === 0) { + return existingFlush ?? false + } + if ( + (expectedHandle !== null && handle !== expectedHandle) || + handle !== activeHandleRef.current || + activeSessionTabTypeRef.current !== 'terminal' || + !liveInputTerminalHandlesRef.current.has(handle) + ) { + return false + } + + return queueTerminalLivePendingFlush(pendingLiveInputFlushRef, () => + sendLiveTerminalInputRef.current(handle, text) + ) + }, + [ + activeHandleRef, + activeSessionTabTypeRef, + liveInputRef, + liveInputTerminalHandlesRef, + sendLiveTerminalInputRef, + setLiveInputCapture + ] + ) + + const schedulePendingLiveInputCommit = useCallback( + (handle: string, text: string, delayMs: number | null) => { + if (liveInputCommitTimerRef.current) { + clearTimeout(liveInputCommitTimerRef.current) + } + pendingLiveInputHandleRef.current = handle + pendingLiveInputTextRef.current = text + if (delayMs === null) { + liveInputCommitTimerRef.current = null + return + } + liveInputCommitTimerRef.current = setTimeout(() => { + liveInputCommitTimerRef.current = null + void flushPendingLiveInputText(handle) + }, delayMs) + }, + [flushPendingLiveInputText] + ) + + useEffect(() => { + return () => { + if (liveInputCommitTimerRef.current) { + clearTimeout(liveInputCommitTimerRef.current) + liveInputCommitTimerRef.current = null + } + pendingLiveInputHandleRef.current = null + pendingLiveInputTextRef.current = '' + pendingLiveInputFlushRef.current = null + } + }, []) + + return { + clearPendingLiveInputCommit, + flushPendingLiveInputText, + pendingLiveInputHandleRef, + pendingLiveInputTextRef, + schedulePendingLiveInputCommit, + waitForPendingLiveInputFlush + } +} diff --git a/mobile/src/transport/rpc-client-terminal-binary-frame.test.ts b/mobile/src/transport/rpc-client-terminal-binary-frame.test.ts new file mode 100644 index 00000000000..216fd2aec56 --- /dev/null +++ b/mobile/src/transport/rpc-client-terminal-binary-frame.test.ts @@ -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' }) + }) +}) diff --git a/mobile/src/transport/rpc-client-terminal-binary-frame.ts b/mobile/src/transport/rpc-client-terminal-binary-frame.ts new file mode 100644 index 00000000000..ee7da35383e --- /dev/null +++ b/mobile/src/transport/rpc-client-terminal-binary-frame.ts @@ -0,0 +1,116 @@ +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamJson, + decodeTerminalStreamText +} from './terminal-stream-protocol' + +export type TerminalSnapshotState = { + streamId: number + meta: Record + chunks: string[] +} + +type StreamingListener = (result: unknown) => void + +type TerminalBinaryFrameOptions = { + terminalSnapshots: Map + 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>(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>(frame.payload) + if (!meta) { + return + } + options.recordValidatedInboundTraffic() + listener({ + ...meta, + type: 'resized', + streamId: frame.streamId + }) + return + } + if (frame.opcode === TerminalStreamOpcode.Metadata) { + const meta = decodeTerminalStreamJson>(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) + }) + } +} diff --git a/mobile/src/transport/rpc-client-terminal-reconnect.test.ts b/mobile/src/transport/rpc-client-terminal-reconnect.test.ts new file mode 100644 index 00000000000..f382068b735 --- /dev/null +++ b/mobile/src/transport/rpc-client-terminal-reconnect.test.ts @@ -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() + }) +}) diff --git a/mobile/src/transport/rpc-client.test.ts b/mobile/src/transport/rpc-client.test.ts index 03d7515b69d..e096ee659c9 100644 --- a/mobile/src/transport/rpc-client.test.ts +++ b/mobile/src/transport/rpc-client.test.ts @@ -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 () => { diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index 03777588698..2cd484de4b8 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -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 - 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>(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>(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)) diff --git a/mobile/src/transport/terminal-stream-protocol.ts b/mobile/src/transport/terminal-stream-protocol.ts index 0d5a04340c5..384d66996f1 100644 --- a/mobile/src/transport/terminal-stream-protocol.ts +++ b/mobile/src/transport/terminal-stream-protocol.ts @@ -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 ) } diff --git a/notes/windows-perf-progress.md b/notes/windows-perf-progress.md index 5cd3b89ff20..51bee65e4f6 100644 --- a/notes/windows-perf-progress.md +++ b/notes/windows-perf-progress.md @@ -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`) diff --git a/package.json b/package.json index 88f598a7d08..858ae4e230b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/cli/handlers/emulator.test.ts b/src/cli/handlers/emulator.test.ts index ee1175863ab..3a9711595b5 100644 --- a/src/cli/handlers/emulator.test.ts +++ b/src/cli/handlers/emulator.test.ts @@ -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) diff --git a/src/cli/handlers/emulator.ts b/src/cli/handlers/emulator.ts index 20fa44f149b..e41700ff4b5 100644 --- a/src/cli/handlers/emulator.ts +++ b/src/cli/handlers/emulator.ts @@ -128,7 +128,13 @@ export const EMULATOR_HANDLERS: Record = { 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 diff --git a/src/main/agent-hooks/installer-utils.test.ts b/src/main/agent-hooks/installer-utils.test.ts index 35701d07332..ba0dad4a0ab 100644 --- a/src/main/agent-hooks/installer-utils.test.ts +++ b/src/main/agent-hooks/installer-utils.test.ts @@ -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') diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts index 4d233c0fc33..86bb58607ca 100644 --- a/src/main/agent-hooks/installer-utils.ts +++ b/src/main/agent-hooks/installer-utils.ts @@ -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. diff --git a/src/main/browser/cdp-ws-proxy.test.ts b/src/main/browser/cdp-ws-proxy.test.ts index 9f345a2e6ee..11fbcd772cf 100644 --- a/src/main/browser/cdp-ws-proxy.test.ts +++ b/src/main/browser/cdp-ws-proxy.test.ts @@ -21,7 +21,9 @@ function createMockWebContents() { detach: vi.fn(() => { debuggerAttached = false }), - sendCommand: vi.fn(async () => ({})), + sendCommand: vi.fn( + async (_method?: string, _params?: Record, _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?] + + function getSendCommandCalls(): SendCommandCall[] { + const calls = mock.webContents.debugger.sendCommand.mock.calls as unknown as [ + string, + Record?, + 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 ── diff --git a/src/main/browser/cdp-ws-proxy.ts b/src/main/browser/cdp-ws-proxy.ts index 2531822b982..ec8de5be176 100644 --- a/src/main/browser/cdp-ws-proxy.ts +++ b/src/main/browser/cdp-ws-proxy.ts @@ -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, + sessionId?: string + ): Promise { + 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 + params: Record, + msgSessionId?: string ): Promise { + 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, + msgSessionId?: string + ): Promise { + 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 | null { + return Object.keys(params).find((key) => key !== 'ignoreCache') ?? null + } + + private async primePageLifecycle(sessionId?: string): Promise { + let timeout: ReturnType | null = null + const priming = (async (): Promise => { // 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((resolve) => { + timeout = setTimeout(resolve, LIFECYCLE_PRIMING_TIMEOUT_MS) + timeout.unref?.() + }) + ]) + } finally { + if (timeout) { + clearTimeout(timeout) + } } - this.forwardCommand(client, clientId, 'Page.navigate', params) } private handleScreenshot( diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts index 97e43e24287..22c20b63e81 100644 --- a/src/main/claude/hook-service.test.ts +++ b/src/main/claude/hook-service.test.ts @@ -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 @@ -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 /) } } diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts index 51ce5a9f16b..cb754fce5d0 100644 --- a/src/main/claude/hook-settings.ts +++ b/src/main/claude/hook-settings.ts @@ -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 { diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 807d502f934..13b39b6b0d7 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -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 diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index ef28b584ae3..c979725633b 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -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 diff --git a/src/main/codex/codex-config-mirror.test.ts b/src/main/codex/codex-config-mirror.test.ts index 17865439a5d..ff3ad9ba3bd 100644 --- a/src/main/codex/codex-config-mirror.test.ts +++ b/src/main/codex/codex-config-mirror.test.ts @@ -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(), diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts index a78b876c155..ff9f72c55fe 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -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() @@ -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 -} diff --git a/src/main/codex/codex-config-path-reference-rewrite.ts b/src/main/codex/codex-config-path-reference-rewrite.ts new file mode 100644 index 00000000000..94e29db3d8f --- /dev/null +++ b/src/main/codex/codex-config-path-reference-rewrite.ts @@ -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 '' +} diff --git a/src/main/codex/config-toml-line-scan.ts b/src/main/codex/config-toml-line-scan.ts new file mode 100644 index 00000000000..c0136be9e32 --- /dev/null +++ b/src/main/codex/config-toml-line-scan.ts @@ -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 +} diff --git a/src/main/codex/config-toml-trust.ts b/src/main/codex/config-toml-trust.ts index afc9c0d3685..d587f044d5e 100644 --- a/src/main/codex/config-toml-trust.ts +++ b/src/main/codex/config-toml-trust.ts @@ -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.""]. 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 { if (process.send) { process.send({ type: 'ready' }) } + + warmWindowsConptyOnce() } // Only auto-run when executed directly (not imported for testing) diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index e32415aa9a3..af7756e45e7 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -6,6 +6,7 @@ import { mkdtempSync, mkdirSync, rmSync, existsSync, readFileSync, writeFileSync import { DaemonPtyAdapter } from './daemon-pty-adapter' import { DaemonServer } from './daemon-server' import { getHistorySessionDirName } from './history-paths' +import type { HistoryReader } from './history-reader' import type { SubprocessHandle } from './session' import type * as DaemonHealthModule from './daemon-health' import { getDaemonSocketPath } from './daemon-spawner' @@ -1239,6 +1240,159 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(internals.lastFullCheckpointAt.has(sessionId)).toBe(false) }) + it('skips the cold-restore replay when the daemon session is still alive', async () => { + const sessionId = 'warm-reattach-skip-replay' + const first = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + await first.spawn({ cols: 80, rows: 24, cwd: '/home/user', sessionId }) + // Why disconnectOnly: the production app-quit path leaves meta.endedAt + // null so the session stays crash-recoverable — the state every app + // relaunch with a live daemon sees. + await first.disconnectOnly() + + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const reader = (historyAdapter as unknown as { historyReader: HistoryReader }).historyReader + const detectSpy = vi.spyOn(reader, 'detectColdRestore') + const result = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId }) + + expect(result.isReattach).toBe(true) + expect(result.coldRestore).toBeUndefined() + expect(detectSpy).not.toHaveBeenCalled() + // The unmanaged-reattach re-anchor must survive the skipped detect. + const internals = historyAdapter as unknown as { + sessionsNeedingFullCheckpoint: Set + lastFullCheckpointAt: Map + } + expect(internals.sessionsNeedingFullCheckpoint.has(sessionId)).toBe(true) + expect(internals.lastFullCheckpointAt.has(sessionId)).toBe(false) + }) + + it('does not probe session aliveness when there is no restorable history', async () => { + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const client = ( + historyAdapter as unknown as { + client: { request: (type: string, payload?: unknown) => Promise } + } + ).client + const requestSpy = vi.spyOn(client, 'request') + + await historyAdapter.spawn({ cols: 80, rows: 24, sessionId: 'fresh-no-history' }) + + expect(requestSpy.mock.calls.map((call) => call[0])).not.toContain('getSize') + }) + + it('recovers cold restore when the probed session dies before createOrAttach', async () => { + const sessionId = 'probe-race-cold-restore' + const sessionDir = join(historyDir, getHistorySessionDirName(sessionId)) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync( + join(sessionDir, 'meta.json'), + JSON.stringify({ + cwd: '/projects/raced', + cols: 100, + rows: 30, + startedAt: '2026-04-15T10:00:00Z', + endedAt: null, + exitCode: null + }) + ) + writeFileSync(join(sessionDir, 'scrollback.bin'), 'raced output\r\n') + + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const client = ( + historyAdapter as unknown as { + client: { request: (type: string, payload?: unknown) => Promise } + } + ).client + const originalRequest = client.request.bind(client) + // Why: simulates the probe→createOrAttach race — the probe sees the + // session alive, but it is gone by the time createOrAttach runs. The + // meta rewrite mimics the dying session's exit event beating the + // createOrAttach reply and writing endedAt via closeSession; the + // fallback detect must still restore instead of falling through to + // openSession (which would delete the checkpoint). + vi.spyOn(client, 'request').mockImplementation(async (type: string, payload?: unknown) => { + if (type === 'getSize') { + return { size: { cols: 100, rows: 30 } } + } + const response = await originalRequest(type, payload) + if (type === 'createOrAttach') { + writeFileSync( + join(sessionDir, 'meta.json'), + JSON.stringify({ + cwd: '/projects/raced', + cols: 100, + rows: 30, + startedAt: '2026-04-15T10:00:00Z', + endedAt: '2026-04-15T10:05:00Z', + exitCode: 0 + }) + ) + } + return response + }) + + const result = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId }) + + expect(result.coldRestore).toBeDefined() + expect(result.coldRestore!.scrollback).toContain('raced output') + // Documented race delta: the fresh shell spawns with the renderer's + // requested params, not the recovered ones. + expect(lastSpawnOpts).toMatchObject({ sessionId, cols: 80, rows: 24 }) + // The recovery data must survive — openSession would have deleted it. + expect(existsSync(join(sessionDir, 'scrollback.bin'))).toBe(true) + const internals = historyAdapter as unknown as { + sessionsNeedingFullCheckpoint: Set + lastFullCheckpointAt: Map + } + expect(internals.sessionsNeedingFullCheckpoint.has(sessionId)).toBe(true) + expect(internals.lastFullCheckpointAt.has(sessionId)).toBe(false) + }) + + it('falls back to the full cold-restore detect when the aliveness probe fails', async () => { + const sessionId = 'probe-error-cold-restore' + const sessionDir = join(historyDir, getHistorySessionDirName(sessionId)) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync( + join(sessionDir, 'meta.json'), + JSON.stringify({ + cwd: '/projects/probeless', + cols: 132, + rows: 43, + startedAt: '2026-04-15T10:00:00Z', + endedAt: null, + exitCode: null + }) + ) + writeFileSync(join(sessionDir, 'scrollback.bin'), 'probeless output\r\n') + + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const client = ( + historyAdapter as unknown as { + client: { request: (type: string, payload?: unknown) => Promise } + } + ).client + const originalRequest = client.request.bind(client) + // Why: an old daemon rejects the unknown getSize method; the spawn must + // behave exactly like the unprobed path. + vi.spyOn(client, 'request').mockImplementation((type: string, payload?: unknown) => { + if (type === 'getSize') { + return Promise.reject(new Error('Unknown request type')) + } + return originalRequest(type, payload) + }) + + const result = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId }) + + expect(result.coldRestore).toBeDefined() + expect(result.coldRestore!.scrollback).toContain('probeless output') + expect(lastSpawnOpts).toMatchObject({ + sessionId, + cwd: '/projects/probeless', + cols: 132, + rows: 43 + }) + }) + it('returns same cold restore on StrictMode double-mount (sticky cache)', async () => { const sessionId = 'sticky-cache-test' const sessionDir = join(historyDir, getHistorySessionDirName(sessionId)) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index f4d717f1999..56a775a8c99 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -6,7 +6,7 @@ import { existsSync } from 'node:fs' import { DaemonClient } from './client' import { getMacDaemonSystemResolverHealth } from './daemon-health' import { HistoryManager } from './history-manager' -import { HistoryReader } from './history-reader' +import { HistoryReader, type ColdRestoreInfo } from './history-reader' import { mintPtySessionId, parsePtySessionId } from './pty-session-id' import { supportsPtyStartupBarrier } from './shell-ready' import { CODEX_SHELL_READY_TIMEOUT_MS } from './session' @@ -19,7 +19,12 @@ import { type SessionInfo, type TakePendingOutputResult } from './types' -import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' +import type { + IPtyProvider, + PtyProcessInfo, + PtySpawnOptions, + PtySpawnResult +} from '../providers/types' import { isShellProcess } from '../../shared/agent-detection' import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery' @@ -141,16 +146,30 @@ export class DaemonPtyAdapter implements IPtyProvider { await this.replaceUnhealthyMacResolverDaemonBeforeNewPty() } + await this.ensureConnected() + // Why: detect crash-recovery history before spawning a replacement PTY so // the revived shell inherits the recovered cwd and dimensions instead of // whatever the current renderer happened to request on mount. - const restoreInfo = this.historyReader?.detectColdRestore(sessionId) ?? null + // Why probe aliveness first: detectColdRestore synchronously replays the + // full checkpoint + log (up to ~5MB) through a scratch emulator on the + // main process, but a live daemon session ignores spawn params and its + // own snapshot supersedes disk — the replay result would be discarded. + // getSize is a read-only probe; on error/unsupported it degrades to the + // full detect. + let restoreInfo: ColdRestoreInfo | null = null + let restoreSkippedForLiveSession = false + if (this.historyReader?.hasRestorableHistory(sessionId)) { + if ((await this.getAppliedSize(sessionId)) !== null) { + restoreSkippedForLiveSession = true + } else { + restoreInfo = this.historyReader.detectColdRestore(sessionId) + } + } const effectiveCwd = restoreInfo?.cwd ?? opts.cwd const effectiveCols = restoreInfo?.cols ?? opts.cols const effectiveRows = restoreInfo?.rows ?? opts.rows - await this.ensureConnected() - const shellReadySupported = opts.command ? supportsPtyStartupBarrier(opts.env ?? {}) : false const isCodexStartupCommand = recognizeAgentProcessFromCommandLine(opts.command)?.agent === 'codex' @@ -208,6 +227,19 @@ export class DaemonPtyAdapter implements IPtyProvider { } } + // Why: the probe→createOrAttach gap is racy — the session can exit (or + // enter termination) in between, so the daemon spawned a fresh shell. + // Detect now so scrollback restore matches the unprobed path; only the + // new shell's cwd/dims came from the renderer request in this rare case. + // Why ignoreCleanEnd: the raced session's exit event (stream socket) can + // beat the createOrAttach reply and write endedAt via closeSession; that + // must not null the restore here, or the openSession branch below would + // delete the checkpoint instead of restoring it. + if (result.isNew && restoreSkippedForLiveSession) { + restoreInfo = + this.historyReader?.detectColdRestore(sessionId, { ignoreCleanEnd: true }) ?? null + } + const wasAlreadyManaged = this.activeSessionIds.has(sessionId) this.activeSessionIds.add(sessionId) @@ -490,7 +522,7 @@ export class DaemonPtyAdapter implements IPtyProvider { return { alive, killed } } - async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { + async listProcesses(): Promise { await this.ensureConnected() const result = await this.client.request('listSessions', undefined) return result.sessions @@ -498,7 +530,8 @@ export class DaemonPtyAdapter implements IPtyProvider { .map((s) => ({ id: s.sessionId, cwd: s.cwd ?? '', - title: 'shell' + title: 'shell', + ...(s.terminalHandle ? { terminalHandle: s.terminalHandle } : {}) })) } diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index 2212635d1fa..1f80f770cd9 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -1,5 +1,10 @@ import type { DaemonPtyAdapter } from './daemon-pty-adapter' -import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' +import type { + IPtyProvider, + PtyProcessInfo, + PtySpawnOptions, + PtySpawnResult +} from '../providers/types' export class DaemonPtyRouter implements IPtyProvider { private current: DaemonPtyAdapter @@ -124,7 +129,7 @@ export class DaemonPtyRouter implements IPtyProvider { await this.current.revive(state) } - async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { + async listProcesses(): Promise { // Why: runtime exact-stop/liveness flows must fail closed if any adapter // cannot provide a trustworthy process list. const results = await Promise.all(this.allAdapters().map((adapter) => adapter.listProcesses())) diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts index acf95ffcdfe..a7b8c68fa19 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.ts @@ -1,5 +1,10 @@ import type { DaemonPtyAdapter } from './daemon-pty-adapter' -import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' +import type { + IPtyProvider, + PtyProcessInfo, + PtySpawnOptions, + PtySpawnResult +} from '../providers/types' type ManagedPtyProvider = IPtyProvider & { disconnectOnly?: () => Promise @@ -135,7 +140,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { await this.fallback.revive(state) } - async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { + async listProcesses(): Promise { const results = await Promise.all( this.allProviders().map((provider) => provider.listProcesses()) ) diff --git a/src/main/daemon/headless-emulator-unicode-width.test.ts b/src/main/daemon/headless-emulator-unicode-width.test.ts new file mode 100644 index 00000000000..f358ce79189 --- /dev/null +++ b/src/main/daemon/headless-emulator-unicode-width.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { HeadlessEmulator } from './headless-emulator' + +// Why these oracles: agent TUIs position the cursor assuming the widths the +// on-screen xterm uses (Unicode 11 + ZWJ joining). If this mirror measures a +// preceding emoji differently, the positioned write lands on the wrong cell +// and the mirrored row tears — which snapshot restores then paint back. +describe('headless emulator unicode widths', () => { + it('advances emoji as two cells so positioned writes land like the renderer', async () => { + const emulator = new HeadlessEmulator({ cols: 40, rows: 4 }) + // 🤖 occupies columns 1-2 under Unicode 11 (one cell under the v6 default), + // so "A" is at column 3, "B" at 4, and column 5 overwrites just past "B". + await emulator.write('\x1b[H\u{1F916}AB') + await emulator.write('\x1b[1;5HZ') + expect(emulator.getVisibleLines()[0]).toBe('\u{1F916}ABZ') + emulator.dispose() + }) + + it('joins ZWJ emoji into one wide pair like the renderer provider', async () => { + const emulator = new HeadlessEmulator({ cols: 40, rows: 4 }) + // 👩‍💻 (woman + ZWJ + laptop) renders as a single two-cell glyph in CLIs; + // plain Unicode 11 would budget four cells and shift everything after it. + await emulator.write('\x1b[H\u{1F469}\u{200D}\u{1F4BB}X') + await emulator.write('\x1b[1;3HY') + expect(emulator.getVisibleLines()[0]).toBe('\u{1F469}\u{200D}\u{1F4BB}Y') + emulator.dispose() + }) +}) diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index e2e396170aa..f5c2bb34049 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -1,10 +1,13 @@ import './xterm-env-polyfill' import { Terminal } from '@xterm/headless' import { SerializeAddon } from '@xterm/addon-serialize' +import { Unicode11Addon } from '@xterm/addon-unicode11' +import { activateOrcaTerminalUnicodeProvider } from '../../shared/terminal-unicode-provider' import { extractLastOscTitle } from '../../shared/agent-detection' import { collectHeadlessOscLinkRanges } from './headless-osc-link-ranges' import { extractOscScanTail, scanOsc7Uris } from './osc7-uri-extraction' import { parseFileUriPath } from './osc7-file-uri' +import { TerminalPrivateModeTracker } from './terminal-private-mode-tracker' import type { TerminalSnapshot, TerminalModes } from './types' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' @@ -12,6 +15,8 @@ export type HeadlessEmulatorOptions = { cols: number rows: number scrollback?: number + pathFlavor?: 'posix' | 'win32' + remotePosixFileUriAuthority?: boolean } type TerminalWithSynchronousWrite = Terminal & { @@ -22,10 +27,6 @@ type TerminalWithSynchronousWrite = Terminal & { const DEFAULT_SCROLLBACK = 5000 const OSC_SCAN_TAIL_LIMIT = 4096 -// Why: PTY/SSH chunks can split a long combined DECSET before the final h/l. -// Keep parser state far beyond normal mode lists while still bounding memory. -const PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096 -type MouseTrackingMode = NonNullable export class HeadlessEmulator { private terminal: Terminal @@ -33,14 +34,15 @@ export class HeadlessEmulator { private cwd: string | null = null private lastTitle: string | null = null private oscScanTail = '' - private privateModeScanTail = '' - private mouseTrackingMode: MouseTrackingMode = 'none' - private sgrMouseMode = false - private sgrMousePixelsMode = false + private privateModes = new TerminalPrivateModeTracker() private restoredOscLinks: TerminalOscLinkRange[] = [] private disposed = false + private readonly pathFlavor?: 'posix' | 'win32' + private readonly remotePosixFileUriAuthority: boolean constructor(opts: HeadlessEmulatorOptions) { + this.pathFlavor = opts.pathFlavor + this.remotePosixFileUriAuthority = opts.remotePosixFileUriAuthority === true this.terminal = new Terminal({ cols: opts.cols, rows: opts.rows, @@ -52,6 +54,14 @@ export class HeadlessEmulator { this.serializer = new SerializeAddon() this.terminal.loadAddon(this.serializer) + // Why: this mirror must measure character widths exactly like the + // renderer's xterm (Unicode 11 + ZWJ emoji joining). With the default v6 + // tables, emoji-dense rows (agent status lines) advance the cursor + // differently here than on screen, so the mirrored buffer accumulates + // cell-shifted tears that snapshot restores then paint back as garbage. + this.terminal.loadAddon(new Unicode11Addon()) + activateOrcaTerminalUnicodeProvider(this.terminal) + // Why no onData wiring: this emulator exists purely for state tracking // (snapshots, cwd, mode flags). It MUST NOT respond to terminal query // sequences (DA1/DA2, DSR, OSC 10/11/12, DECRPM). The emulator parses @@ -78,7 +88,7 @@ export class HeadlessEmulator { this.terminal.write(data, () => { // Why: snapshots combine serialized xterm state with mirrored mouse // modes. Commit the mirror only after xterm has parsed the same bytes. - this.scanPrivateModes(data) + this.privateModes.scan(data) resolve() }) }) @@ -104,7 +114,7 @@ export class HeadlessEmulator { // Why: hidden renderer restore snapshots are requested immediately after // PTY bursts; queued headless writes can snapshot half-cleared TUI rows. writeSync.call((this.terminal as TerminalWithSynchronousWrite)._core, data) - this.scanPrivateModes(data) + this.privateModes.scan(data) return true } @@ -207,78 +217,6 @@ export class HeadlessEmulator { return extractOscScanTail(input, OSC_SCAN_TAIL_LIMIT) } - private scanPrivateModes(data: string): void { - const input = this.privateModeScanTail + data - this.privateModeScanTail = this.extractPrivateModeScanTail(input) - // oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars - const privateModeRe = /\x1bc|\x1b\[\?([0-9;]+)([hl])|\x9b\?([0-9;]+)([hl])/g - let match: RegExpExecArray | null - while ((match = privateModeRe.exec(input)) !== null) { - if (match[0] === '\x1bc') { - this.mouseTrackingMode = 'none' - this.sgrMouseMode = false - this.sgrMousePixelsMode = false - continue - } - const params = match[1] ?? match[3] - const enabled = (match[2] ?? match[4]) === 'h' - for (const rawParam of params.split(';')) { - if (rawParam === '') { - continue - } - const param = Number(rawParam) - if (!Number.isInteger(param)) { - continue - } - if (param === 9) { - this.mouseTrackingMode = enabled ? 'x10' : 'none' - } - if (param === 1000) { - this.mouseTrackingMode = enabled ? 'vt200' : 'none' - } - if (param === 1002) { - this.mouseTrackingMode = enabled ? 'drag' : 'none' - } - if (param === 1003) { - this.mouseTrackingMode = enabled ? 'any' : 'none' - } - if (param === 1006) { - this.sgrMouseMode = enabled - this.sgrMousePixelsMode = false - } - if (param === 1016) { - this.sgrMouseMode = false - this.sgrMousePixelsMode = enabled - } - } - } - } - - private extractPrivateModeScanTail(input: string): string { - const start = Math.max(input.lastIndexOf('\x1b'), input.lastIndexOf('\x9b')) - if (start === -1) { - return '' - } - const tail = input.slice(start) - if (tail.length > PRIVATE_MODE_SCAN_TAIL_LIMIT) { - return '' - } - if (tail === '\x1b' || tail === '\x1b[' || tail === '\x9b') { - return tail - } - if (tail.startsWith('\x1b[?')) { - return this.isIncompletePrivateModeParams(tail.slice(3)) ? tail : '' - } - if (tail.startsWith('\x9b?')) { - return this.isIncompletePrivateModeParams(tail.slice(2)) ? tail : '' - } - return '' - } - - private isIncompletePrivateModeParams(params: string): boolean { - return /^[0-9;]*$/.test(params) - } - private normalizeSnapshotAnsiForModes(snapshotAnsi: string, modes: TerminalModes): string { if (!modes.alternateScreen) { return snapshotAnsi @@ -295,7 +233,10 @@ export class HeadlessEmulator { } private parseOsc7Uri(uri: string): void { - const parsed = parseFileUriPath(uri) + const parsed = parseFileUriPath(uri, { + pathFlavor: this.pathFlavor, + remotePosixAuthority: this.remotePosixFileUriAuthority + }) if (parsed) { this.cwd = parsed } @@ -303,13 +244,13 @@ export class HeadlessEmulator { private getModes(): TerminalModes { const buffer = this.terminal.buffer.active - const mouseTrackingMode = this.mouseTrackingMode + const mouseTrackingMode = this.privateModes.mouseTrackingMode return { bracketedPaste: this.terminal.modes.bracketedPasteMode, mouseTracking: mouseTrackingMode !== 'none', mouseTrackingMode, - sgrMouseMode: this.sgrMouseMode, - sgrMousePixelsMode: this.sgrMousePixelsMode, + sgrMouseMode: this.privateModes.sgrMouseMode, + sgrMousePixelsMode: this.privateModes.sgrMousePixelsMode, applicationCursor: buffer.type === 'normal' ? this.terminal.modes.applicationCursorKeysMode : false, alternateScreen: buffer.type === 'alternate' diff --git a/src/main/daemon/history-reader.ts b/src/main/daemon/history-reader.ts index 2914ed21aa7..f6661b50044 100644 --- a/src/main/daemon/history-reader.ts +++ b/src/main/daemon/history-reader.ts @@ -28,12 +28,28 @@ export class HistoryReader { this.basePath = basePath } - detectColdRestore(sessionId: string): ColdRestoreInfo | null { + // Why: spawn needs a cheap "could this cold-restore?" predicate before + // deciding to pay detectColdRestore's full checkpoint+log replay. Reads only + // the small meta.json, using the same unclean-shutdown test detectColdRestore + // starts with. + hasRestorableHistory(sessionId: string): boolean { + const meta = this.readMeta(sessionId) + return meta !== null && meta.endedAt === null + } + + detectColdRestore( + sessionId: string, + opts?: { ignoreCleanEnd?: boolean } + ): ColdRestoreInfo | null { const meta = this.readMeta(sessionId) if (!meta) { return null } - if (meta.endedAt !== null) { + // Why ignoreCleanEnd: in the spawn probe race, the dying session's exit + // event can write endedAt between the aliveness probe and the post-spawn + // fallback detect. The caller established restore eligibility before the + // probe, so the just-written clean end must not downgrade the restore. + if (meta.endedAt !== null && !opts?.ignoreCleanEnd) { return null } diff --git a/src/main/daemon/osc7-file-uri.test.ts b/src/main/daemon/osc7-file-uri.test.ts new file mode 100644 index 00000000000..faa84879c55 --- /dev/null +++ b/src/main/daemon/osc7-file-uri.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { parseFileUriPath, parseFileUriPathParts } from './osc7-file-uri' + +describe('parseFileUriPath', () => { + it('accepts host-qualified POSIX file URI authorities as plain paths', () => { + expect(parseFileUriPath('file://remote-host/tmp/result.json')).toBe('/tmp/result.json') + }) + + it('accepts empty and localhost POSIX file URI authorities', () => { + expect(parseFileUriPath('file:///tmp/result.json')).toBe('/tmp/result.json') + expect(parseFileUriPath('file://localhost/tmp/result.json')).toBe('/tmp/result.json') + }) + + it('keeps remote OSC7 authorities as POSIX paths for SSH PTYs on Windows', () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + expect( + parseFileUriPathParts('file://remote-host/home/me/repo', { + remotePosixAuthority: true + }) + ).toEqual({ path: '/home/me/repo', hostname: 'remote-host' }) + expect(parseFileUriPathParts('file://server/share/repo')).toEqual({ + path: '\\\\server\\share\\repo', + hostname: 'server' + }) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('parses Windows SSH OSC7 drive paths independent of the desktop platform', () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + try { + expect(parseFileUriPathParts('file:///C:/Users/me/repo', { pathFlavor: 'win32' })).toEqual({ + path: 'C:/Users/me/repo', + hostname: '' + }) + expect( + parseFileUriPathParts('file://remote-host/C:/Users/me/repo', { pathFlavor: 'win32' }) + ).toEqual({ + path: 'C:/Users/me/repo', + hostname: 'remote-host' + }) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) +}) diff --git a/src/main/daemon/osc7-file-uri.ts b/src/main/daemon/osc7-file-uri.ts index 5ae2dd06d52..f931174ee03 100644 --- a/src/main/daemon/osc7-file-uri.ts +++ b/src/main/daemon/osc7-file-uri.ts @@ -1,4 +1,24 @@ -export function parseFileUriPath(uri: string): string | null { +export type ParsedFileUriPath = { + path: string + hostname: string +} + +export type ParseFileUriPathOptions = { + pathFlavor?: 'posix' | 'win32' + remotePosixAuthority?: boolean +} + +export function parseFileUriPath( + uri: string, + options: ParseFileUriPathOptions = {} +): string | null { + return parseFileUriPathParts(uri, options)?.path ?? null +} + +export function parseFileUriPathParts( + uri: string, + options: ParseFileUriPathOptions = {} +): ParsedFileUriPath | null { try { const url = new URL(uri) if (url.protocol !== 'file:') { @@ -6,19 +26,21 @@ export function parseFileUriPath(uri: string): string | null { } const decodedPath = decodeURIComponent(url.pathname) - if (process.platform !== 'win32') { - return decodedPath + const hostname = url.hostname.toLowerCase() + const pathFlavor = options.pathFlavor ?? (process.platform === 'win32' ? 'win32' : 'posix') + if (pathFlavor !== 'win32') { + return { path: decodedPath, hostname } } if (/^\/[A-Za-z]:/.test(decodedPath)) { - return decodedPath.slice(1) + return { path: decodedPath.slice(1), hostname } } // Why: localhost/empty-host OSC-7 URIs are POSIX paths even when parsed by // a Windows app; only non-local hosts describe Windows UNC shares. - if (url.hostname && url.hostname !== 'localhost') { - return `\\\\${url.hostname}${decodedPath.replace(/\//g, '\\')}` + if (hostname && hostname !== 'localhost' && !options.remotePosixAuthority) { + return { path: `\\\\${url.hostname}${decodedPath.replace(/\//g, '\\')}`, hostname } } - return decodedPath + return { path: decodedPath, hostname } } catch { return null } diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 551b8ad276c..b4d3433a5f8 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -56,6 +56,7 @@ export type SessionOptions = { sessionId: string cols: number rows: number + terminalHandle?: string subprocess: SubprocessHandle shellReadySupported: boolean shellReadyTimeoutMs?: number @@ -76,6 +77,7 @@ type AttachedClient = { export class Session { readonly sessionId: string + readonly terminalHandle: string | null private _state: SessionState = 'running' private _shellState: ShellReadyState private _exitCode: number | null = null @@ -97,6 +99,7 @@ export class Session { constructor(opts: SessionOptions) { this.sessionId = opts.sessionId + this.terminalHandle = opts.terminalHandle ?? null this.subprocess = opts.subprocess this.onSessionExit = opts.onExit const size = normalizePtySize(opts.cols, opts.rows) diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index 4731322e526..41e1f96b1bb 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -136,6 +136,7 @@ export class TerminalHost { sessionId: opts.sessionId, cols: size.cols, rows: size.rows, + terminalHandle: opts.env?.ORCA_TERMINAL_HANDLE, subprocess, shellReadySupported: opts.shellReadySupported ?? false, // Why: reap the dead session (dispose emulator + drop from the map) the @@ -301,6 +302,7 @@ export class TerminalHost { state: session.state, shellState: session.shellState, isAlive: true, + ...(session.terminalHandle ? { terminalHandle: session.terminalHandle } : {}), pid: session.pid, cwd: session.getCwd(), cols: size?.cols ?? 0, diff --git a/src/main/daemon/terminal-private-mode-tracker.ts b/src/main/daemon/terminal-private-mode-tracker.ts new file mode 100644 index 00000000000..a8f20ef7a4d --- /dev/null +++ b/src/main/daemon/terminal-private-mode-tracker.ts @@ -0,0 +1,89 @@ +import type { TerminalModes } from './types' + +// Why: PTY/SSH chunks can split a long combined DECSET before the final h/l. +// Keep parser state far beyond normal mode lists while still bounding memory. +const PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096 + +type MouseTrackingMode = NonNullable + +// Why: @xterm/headless doesn't expose DECSET mouse/SGR encoding state, so the +// emulator mirrors it by scanning the raw byte stream; snapshots restore these +// modes on rehydrate. +export class TerminalPrivateModeTracker { + private scanTail = '' + mouseTrackingMode: MouseTrackingMode = 'none' + sgrMouseMode = false + sgrMousePixelsMode = false + + scan(data: string): void { + const input = this.scanTail + data + this.scanTail = this.extractScanTail(input) + // oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars + const privateModeRe = /\x1bc|\x1b\[\?([0-9;]+)([hl])|\x9b\?([0-9;]+)([hl])/g + let match: RegExpExecArray | null + while ((match = privateModeRe.exec(input)) !== null) { + if (match[0] === '\x1bc') { + this.mouseTrackingMode = 'none' + this.sgrMouseMode = false + this.sgrMousePixelsMode = false + continue + } + const params = match[1] ?? match[3] + const enabled = (match[2] ?? match[4]) === 'h' + for (const rawParam of params.split(';')) { + if (rawParam === '') { + continue + } + const param = Number(rawParam) + if (!Number.isInteger(param)) { + continue + } + if (param === 9) { + this.mouseTrackingMode = enabled ? 'x10' : 'none' + } + if (param === 1000) { + this.mouseTrackingMode = enabled ? 'vt200' : 'none' + } + if (param === 1002) { + this.mouseTrackingMode = enabled ? 'drag' : 'none' + } + if (param === 1003) { + this.mouseTrackingMode = enabled ? 'any' : 'none' + } + if (param === 1006) { + this.sgrMouseMode = enabled + this.sgrMousePixelsMode = false + } + if (param === 1016) { + this.sgrMouseMode = false + this.sgrMousePixelsMode = enabled + } + } + } + } + + private extractScanTail(input: string): string { + const start = Math.max(input.lastIndexOf('\x1b'), input.lastIndexOf('\x9b')) + if (start === -1) { + return '' + } + const tail = input.slice(start) + if (tail.length > PRIVATE_MODE_SCAN_TAIL_LIMIT) { + return '' + } + if (tail === '\x1b' || tail === '\x1b[' || tail === '\x9b') { + return tail + } + if (tail.startsWith('\x1b[?')) { + return this.isIncompleteParams(tail.slice(3)) ? tail : '' + } + if (tail.startsWith('\x9b?')) { + return this.isIncompleteParams(tail.slice(2)) ? tail : '' + } + return '' + } + + private isIncompleteParams(params: string): boolean { + return /^[0-9;]*$/.test(params) + } +} diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 868afa81610..a38cc21a425 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -324,6 +324,7 @@ export type SessionInfo = { state: SessionState shellState: ShellReadyState isAlive: boolean + terminalHandle?: string pid: number | null cwd: string | null cols: number diff --git a/src/main/daemon/windows-conpty-warmup.test.ts b/src/main/daemon/windows-conpty-warmup.test.ts new file mode 100644 index 00000000000..1ab19b0c97e --- /dev/null +++ b/src/main/daemon/windows-conpty-warmup.test.ts @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type * as pty from 'node-pty' +import { warmWindowsConptyOnce } from './windows-conpty-warmup' + +function setPlatform(platform: NodeJS.Platform): () => void { + const original = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) + return () => Object.defineProperty(process, 'platform', { configurable: true, value: original }) +} + +function flushImmediates(): Promise { + return new Promise((resolve) => setImmediate(resolve)) +} + +let restorePlatform: (() => void) | null = null +afterEach(() => { + restorePlatform?.() + restorePlatform = null + vi.restoreAllMocks() +}) + +function makeFakePty(): { proc: pty.IPty; fireExit: () => void } { + let exitListener: (() => void) | null = null + const proc = { + pid: 4321, + kill: vi.fn(), + onExit: vi.fn((listener: () => void) => { + exitListener = listener + return { dispose: () => undefined } + }) + } as unknown as pty.IPty + return { proc, fireExit: () => exitListener?.() } +} + +describe('warmWindowsConptyOnce', () => { + it('is a no-op off Windows', async () => { + restorePlatform = setPlatform('darwin') + const spawnPty = vi.fn() as unknown as typeof pty.spawn + + warmWindowsConptyOnce(spawnPty) + await flushImmediates() + + expect(spawnPty).not.toHaveBeenCalled() + }) + + it('spawns a short-lived cmd.exe with the bundled ConPTY on Windows', async () => { + restorePlatform = setPlatform('win32') + const { proc, fireExit } = makeFakePty() + const spawnPty = vi.fn(() => proc) as unknown as typeof pty.spawn + + warmWindowsConptyOnce(spawnPty) + await flushImmediates() + + expect(spawnPty).toHaveBeenCalledTimes(1) + const [file, args, options] = vi.mocked(spawnPty).mock.calls[0] + expect(String(file).toLowerCase()).toContain('cmd') + expect(args).toEqual(['/c', 'exit']) + expect(options).toMatchObject({ useConptyDll: true, cols: 2, rows: 1 }) + + // A clean exit must not leave the kill timer to fire later. + fireExit() + expect(proc.kill).not.toHaveBeenCalled() + }) + + it('kills the warm-up shell if it never exits', async () => { + restorePlatform = setPlatform('win32') + vi.useFakeTimers() + try { + const { proc } = makeFakePty() + const spawnPty = vi.fn(() => proc) as unknown as typeof pty.spawn + + warmWindowsConptyOnce(spawnPty) + await vi.runOnlyPendingTimersAsync() + vi.advanceTimersByTime(10_000) + + expect(proc.kill).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('swallows spawn failures', async () => { + restorePlatform = setPlatform('win32') + const spawnPty = vi.fn(() => { + throw new Error('conpty unavailable') + }) as unknown as typeof pty.spawn + + expect(() => warmWindowsConptyOnce(spawnPty)).not.toThrow() + await flushImmediates() + }) +}) diff --git a/src/main/daemon/windows-conpty-warmup.ts b/src/main/daemon/windows-conpty-warmup.ts new file mode 100644 index 00000000000..aea1c50b2db --- /dev/null +++ b/src/main/daemon/windows-conpty-warmup.ts @@ -0,0 +1,45 @@ +import os from 'node:os' +import * as pty from 'node-pty' + +const WARMUP_KILL_TIMEOUT_MS = 10_000 + +/** + * Pays the one-time cost of the first ConPTY spawn (conpty native module + * load, bundled conpty.dll + OpenConsole.exe first launch, Defender scans of + * those binaries) at daemon boot instead of on the user's first terminal. + * Measured ~2.7s on a Windows dev profile for the first spawn vs ~70ms after. + */ +export function warmWindowsConptyOnce(spawnPty: typeof pty.spawn = pty.spawn): void { + if (process.platform !== 'win32') { + return + } + // Why: setImmediate keeps the ready/handshake path ahead of the warm-up; a + // real spawn arriving first simply does the warming itself. + setImmediate(() => { + try { + const proc = spawnPty(process.env.COMSPEC || 'cmd.exe', ['/c', 'exit'], { + name: 'xterm-256color', + cols: 2, + rows: 1, + cwd: os.homedir(), + env: process.env as Record, + // Match real terminal spawns so the bundled ConPTY binaries are the + // ones warmed, not the legacy system ConPTY. + useConptyDll: true + }) + const killTimer = setTimeout(() => { + try { + proc.kill() + } catch { + /* best-effort cleanup of a stuck warm-up shell */ + } + }, WARMUP_KILL_TIMEOUT_MS) + killTimer.unref?.() + proc.onExit(() => { + clearTimeout(killTimer) + }) + } catch { + /* warm-up is best-effort; real spawns surface their own errors */ + } + }) +} diff --git a/src/main/emulator/backends/ios-emulator-backend.test.ts b/src/main/emulator/backends/ios-emulator-backend.test.ts index d82e05231b2..fd7df588f19 100644 --- a/src/main/emulator/backends/ios-emulator-backend.test.ts +++ b/src/main/emulator/backends/ios-emulator-backend.test.ts @@ -3,6 +3,7 @@ import type { SimulatorDevice } from '../simctl-simulator-devices' import type { ServeSimHelperProcess } from '../serve-sim-helper-processes' const { + ensureSimulatorBootedMock, execServeSimCommandMock, hideNativeSimulatorAppMock, killServeSimHelperProcessesForDeviceMock, @@ -12,7 +13,8 @@ const { sendEmulatorGestureSequenceMock, parseServeSimDetachedSessionMock } = vi.hoisted(() => ({ - execServeSimCommandMock: vi.fn(async () => ({})), + ensureSimulatorBootedMock: vi.fn(async () => {}), + execServeSimCommandMock: vi.fn(async (_executable?: unknown, _args?: string[]) => ({})), hideNativeSimulatorAppMock: vi.fn(async () => {}), killServeSimHelperProcessesForDeviceMock: vi.fn(async () => {}), listSimulatorDevicesMock: vi.fn(async (): Promise => []), @@ -30,7 +32,7 @@ vi.mock('../serve-sim-execution', () => ({ })) vi.mock('../simctl-simulator-devices', () => ({ - ensureSimulatorBooted: vi.fn(async () => {}), + ensureSimulatorBooted: ensureSimulatorBootedMock, listSimulatorDevices: listSimulatorDevicesMock, resolveSimulatorUdid: vi.fn(async (device: string) => device), shutdownSimulatorDevice: shutdownSimulatorDeviceMock @@ -53,12 +55,15 @@ vi.mock('../serve-sim-detached-session', () => ({ parseServeSimDetachedSession: parseServeSimDetachedSessionMock })) +import { EmulatorError } from '../emulator-errors' import { IosEmulatorBackend } from './ios-emulator-backend' const EXECUTABLE = { command: '/serve-sim', env: {} } describe('IosEmulatorBackend', () => { beforeEach(() => { + ensureSimulatorBootedMock.mockReset() + ensureSimulatorBootedMock.mockImplementation(async () => {}) execServeSimCommandMock.mockReset() execServeSimCommandMock.mockImplementation(async () => ({})) listSimulatorDevicesMock.mockReset() @@ -186,6 +191,113 @@ describe('IosEmulatorBackend', () => { expect(hideNativeSimulatorAppMock).toHaveBeenCalledTimes(1) }) + it('recycles the device and retries when the helper finds no framebuffer', async () => { + // The real-world failure: simctl reports Booted but the display IO ports + // never came up, so serve-sim --detach dies with the framebuffer error. + execServeSimCommandMock + .mockRejectedValueOnce( + new EmulatorError( + 'emulator_error', + 'Helper failed:\n[main] Starting serve-sim-bin\n[main] Failed to start capture: No framebuffer display descriptor found' + ) + ) + .mockResolvedValueOnce({}) + parseServeSimDetachedSessionMock.mockReturnValue({ + deviceUdid: 'device-1', + streamUrl: 'http://127.0.0.1:3102/stream.mjpeg', + wsUrl: 'ws://127.0.0.1:3102', + helperPid: 1234 + }) + const backend = new IosEmulatorBackend({ waitForEndpointReady: async () => true }) + const info = await backend.startSession('device-1') + expect(info.deviceUdid).toBe('device-1') + expect(shutdownSimulatorDeviceMock).toHaveBeenCalledWith('device-1') + // Booted once up front, again after the recycle shutdown. + expect(ensureSimulatorBootedMock).toHaveBeenCalledTimes(2) + expect(execServeSimCommandMock).toHaveBeenCalledTimes(2) + }) + + it('does not recycle the device for unrelated helper start failures', async () => { + execServeSimCommandMock.mockRejectedValueOnce( + new EmulatorError('emulator_error', 'Helper failed:\n[main] Port 3100 already in use') + ) + const backend = new IosEmulatorBackend({ waitForEndpointReady: async () => true }) + await expect(backend.startSession('device-1')).rejects.toMatchObject({ + message: expect.stringContaining('Port 3100 already in use') + }) + expect(shutdownSimulatorDeviceMock).not.toHaveBeenCalled() + expect(execServeSimCommandMock).toHaveBeenCalledTimes(1) + }) + + it('propagates mid-recycle failures instead of masking them', async () => { + execServeSimCommandMock.mockRejectedValue( + new EmulatorError( + 'emulator_error', + 'Helper failed:\n[main] Failed to start capture: No framebuffer display descriptor found' + ) + ) + shutdownSimulatorDeviceMock.mockRejectedValueOnce( + new EmulatorError('emulator_error', 'xcrun simctl shutdown timed out') + ) + const backend = new IosEmulatorBackend({ waitForEndpointReady: async () => true }) + await expect(backend.startSession('device-1')).rejects.toMatchObject({ + message: expect.stringContaining('timed out') + }) + expect(execServeSimCommandMock).toHaveBeenCalledTimes(1) + }) + + it('surfaces an actionable error when the display stays wedged after one recycle', async () => { + execServeSimCommandMock.mockRejectedValue( + new EmulatorError( + 'emulator_error', + 'Helper failed:\n[main] Failed to start capture: No framebuffer display descriptor found' + ) + ) + const backend = new IosEmulatorBackend({ waitForEndpointReady: async () => true }) + await expect(backend.startSession('device-1')).rejects.toMatchObject({ + code: 'emulator_helper_failed', + // Actionable headline plus the raw helper log for diagnosis. + message: expect.stringMatching(/simctl erase[\s\S]*No framebuffer display descriptor found/) + }) + // Exactly one recycle attempt; no shutdown/boot loop against a broken device. + expect(shutdownSimulatorDeviceMock).toHaveBeenCalledTimes(1) + expect(execServeSimCommandMock).toHaveBeenCalledTimes(2) + }) + + it('does not recycle more than once during one start attempt', async () => { + let detachCalls = 0 + execServeSimCommandMock.mockImplementation( + async (_executable?: unknown, args: string[] = []) => { + if (args[0] !== '--detach') { + return {} + } + detachCalls += 1 + if (detachCalls === 2) { + return {} + } + throw new EmulatorError( + 'emulator_error', + 'Helper failed:\n[main] Failed to start capture: No framebuffer display descriptor found' + ) + } + ) + parseServeSimDetachedSessionMock.mockReturnValue({ + deviceUdid: 'device-1', + streamUrl: 'http://127.0.0.1:3102/stream.mjpeg', + wsUrl: 'ws://127.0.0.1:3102', + helperPid: 1234 + }) + const backend = new IosEmulatorBackend({ waitForEndpointReady: async () => false }) + + await expect(backend.startSession('device-1')).rejects.toMatchObject({ + code: 'emulator_helper_failed', + message: expect.stringContaining('even after a reboot') + }) + expect(detachCalls).toBe(3) + expect(shutdownSimulatorDeviceMock).toHaveBeenCalledTimes(1) + expect(ensureSimulatorBootedMock).toHaveBeenCalledTimes(2) + }) + it('stops a helper via serve-sim kill plus the orphan sweep', async () => { const backend = new IosEmulatorBackend() await backend.stopHelperForDevice('device-1', { helperPid: 1234, includeOrphaned: true }) diff --git a/src/main/emulator/backends/ios-emulator-backend.ts b/src/main/emulator/backends/ios-emulator-backend.ts index 36cf6091242..8d3c31604e4 100644 --- a/src/main/emulator/backends/ios-emulator-backend.ts +++ b/src/main/emulator/backends/ios-emulator-backend.ts @@ -190,9 +190,43 @@ export class IosEmulatorBackend implements EmulatorBackend { return false } - let info = await startDetachedHelper() + const throwPersistentMissingFramebuffer = (error: EmulatorError): never => { + throw new EmulatorError( + 'emulator_helper_failed', + `Simulator ${udid} keeps booting without a working display (no framebuffer descriptor), even after a reboot. Erase it with \`xcrun simctl erase ${udid}\` or recreate it in Xcode > Window > Devices and Simulators.\n\n${error.message}` + ) + } + + // Why: CoreSimulator can report "Booted" with the display IO ports down + // (HID alive, no framebuffer); a shutdown/boot recycle is the only recovery. + let didRecycleWedgedBoot = false + const startHelperRecyclingWedgedBoot = async (): Promise => { + try { + return await startDetachedHelper() + } catch (error) { + if (!isMissingFramebufferError(error)) { + throw error + } + if (didRecycleWedgedBoot) { + return throwPersistentMissingFramebuffer(error) + } + didRecycleWedgedBoot = true + await shutdownSimulatorDevice(udid) + await ensureSimulatorBooted(udid) + try { + return await startDetachedHelper() + } catch (retryError) { + if (!isMissingFramebufferError(retryError)) { + throw retryError + } + return throwPersistentMissingFramebuffer(retryError) + } + } + } + + let info = await startHelperRecyclingWedgedBoot() if (!(await waitForReadyOrKill(info))) { - info = await startDetachedHelper() + info = await startHelperRecyclingWedgedBoot() if (!(await waitForReadyOrKill(info))) { throw new EmulatorError( 'emulator_helper_failed', @@ -242,6 +276,18 @@ export class IosEmulatorBackend implements EmulatorBackend { } } +// Why: serve-sim's capture helper prints exactly this when a booted device has +// no com.apple.framebuffer.display IO port: the signature of a wedged boot. +const MISSING_FRAMEBUFFER_RE = /No framebuffer display descriptor found/i + +function isMissingFramebufferError(error: unknown): error is EmulatorError { + return ( + error instanceof EmulatorError && + error.code === 'emulator_error' && + MISSING_FRAMEBUFFER_RE.test(error.message) + ) +} + function toEmulatorDevice(device: SimulatorDevice): EmulatorDevice { return { backend: 'ios', diff --git a/src/main/emulator/simctl-simulator-devices.test.ts b/src/main/emulator/simctl-simulator-devices.test.ts index ba7c54aa1fc..deff0defbe6 100644 --- a/src/main/emulator/simctl-simulator-devices.test.ts +++ b/src/main/emulator/simctl-simulator-devices.test.ts @@ -22,7 +22,7 @@ vi.mock('./serve-sim-execution', () => ({ execServeSimCommand: vi.fn() })) -import { listSimulatorDevices } from './simctl-simulator-devices' +import { listSimulatorDevices, shutdownSimulatorDevice } from './simctl-simulator-devices' describe('listSimulatorDevices', () => { beforeEach(() => { @@ -94,3 +94,64 @@ describe('listSimulatorDevices', () => { expect((error as Error).message).not.toContain('Command failed') }) }) + +describe('shutdownSimulatorDevice', () => { + beforeEach(() => { + execFileMock.mockReset() + platformMock.mockReset() + platformMock.mockReturnValue('darwin') + }) + + it('treats an already-shut-down device as success', async () => { + execFileMock.mockImplementation((_command, _args, _options, callback) => { + callback( + Object.assign( + new Error('Command failed: xcrun simctl shutdown AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE'), + { code: 164 } + ), + '', + 'Unable to shutdown device in current state: Shutdown' + ) + }) + + await expect( + shutdownSimulatorDevice('AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE') + ).resolves.toBeUndefined() + }) + + it('propagates real shutdown failures instead of swallowing them', async () => { + // execFile's message echoes the command line, which always contains + // "shutdown"; a hung device (timeout kill) must still reject. + execFileMock.mockImplementation((_command, _args, _options, callback) => { + callback( + Object.assign( + new Error('Command failed: xcrun simctl shutdown AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE'), + { killed: true, signal: 'SIGTERM' } + ), + '', + '' + ) + }) + + await expect( + shutdownSimulatorDevice('AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE') + ).rejects.toMatchObject({ code: 'emulator_error' }) + }) + + it('rejects current-state failures that are not already shut down', async () => { + execFileMock.mockImplementation((_command, _args, _options, callback) => { + callback( + Object.assign( + new Error('Command failed: xcrun simctl shutdown AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE'), + { code: 164 } + ), + '', + 'Unable to shutdown device in current state: Booting' + ) + }) + + await expect( + shutdownSimulatorDevice('AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE') + ).rejects.toMatchObject({ code: 'emulator_error' }) + }) +}) diff --git a/src/main/emulator/simctl-simulator-devices.ts b/src/main/emulator/simctl-simulator-devices.ts index c9f86ee9812..6c913d45cc4 100644 --- a/src/main/emulator/simctl-simulator-devices.ts +++ b/src/main/emulator/simctl-simulator-devices.ts @@ -191,8 +191,10 @@ export async function shutdownSimulatorDevice(udid: string): Promise { resolve() return } - const message = error.message.toLowerCase() - if (message.includes('shutdown') || message.includes('current state')) { + // Why: execFile's message echoes the command line, so only the actual + // already-off state is idempotent; other current states are real failures. + const message = `${error.message}\n${stderr?.toString() ?? ''}`.toLowerCase() + if (/\bcurrent state:\s*shutdown\b/.test(message)) { resolve() return } diff --git a/src/main/ipc/pty-spawn-timing.ts b/src/main/ipc/pty-spawn-timing.ts new file mode 100644 index 00000000000..0ab0e12c800 --- /dev/null +++ b/src/main/ipc/pty-spawn-timing.ts @@ -0,0 +1,41 @@ +// Why: pty:spawn latency has four very different suspects (startup barrier, +// Claude auth prep, buildPtyHostEnv filesystem work, provider/daemon spawn). +// A single opt-in log line per spawn lets benchmarks attribute the cost +// without a tracing dependency. Enabled via ORCA_PTY_SPAWN_TIMING=1. + +export type PtySpawnTiming = { + mark(phase: string): void + log(id: string, extra?: Record): void +} + +const noopTiming: PtySpawnTiming = { + mark: () => undefined, + log: () => undefined +} + +export function createPtySpawnTiming(): PtySpawnTiming { + const flag = process.env.ORCA_PTY_SPAWN_TIMING + if (!flag || flag === '0' || flag.toLowerCase() === 'false') { + return noopTiming + } + const startedAt = Date.now() + let lastAt = startedAt + const phases: string[] = [] + return { + mark(phase: string): void { + const now = Date.now() + phases.push(`${phase}=${now - lastAt}ms`) + lastAt = now + }, + log(id: string, extra?: Record): void { + const extras = extra + ? ` ${Object.entries(extra) + .map(([key, value]) => `${key}=${value}`) + .join(' ')}` + : '' + console.log( + `[pty-spawn-timing] id=${id} total=${Date.now() - startedAt}ms ${phases.join(' ')}${extras}` + ) + } + } +} diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 0afc0c3e4e0..26c1bda9157 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -2331,7 +2331,9 @@ describe('registerPtyHandlers', () => { } expect(controller.kill('remote-pty')).toBe(true) - await Promise.resolve() + // Why: kill's shutdown now runs through the exit-detection wrapper, + // which adds async hops; a single microtask flush is no longer enough. + await new Promise((resolve) => setImmediate(resolve)) expect(shutdown).toHaveBeenCalledWith('remote-pty', { immediate: false }) expect(store.markSshRemotePtyLease).toHaveBeenCalledWith( @@ -2342,6 +2344,113 @@ describe('registerPtyHandlers', () => { expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1) }) + it('controller kill does not duplicate exits when the provider emits exit during shutdown', async () => { + const exitListeners = new Set<(payload: { id: string; code: number }) => void>() + const shutdown = vi.fn(async (id: string) => { + for (const listener of exitListeners) { + listener({ id, code: 0 }) + } + }) + const runtime = { + setPtyController: vi.fn(), + onPtyExit: vi.fn() + } + setLocalPtyProvider({ + spawn: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + shutdown, + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn((listener: (payload: { id: string; code: number }) => void) => { + exitListeners.add(listener) + return () => exitListeners.delete(listener) + }), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as { + kill: (ptyId: string) => boolean + } + + expect(controller.kill('local-pty')).toBe(true) + await Promise.resolve() + await Promise.resolve() + + expect(runtime.onPtyExit).toHaveBeenCalledTimes(1) + expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0) + expect( + mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit') + ).toEqual([['pty:exit', { id: 'local-pty', code: 0 }]]) + }) + + it('controller stopAndWait skips the synthetic exit when the provider emitted one', async () => { + vi.useFakeTimers() + const exitListeners = new Set<(payload: { id: string; code: number }) => void>() + const shutdown = vi.fn(async (id: string) => { + for (const listener of exitListeners) { + listener({ id, code: 0 }) + } + }) + const runtime = { + setPtyController: vi.fn(), + onPtyExit: vi.fn() + } + setLocalPtyProvider({ + spawn: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + shutdown, + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn((listener: (payload: { id: string; code: number }) => void) => { + exitListeners.add(listener) + return () => exitListeners.delete(listener) + }), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as { + stopAndWait: (ptyId: string, opts?: { keepHistory?: boolean }) => Promise + } + + const stopPromise = controller.stopAndWait('local-pty') + await vi.advanceTimersByTimeAsync(1_200) + await expect(stopPromise).resolves.toBe(true) + + expect(runtime.onPtyExit).toHaveBeenCalledTimes(1) + expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0) + expect( + mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit') + ).toEqual([['pty:exit', { id: 'local-pty', code: 0 }]]) + }) + it('passes keepHistory through runtime controller stopAndWait', async () => { vi.useFakeTimers() const shutdown = vi.fn(async () => undefined) @@ -2566,7 +2675,7 @@ describe('registerPtyHandlers', () => { } expect(controller.kill('ssh:ssh-1@@relay-pty')).toBe(true) - await Promise.resolve() + await new Promise((resolve) => setImmediate(resolve)) expect(shutdown).toHaveBeenCalledWith('ssh:ssh-1@@relay-pty', { immediate: false }) expect(localShutdown).not.toHaveBeenCalled() @@ -2701,8 +2810,7 @@ describe('registerPtyHandlers', () => { try { expect(controller.kill('remote-pty')).toBe(true) - await Promise.resolve() - await Promise.resolve() + await new Promise((resolve) => setImmediate(resolve)) } finally { warnSpy.mockRestore() deletePtyOwnership('remote-pty') @@ -2911,6 +3019,104 @@ describe('registerPtyHandlers', () => { keepHistory: true }) expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', -1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:exit', { + id: 'local-pty', + code: -1 + }) + }) + + it('does not synthesize a duplicate renderer exit when kill emits provider exit', async () => { + const exitListeners = new Set<(payload: { id: string; code: number }) => void>() + const shutdown = vi.fn(async (id: string) => { + for (const listener of exitListeners) { + listener({ id, code: 0 }) + } + }) + const runtime = { + setPtyController: vi.fn(), + onPtyExit: vi.fn() + } + setLocalPtyProvider({ + spawn: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + shutdown, + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn((listener: (payload: { id: string; code: number }) => void) => { + exitListeners.add(listener) + return () => exitListeners.delete(listener) + }), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + + await handlers.get('pty:kill')!(null, { id: 'local-pty' }) + + expect(runtime.onPtyExit).toHaveBeenCalledTimes(1) + expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0) + expect(mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit')).toEqual( + [['pty:exit', { id: 'local-pty', code: 0 }]] + ) + }) + + it('ignores a late provider exit after synthesizing kill exit', async () => { + const exitListeners = new Set<(payload: { id: string; code: number }) => void>() + const runtime = { + setPtyController: vi.fn(), + onPtyExit: vi.fn() + } + setLocalPtyProvider({ + spawn: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(async () => undefined), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn((listener: (payload: { id: string; code: number }) => void) => { + exitListeners.add(listener) + return () => exitListeners.delete(listener) + }), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + + await handlers.get('pty:kill')!(null, { id: 'local-pty' }) + for (const listener of exitListeners) { + listener({ id: 'local-pty', code: 0 }) + } + + expect(runtime.onPtyExit).toHaveBeenCalledTimes(1) + expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', -1) + expect(mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit')).toEqual( + [['pty:exit', { id: 'local-pty', code: -1 }]] + ) }) it('waits for the desktop startup barrier before renderer local spawns resolve the provider', async () => { @@ -3623,6 +3829,62 @@ describe('registerPtyHandlers', () => { const reported = await handlers.get('pty:getSize')!(null, { id }) expect(reported).toEqual({ cols: 100, rows: 30 }) }) + + it('fans out accepted desktop resizes to the runtime after provider resize', async () => { + const resize = vi.fn() + setupProviderWithAppliedSize({ applied: { cols: 120, rows: 30 }, resize }) + const runtime = { + setPtyController: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => null), + registerPty: vi.fn(), + getDriver: vi.fn(() => ({ kind: 'host' })), + isResizeSuppressed: vi.fn(() => false), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(), + onExternalPtyResize: vi.fn() + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const spawn = await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, env: {} }) + const id = (spawn as { id: string }).id + + resizeListener()(mainWindowIpcEvent, { id, cols: 120, rows: 30 }) + + expect(resize).toHaveBeenCalledWith(id, 120, 30) + expect(runtime.onExternalPtyResize).toHaveBeenCalledWith(id, 120, 30) + expect(resize.mock.invocationCallOrder[0]).toBeLessThan( + runtime.onExternalPtyResize.mock.invocationCallOrder[0]! + ) + }) + + it('does not fan out rejected desktop resizes to the runtime', async () => { + setupProviderWithAppliedSize({ + applied: { cols: 80, rows: 24 }, + resize: () => { + throw new Error('resize rejected') + } + }) + const runtime = { + setPtyController: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => null), + registerPty: vi.fn(), + getDriver: vi.fn(() => ({ kind: 'host' })), + isResizeSuppressed: vi.fn(() => false), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(), + onExternalPtyResize: vi.fn() + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const spawn = await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, env: {} }) + const id = (spawn as { id: string }).id + + resizeListener()(mainWindowIpcEvent, { id, cols: 120, rows: 30 }) + + expect(runtime.onExternalPtyResize).not.toHaveBeenCalled() + }) }) it('injects ORCA_TERMINAL_HANDLE for non-local PTY providers', async () => { @@ -4108,7 +4370,8 @@ describe('registerPtyHandlers', () => { worktreeId: 'wt-1', tabId: 'tab-race', leafId, - ptyId: 'pty-shared' + ptyId: 'pty-shared', + startupCwd: '/tmp' }) }) @@ -4221,7 +4484,8 @@ describe('registerPtyHandlers', () => { worktreeId: 'wt-1', tabId: 'tab-race', leafId, - ptyId: 'pty-renderer' + ptyId: 'pty-renderer', + startupCwd: '/tmp' }) }) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 9f654ea1e1f..42806caac11 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -39,6 +39,7 @@ import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' import { SSH_SESSION_EXPIRED_ERROR, isSshPtyNotFoundError } from '../providers/ssh-pty-provider' import { parseAppSshPtyId, toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id' +import { createPtySpawnTiming } from './pty-spawn-timing' import { mintPtySessionId, isSafePtySessionId } from '../daemon/pty-session-id' import { addNodePtyRecoveryHint } from '../daemon/node-pty-error-hints' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' @@ -76,6 +77,7 @@ import { parseLegacyNumericPaneKey, parsePaneKey } from '../../shared/stable-pane-id' +import { resolveTerminalStartupCwdForWorkspace } from '../../shared/terminal-startup-cwd' import { clearMigrationUnsupportedPty, clearMigrationUnsupportedPtysForPaneKey @@ -111,6 +113,7 @@ type FreshLocalFallbackProvider = IPtyProvider & { routesFreshSpawnsToLocalProvider?: true } const sshProviders = new Map() +const SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS = 30_000 // Why: PTY IDs are assigned at spawn time with a connectionId, but subsequent // write/resize/kill calls only carry the PTY ID. This map lets us route // post-spawn operations to the correct provider without the renderer needing @@ -1656,6 +1659,82 @@ export function registerPtyHandlers( flushTimer = null } + const syntheticKillExitPtyIds = new Map() + + function rememberSyntheticKillExit(id: string): void { + const existing = syntheticKillExitPtyIds.get(id) + if (existing) { + clearTimeout(existing) + } + // Why: some providers can report the real exit after kill has already + // completed; skip only that late duplicate, not a future reused id forever. + const cleanupTimer = setTimeout(() => { + syntheticKillExitPtyIds.delete(id) + }, SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS) + cleanupTimer.unref?.() + syntheticKillExitPtyIds.set(id, cleanupTimer) + } + + function consumeSyntheticKillExit(id: string): boolean { + const cleanupTimer = syntheticKillExitPtyIds.get(id) + if (!cleanupTimer) { + return false + } + clearTimeout(cleanupTimer) + syntheticKillExitPtyIds.delete(id) + return true + } + + function sendPtyExitToRenderer(payload: { id: string; code: number }): void { + if (mainWindow.isDestroyed()) { + return + } + // Why: flush any batched data for this PTY before sending the exit event, + // otherwise the last <=8ms of output is silently lost because the renderer + // tears down the terminal on pty:exit before the batch timer fires. + const remaining = pendingData.get(payload.id) + if (remaining) { + sendPtyDataToRenderer( + payload.id, + makePtyDataPayload( + payload.id, + remaining.data, + remaining.startSeq, + remaining.containsBackgroundOutput + ) + ) + pendingData.delete(payload.id) + } + lastInputAtByPty.delete(payload.id) + interactiveOutputCharsByPty.delete(payload.id) + rendererInFlightTotalChars = Math.max( + 0, + rendererInFlightTotalChars - (rendererInFlightCharsByPty.get(payload.id) ?? 0) + ) + rendererInFlightCharsByPty.delete(payload.id) + recordPtyRendererDeliveryPressure() + mainWindow.webContents.send('pty:exit', payload) + } + + async function shutdownProviderAndDetectExit( + provider: IPtyProvider, + id: string, + opts: { immediate?: boolean; keepHistory?: boolean } + ): Promise { + let providerExitObserved = false + const unsubscribe = provider.onExit((payload) => { + if (payload.id === id) { + providerExitObserved = true + } + }) + try { + await provider.shutdown(id, opts) + } finally { + unsubscribe() + } + return providerExitObserved + } + // Why: extracted so the "Restart daemon" flow can rebind against the fresh // adapter after replaceDaemonProvider runs. Both the startup registration // and the post-restart rebind go through the same code path — no risk of @@ -1745,39 +1824,16 @@ export function registerPtyHandlers( } }) localExitUnsub = localProvider.onExit((payload) => { + if (consumeSyntheticKillExit(payload.id)) { + return + } if (!isLocalProvider) { clearProviderPtyState(payload.id) ptyOwnership.delete(payload.id) markClaudePtyExited(payload.id) runtime?.onPtyExit(payload.id, payload.code) } - if (!mainWindow.isDestroyed()) { - // Why: flush any batched data for this PTY before sending the exit event, - // otherwise the last ≤8ms of output is silently lost because the renderer - // tears down the terminal on pty:exit before the batch timer fires. - const remaining = pendingData.get(payload.id) - if (remaining) { - sendPtyDataToRenderer( - payload.id, - makePtyDataPayload( - payload.id, - remaining.data, - remaining.startSeq, - remaining.containsBackgroundOutput - ) - ) - pendingData.delete(payload.id) - } - lastInputAtByPty.delete(payload.id) - interactiveOutputCharsByPty.delete(payload.id) - rendererInFlightTotalChars = Math.max( - 0, - rendererInFlightTotalChars - (rendererInFlightCharsByPty.get(payload.id) ?? 0) - ) - rendererInFlightCharsByPty.delete(payload.id) - recordPtyRendererDeliveryPressure() - mainWindow.webContents.send('pty:exit', payload) - } + sendPtyExitToRenderer(payload) }) } @@ -1905,6 +1961,17 @@ export function registerPtyHandlers( assertFolderWorkspacePathUsable(status) } + const resolveGuardedPtySpawnCwd = ( + worktreeId: string | undefined, + cwd: string | undefined + ): string | undefined => + resolveTerminalStartupCwdForWorkspace({ + workspaceId: worktreeId, + requestedCwd: cwd, + resolveFolderWorkspacePath: (folderWorkspaceId) => + store?.getFolderWorkspace(folderWorkspaceId)?.folderPath + }) + // Why: the runtime controller must route through getProviderForPty() so that // CLI commands (terminal.send, terminal.stop) work for both local and remote PTYs. // Hardcoding localProvider.getPtyProcess() would silently fail for remote PTYs. @@ -1915,6 +1982,7 @@ export function registerPtyHandlers( await startupPromise } await assertFolderWorkspacePtyPathUsable(args.worktreeId) + const cwd = resolveGuardedPtySpawnCwd(args.worktreeId, args.cwd) const provider = getProvider(args.connectionId) const isClaudeLaunch = !args.connectionId && isClaudeLaunchCommand(args.command) if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { @@ -1934,7 +2002,7 @@ export function registerPtyHandlers( const daemonShellOverride = terminalRuntimeOptions.shellOverride const codexSelectionTarget = getCodexSelectionTargetForPty( daemonShellOverride, - args.cwd, + cwd, terminalRuntimeOptions.terminalWindowsWslDistro ?? null ) const claudeAuth = @@ -2002,7 +2070,7 @@ export function registerPtyHandlers( : null const skipCodexHomeEnv = isDaemonHostSpawn && - shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, args.cwd) && + shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, cwd) && !selectedCodexHomePath if (isDaemonHostSpawn && sessionId) { if (!isSafePtySessionId(sessionId, app.getPath('userData'))) { @@ -2016,7 +2084,7 @@ export function registerPtyHandlers( githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false, launchCommand: args.command, shellPath: daemonShellOverride ?? process.env.COMSPEC, - isWsl: shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, args.cwd), + isWsl: shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, cwd), agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()), networkProxySettings: getSettings?.() }) @@ -2029,7 +2097,7 @@ export function registerPtyHandlers( const spawnOptions: PtySpawnOptions = { cols: args.cols, rows: args.rows, - cwd: args.cwd, + cwd, env, ...(isMintedSessionId ? { isNewSession: true } : {}) } @@ -2149,7 +2217,8 @@ export function registerPtyHandlers( worktreeId: hostSessionBinding.worktreeId, tabId: hostSessionBinding.tabId, leafId: hostSessionBinding.leafId, - ptyId: result.id + ptyId: result.id, + ...(cwd ? { startupCwd: cwd } : {}) }) } catch (err) { console.error('[pty] failed to persist runtime PTY binding after spawn:', err) @@ -2240,6 +2309,8 @@ export function registerPtyHandlers( // not revive a terminal the user explicitly closed. finishPtyShutdown(ptyId, connectionId, store) runtime?.onPtyExit(ptyId, -1) + rememberSyntheticKillExit(ptyId) + sendPtyExitToRenderer({ id: ptyId, code: -1 }) return true } return false @@ -2247,16 +2318,25 @@ export function registerPtyHandlers( // Why: shutdown() is async but the PtyController interface is sync. Defer // cleanup until shutdown resolves so transient SSH/daemon failures don't // hide a still-running remote process or local daemon session. - void provider - .shutdown(ptyId, { immediate: false }) - .then(() => { + // + // Same synthetic-exit contract as the renderer pty:kill handler: when the + // provider emitted its own exit during shutdown, the exit listener already + // delivered runtime + renderer exits — synthesizing again would double-fire. + void shutdownProviderAndDetectExit(provider, ptyId, { immediate: false }) + .then((providerExitObserved) => { finishPtyShutdown(ptyId, connectionId, store) - runtime?.onPtyExit(ptyId, -1) + if (!providerExitObserved) { + runtime?.onPtyExit(ptyId, -1) + rememberSyntheticKillExit(ptyId) + sendPtyExitToRenderer({ id: ptyId, code: -1 }) + } }) .catch((err) => { if (isPtyAlreadyGoneError(err)) { finishPtyShutdown(ptyId, connectionId, store) runtime?.onPtyExit(ptyId, -1) + rememberSyntheticKillExit(ptyId) + sendPtyExitToRenderer({ id: ptyId, code: -1 }) return } console.warn( @@ -2283,12 +2363,15 @@ export function registerPtyHandlers( // await, but the relay lease must still be tombstoned. finishPtyShutdown(ptyId, connectionId, store) runtime?.onPtyExit(ptyId, -1) + rememberSyntheticKillExit(ptyId) + sendPtyExitToRenderer({ id: ptyId, code: -1 }) return true } return false } + let providerExitObserved = false try { - await provider.shutdown(ptyId, { + providerExitObserved = await shutdownProviderAndDetectExit(provider, ptyId, { immediate: true, keepHistory: opts?.keepHistory ?? false }) @@ -2313,7 +2396,11 @@ export function registerPtyHandlers( return false } finishPtyShutdown(ptyId, connectionId, store) - runtime?.onPtyExit(ptyId, -1) + if (!providerExitObserved) { + runtime?.onPtyExit(ptyId, -1) + rememberSyntheticKillExit(ptyId) + sendPtyExitToRenderer({ id: ptyId, code: -1 }) + } return true }, getForegroundProcess: async (ptyId) => { @@ -2323,6 +2410,14 @@ export function registerPtyHandlers( return null } }, + getCwd: async (ptyId) => { + try { + const cwd = await getProviderForPty(ptyId).getCwd(ptyId) + return cwd || null + } catch { + return null + } + }, hasChildProcesses: async (ptyId) => { try { return await getProviderForPty(ptyId).hasChildProcesses(ptyId) @@ -2461,11 +2556,14 @@ export function registerPtyHandlers( } } ) => { + const spawnTiming = createPtySpawnTiming() const startupPromise = getLocalPtyStartupPromise(args.connectionId) if (startupPromise) { await startupPromise } await assertFolderWorkspacePtyPathUsable(args.worktreeId) + const cwd = resolveGuardedPtySpawnCwd(args.worktreeId, args.cwd) + spawnTiming.mark('preflight') const provider = getProvider(args.connectionId) const isClaudeLaunch = !args.connectionId && isClaudeLaunchCommand(args.command) if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { @@ -2483,11 +2581,12 @@ export function registerPtyHandlers( const initialShellOverride = terminalRuntimeOptions.shellOverride const initialSelectionTarget = getCodexSelectionTargetForPty( initialShellOverride, - args.cwd, + cwd, terminalRuntimeOptions.terminalWindowsWslDistro ?? null ) const claudeAuth = isClaudeLaunch && prepareClaudeAuth ? await prepareClaudeAuth(initialSelectionTarget) : null + spawnTiming.mark('auth') if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { throw new Error('A Claude account switch is in progress. Try again after it finishes.') } @@ -2653,7 +2752,7 @@ export function registerPtyHandlers( const effectiveShellOverride = terminalRuntimeOptions.shellOverride const codexSelectionTarget = getCodexSelectionTargetForPty( effectiveShellOverride, - args.cwd, + cwd, terminalRuntimeOptions.terminalWindowsWslDistro ?? null ) const selectedCodexHomePath = isDaemonHostSpawn @@ -2664,7 +2763,7 @@ export function registerPtyHandlers( : null const skipCodexHomeEnv = isDaemonHostSpawn && - shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, args.cwd) && + shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd) && !selectedCodexHomePath if (isDaemonHostSpawn) { if (effectiveSessionId === undefined) { @@ -2693,7 +2792,7 @@ export function registerPtyHandlers( githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false, launchCommand: args.command, shellPath: effectiveShellOverride ?? process.env.COMSPEC, - isWsl: shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, args.cwd), + isWsl: shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd), agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()), networkProxySettings: getSettings?.() }) @@ -2713,6 +2812,7 @@ export function registerPtyHandlers( throw err } } + spawnTiming.mark('host_env') const spawnEnv = preAllocatedHandle ? { ...env, ORCA_TERMINAL_HANDLE: preAllocatedHandle } : env @@ -2734,7 +2834,7 @@ export function registerPtyHandlers( const spawnOptions: PtySpawnOptions = { cols: args.cols, rows: args.rows, - cwd: args.cwd, + cwd, env: spawnEnv, ...(isMintedSessionId ? { isNewSession: true } : {}) } @@ -2807,7 +2907,9 @@ export function registerPtyHandlers( startupTerminalColorQueryReplyColors ) } + spawnTiming.mark('options') result = await provider.spawn(spawnOptions) + spawnTiming.mark('provider_spawn') } catch (err) { const rawMessage = err instanceof Error ? err.message : String(err) const spawnError = normalizeNodePtySpawnError(err) @@ -2868,6 +2970,10 @@ export function registerPtyHandlers( trustedTerminalHandleEnv.delete(preAllocatedHandle) } } + spawnTiming.log(result.id, { + daemon: isDaemonHostSpawn, + reattach: result.isReattach ?? false + }) ptyOwnership.set(result.id, args.connectionId ?? null) if (startupTerminalColorQueryReplyColors) { if (result.isReattach) { @@ -2920,7 +3026,8 @@ export function registerPtyHandlers( worktreeId: args.worktreeId, tabId: args.tabId, leafId: validatedLeafId, - ptyId: result.id + ptyId: result.id, + ...(cwd ? { startupCwd: cwd } : {}) }) } catch (err) { console.error('[pty] failed to persist PTY binding after spawn:', err) @@ -3396,10 +3503,14 @@ export function registerPtyHandlers( // before ownership is rebuilt. Tombstone instead of falling back local. finishPtyShutdown(args.id, connectionId, store) runtime?.onPtyExit(args.id, -1) + rememberSyntheticKillExit(args.id) + sendPtyExitToRenderer({ id: args.id, code: -1 }) return } + const shutdownProvider = provider ?? getProviderForPty(args.id) + let providerExitObserved = false try { - await (provider ?? getProviderForPty(args.id)).shutdown(args.id, { + providerExitObserved = await shutdownProviderAndDetectExit(shutdownProvider, args.id, { immediate: true, keepHistory: args.keepHistory ?? false }) @@ -3412,11 +3523,14 @@ export function registerPtyHandlers( } /* session already dead — cleanup below handles the rest */ } - // Why: onExit clears provider state for LocalPtyProvider, but remote SSH - // and daemon shutdown paths do not emit onExit through the local provider's - // listener. Explicit cleanup is idempotent and covers already-dead PTYs. + // Why: some shutdown paths do not emit onExit through the provider listener. + // Explicit cleanup is idempotent and covers already-dead PTYs. finishPtyShutdown(args.id, connectionId, store) - runtime?.onPtyExit(args.id, -1) + if (!providerExitObserved) { + runtime?.onPtyExit(args.id, -1) + rememberSyntheticKillExit(args.id) + sendPtyExitToRenderer({ id: args.id, code: -1 }) + } }) ipcMain.handle( diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 890449f51b6..c7ff8f45c82 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -1402,13 +1402,9 @@ export function registerWorktreeHandlers( if (!registeredWorktree) { const fsProvider = repo.connectionId ? getSshFilesystemProvider(repo.connectionId) : null let canCleanOrphanedDirectory = false - const knownOrcaLayouts = buildKnownOrcaWorkspaceLayouts(store.getSettings(), repo) if ( canCleanupUnregisteredOrcaWorktreeDirectory({ - meta: removedMeta, - worktreePath, - repo, - knownOrcaLayouts + meta: removedMeta }) ) { if (repo.connectionId) { @@ -1481,7 +1477,6 @@ export function registerWorktreeHandlers( runtimeWorktreePath, repo, runtimeRepoPath: toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), - knownOrcaLayouts, registeredWorktrees, statPath: access.statPath, isGitRepository: (path) => isLocalGitRepository(path, localWorktreeGitOptions) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index b1856b1a8c4..2e609b20558 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -2828,6 +2828,24 @@ describe('Store', () => { expect(store.getSettings().terminalUseSeparateLightTheme).toBe(false) }) + it('round-trips selected terminal theme names across reload', async () => { + const store = await createStore() + + store.updateSettings({ + terminalThemeDark: 'One Light', + terminalThemeLight: 'GitHub Light' + }) + store.flush() + + const persisted = readDataFile() as PersistedState + expect(persisted.settings.terminalThemeDark).toBe('One Light') + expect(persisted.settings.terminalThemeLight).toBe('GitHub Light') + + const reopened = await createStore() + expect(reopened.getSettings().terminalThemeDark).toBe('One Light') + expect(reopened.getSettings().terminalThemeLight).toBe('GitHub Light') + }) + // ── 5. addRepo and getRepo ────────────────────────────────────────── it('addRepo stores a repo retrievable by getRepo', async () => { @@ -4584,6 +4602,135 @@ describe('Store', () => { expect(statSync(dataFile()).ino).toBe(inoBefore) }) + // ── worktreeMeta startup GC ──────────────────────────────────────── + + it('garbage-collects stale local worktreeMeta at load with a 30-day grace', async () => { + const OLD = Date.now() - 40 * 24 * 60 * 60 * 1000 + const RECENT = Date.now() - 1 * 24 * 60 * 60 * 1000 + const missing = (name: string): string => join(testState.dir, 'gone', name) + const meta = (lastActivityAt: number, extra: Record = {}) => ({ + displayName: '', + comment: '', + lastActivityAt, + ...extra + }) + const liveKey = `r1::${testState.dir}` + const deadKey = `r1::${missing('dead')}` + const recentKey = `r1::${missing('recent')}` + const sshKey = `ssh-repo::/home/alice/gone` + const remoteHostKey = `r1::${missing('remote-host')}` + const orphanKey = `removed-repo::${missing('orphan')}` + const wslKey = `r1::\\\\wsl$\\Ubuntu\\home\\gone` + + writeDataFile({ + repos: [ + makeRepo(), + makeRepo({ id: 'ssh-repo', path: '/home/alice/repo', connectionId: 'conn-1' }) + ], + worktreeMeta: { + [liveKey]: meta(OLD), + [deadKey]: meta(OLD), + [recentKey]: meta(RECENT), + [sshKey]: meta(OLD), + [remoteHostKey]: meta(OLD, { hostId: 'ssh:conn-1' }), + [orphanKey]: meta(OLD), + [wslKey]: meta(OLD) + }, + worktreeLineageById: { [deadKey]: { parentWorktreeId: liveKey } } + }) + + const store = await createStore() + const kept = Object.keys(store.getAllWorktreeMeta()) + + expect(kept).toContain(liveKey) // path exists + expect(kept).toContain(recentKey) // inside the grace window + expect(kept).toContain(sshKey) // SSH repo: remote paths never checked locally + expect(kept).toContain(remoteHostKey) // remote hostId on the meta itself + expect(kept).toContain(wslKey) // WSL UNC path + expect(kept).not.toContain(deadKey) + expect(kept).not.toContain(orphanKey) + expect(store.getWorktreeLineage(deadKey)).toBeUndefined() + }) + + it('never GCs folder-workspace instance metas — the meta IS the workspace', async () => { + const OLD = Date.now() - 40 * 24 * 60 * 60 * 1000 + const folderInstanceKey = `r1::${join(testState.dir, 'gone-folder')}::workspace:11111111-1111-4111-8111-111111111111` + writeDataFile({ + repos: [makeRepo({ kind: 'folder' })], + worktreeMeta: { + [folderInstanceKey]: { displayName: 'Session A', comment: '', lastActivityAt: OLD } + } + }) + + const store = await createStore() + expect(Object.keys(store.getAllWorktreeMeta())).toContain(folderInstanceKey) + }) + + it('never GCs Linux-style WSL worktree paths on Windows', async () => { + const OLD = Date.now() - 40 * 24 * 60 * 60 * 1000 + const wslLinkedKey = 'r1::/home/user/gone-worktree' + writeDataFile({ + repos: [makeRepo()], + worktreeMeta: { + [wslLinkedKey]: { displayName: '', comment: '', lastActivityAt: OLD } + } + }) + + await withPlatform('win32', async () => { + const store = await createStore() + expect(Object.keys(store.getAllWorktreeMeta())).toContain(wslLinkedKey) + }) + }) + + it('tolerates a null worktreeMeta map in the durable file', async () => { + writeDataFile({ worktreeMeta: null }) + const store = await createStore() + expect(store.getAllWorktreeMeta()).toEqual({}) + }) + + // ── GitHub cache sidecar ─────────────────────────────────────────── + + it('cache refreshes never rewrite the durable state file', async () => { + vi.useFakeTimers() + try { + const store = await createStore() + store.updateUI({ sidebarWidth: 411 }) + vi.advanceTimersByTime(1000) + await store.waitForPendingWrite() + const inoBefore = statSync(dataFile()).ino + expect((readDataFile() as { githubCache?: unknown }).githubCache).toBeUndefined() + + store.setGitHubCache({ pr: { 'o/r#1': { fetchedAt: 123 } as never }, issue: {} }) + vi.advanceTimersByTime(6000) + await store.waitForPendingWrite() + + expect(statSync(dataFile()).ino).toBe(inoBefore) + } finally { + vi.useRealTimers() + } + }) + + it('snapshots the cache at flush and seeds the next Store from the sidecar', async () => { + const store = await createStore() + store.setGitHubCache({ pr: { 'o/r#7': { fetchedAt: 7 } as never }, issue: {} }) + store.flush() + expect(existsSync(join(testState.dir, 'orca-github-cache.json'))).toBe(true) + + const restarted = await createStore() + expect(restarted.getGitHubCache().pr['o/r#7']).toEqual({ fetchedAt: 7 }) + }) + + it('keeps a legacy in-file cache as the seed and strips it from disk', async () => { + writeDataFile({ githubCache: { pr: { legacy: { fetchedAt: 1 } }, issue: {} } }) + + const store = await createStore() + expect(store.getGitHubCache().pr.legacy).toEqual({ fetchedAt: 1 }) + + // The legacy key marks the state dirty at load; the next write drops it. + store.flush() + expect((readDataFile() as { githubCache?: unknown }).githubCache).toBeUndefined() + }) + // ── UI state ─────────────────────────────────────────────────────── it('updateUI merges partial updates', async () => { diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 5d9ef610f48..9ac1fcc4220 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -112,6 +112,7 @@ import { type ExecutionHostId } from '../shared/execution-host' import { toRelaySshPtyId } from './providers/ssh-pty-id' +import { isWslUncPath } from '../shared/wsl-paths' import { isTerminalLeafId, makePaneKey, @@ -125,9 +126,14 @@ import { import { agentHookServer } from './agent-hooks/server' import { pruneLocalTerminalScrollbackBuffers } from '../shared/workspace-session-terminal-buffers' import { pruneWorkspaceSessionBrowserHistory } from '../shared/workspace-session-browser-history' -import { getRepoIdFromWorktreeId, getWorktreePathBasenameFromId } from '../shared/worktree-id' +import { + FOLDER_WORKSPACE_INSTANCE_SEPARATOR, + getRepoIdFromWorktreeId, + getWorktreePathBasenameFromId +} from '../shared/worktree-id' import { isPathInsideOrEqual, + isWindowsAbsolutePathLike, normalizeRuntimePathForComparison } from '../shared/cross-platform-path' import { normalizeTerminalQuickCommands } from '../shared/terminal-quick-commands' @@ -325,6 +331,110 @@ function getDataFile(): string { return _dataFile } +// Why a sidecar: githubCache is a refetchable 5-min-TTL poll cache whose +// fetchedAt stamps change on every refresh — keeping it inside orca-data.json +// made every poll cycle rewrite the whole multi-MB durable state (defeating +// the content-hash guard by design). It lives in memory during the session +// and is snapshotted here best-effort at quit so PR/issue badges still paint +// instantly on the next launch. Loss of this file costs nothing. +function getGithubCacheFile(): string { + return join(dirname(getDataFile()), 'orca-github-cache.json') +} + +// Why: worktrees deleted outside Orca (git CLI worktree remove, rm -rf, +// agent scripts) purge renderer session state but nothing removed their +// worktreeMeta, so the map grew monotonically (63% dead entries measured on +// a heavy install). GC is deliberately narrow: local-host entries only +// (SSH/runtime metas embed remote paths a local existsSync would falsely +// condemn; WSL UNC paths are skipped the same way), and only after a +// 30-day idle grace so pushTarget cleanup for recently-vanished worktrees +// and quick recreations keep their metadata. +const WORKTREE_META_GC_GRACE_MS = 30 * 24 * 60 * 60 * 1000 + +function gcStaleWorktreeMeta(state: PersistedState): number { + // Why: a hand-corrupted file with `"worktreeMeta": null` overrides the + // defaults merge; normalize instead of throwing outside the parse guard. + state.worktreeMeta ??= {} + const repoById = new Map(state.repos.map((repo) => [repo.id, repo])) + const projectIds = new Set((state.projects ?? []).map((project) => project.id)) + const now = Date.now() + let removed = 0 + for (const key of Object.keys(state.worktreeMeta)) { + // Why: folder-project workspace instances are keyed + // `repoId::path::workspace:` and their meta IS the workspace + // record — never a filesystem-checkout row. Skip them entirely. + if (key.includes(FOLDER_WORKSPACE_INSTANCE_SEPARATOR)) { + continue + } + const separator = key.indexOf('::') + if (separator === -1) { + continue + } + const ownerId = key.slice(0, separator) + const worktreePath = key.slice(separator + 2) + const meta = state.worktreeMeta[key] + const repo = repoById.get(ownerId) + if (repo) { + if (repo.connectionId || getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID) { + continue + } + } else if (projectIds.has(ownerId)) { + // Project-owned metas keep project/host semantics on the entry itself; + // stay conservative and leave them to their own lifecycle. + continue + } + // Unowned entries (repo removed before removeProject pruned metas) fall + // through to the same missing-path + idle-grace gate. + if (meta?.hostId && meta.hostId !== LOCAL_EXECUTION_HOST_ID) { + continue + } + if (!isAbsolute(worktreePath) || isWslUncPath(worktreePath)) { + continue + } + // Why: WSL linked worktrees on Windows carry Linux-style paths from git + // porcelain; a Windows existsSync cannot probe those and would falsely + // condemn live worktrees. + if (process.platform === 'win32' && !isWindowsAbsolutePathLike(worktreePath)) { + continue + } + // Why keep timestamp-less entries: without lastActivityAt/createdAt we + // cannot prove the 30-day idle grace elapsed; the measured dead entries + // all carry timestamps, so this costs almost nothing in reclaimed bytes. + // Grace runs before the stat so healthy profiles skip the existsSync + // fan-out (and its slow-NFS tail) for active entries entirely. + const newestTouch = Math.max(meta?.lastActivityAt ?? 0, meta?.createdAt ?? 0) + if (newestTouch === 0 || now - newestTouch < WORKTREE_META_GC_GRACE_MS) { + continue + } + if (existsSync(worktreePath)) { + continue + } + delete state.worktreeMeta[key] + delete state.worktreeLineageById[key] + delete state.workspaceLineageByChildKey[worktreeWorkspaceKey(key)] + removed++ + } + return removed +} + +function readGithubCacheSnapshot(): PersistedState['githubCache'] | null { + try { + const parsed = JSON.parse(readFileSync(getGithubCacheFile(), 'utf-8')) as unknown + const isPlainRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + if ( + isPlainRecord(parsed) && + isPlainRecord((parsed as { pr?: unknown }).pr) && + isPlainRecord((parsed as { issue?: unknown }).issue) + ) { + return parsed as PersistedState['githubCache'] + } + } catch { + // Missing or corrupt snapshot: start with an empty cache and refetch. + } + return null +} + /** * Return the userData directory captured at initDataPath() time, before * app.setName() can change how app.getPath('userData') resolves. @@ -2242,6 +2352,7 @@ function createMinimalPersistedTerminalTab(args: { tabId: string ptyId: string existingTabCount: number + startupCwd?: string }): TerminalTab { const ordinal = args.existingTabCount + 1 const defaultTitle = `Terminal ${ordinal}` @@ -2255,6 +2366,7 @@ function createMinimalPersistedTerminalTab(args: { color: null, sortOrder: args.existingTabCount, createdAt: Date.now(), + ...(args.startupCwd ? { startupCwd: args.startupCwd } : {}), pendingActivationSpawn: true } } @@ -2451,6 +2563,7 @@ export class Store { // on-disk bytes differ even for identical state. private lastWrittenStateHash: string | null = null private firstPendingSaveAt: number | null = null + private githubCacheDirty = false private gitUsernameCache = new Map() private loadNeedsSave = false private settingsChangeListeners = new Set< @@ -3293,7 +3406,29 @@ export class Store { } result = folderScopeConnectionMigration.state + if (gcStaleWorktreeMeta(result) > 0) { + this.loadNeedsSave = true + } + const migrated = this.migrateTelemetry(result, fileExistedOnLoad) + + // githubCache lives in a sidecar file now (see getGithubCacheFile). A + // legacy in-file cache (pre-sidecar build, or a downgrade round-trip) is + // kept as this session's seed and stripped from the durable file by the + // save scheduled below; otherwise seed from the sidecar snapshot. + const legacyCache = migrated.githubCache + const hasLegacyCache = + Object.keys(legacyCache?.pr ?? {}).length > 0 || + Object.keys(legacyCache?.issue ?? {}).length > 0 + if (hasLegacyCache) { + this.loadNeedsSave = true + // Why: mark dirty so the first flush writes the sidecar even if no + // poll refresh happens this session — the seed survives the migration. + this.githubCacheDirty = true + } else { + migrated.githubCache = readGithubCacheSnapshot() ?? migrated.githubCache + } + logPersistenceStartupMilestone('persistence-load-done', { repos: migrated.repos.length, workspaceSessionBytes: Buffer.byteLength(JSON.stringify(migrated.workspaceSession)) @@ -3403,8 +3538,16 @@ export class Store { } } + // Why githubCache is omitted: it is memory-only during the session (see + // getGithubCacheFile) — excluding it from both the payload and the hash + // keeps cache refreshes from ever touching the durable file. + private getDurableState(): Omit { + const { githubCache: _memoryOnly, ...durable } = this.state + return durable + } + private computeStateHash(): string { - return createHash('sha1').update(JSON.stringify(this.state)).digest('hex') + return createHash('sha1').update(JSON.stringify(this.getDurableState())).digest('hex') } // Why: builds the on-disk payload synchronously so the hash and the @@ -3414,7 +3557,7 @@ export class Store { // Why: secrets must be encrypted on disk. Clone state so the in-memory // this.state stays plaintext for the rest of the app. const stateToSave = { - ...this.state, + ...this.getDurableState(), settings: { ...this.state.settings, opencodeSessionCookie: encrypt(this.state.settings.opencodeSessionCookie), @@ -5241,8 +5384,12 @@ export class Store { } setGitHubCache(cache: PersistedState['githubCache']): void { + // Why no scheduleSave: the cache is memory-only during the session and + // snapshotted to its sidecar file at flush (quit/reload) time. Every poll + // refresh restamps fetchedAt, so persisting here rewrote the whole + // durable state file once per poll cycle for refetchable data. this.state.githubCache = cache - this.scheduleSave() + this.githubCacheDirty = true } // ── Workspace Session ───────────────────────────────────────────── @@ -5604,6 +5751,7 @@ export class Store { tabId: string leafId: string ptyId: string + startupCwd?: string }): void { const session = this.state.workspaceSession if (!session) { @@ -5947,6 +6095,29 @@ export class Store { } catch (err) { console.error('[persistence] Failed to flush state:', err) } + this.writeGithubCacheSnapshotSync() + } + + // Why best-effort: the sidecar is a refetchable cache — a failed write only + // costs a cold badge paint on next launch, never data. + private writeGithubCacheSnapshotSync(): void { + if (!this.githubCacheDirty) { + return + } + const cacheFile = getGithubCacheFile() + const tmpFile = `${cacheFile}.${process.pid}.tmp` + try { + writeFileSync(tmpFile, JSON.stringify(this.state.githubCache), 'utf-8') + renameSync(tmpFile, cacheFile) + this.githubCacheDirty = false + } catch (err) { + try { + unlinkSync(tmpFile) + } catch { + // Best-effort cleanup. + } + console.warn('[persistence] Failed to write github cache snapshot:', err) + } } } diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 01ea3aeea57..7e8588a6b64 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -21,7 +21,7 @@ import { updateHistFileForFallback, logHistoryInjection } from '../terminal-history' -import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from './types' +import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from './types' import { ensureNodePtySpawnHelperExecutable, validateWorkingDirectory, @@ -67,6 +67,7 @@ let ptyCounter = 0 const ptyProcesses = new Map() const ptyShellName = new Map() const ptyAgentForegroundContextPaths = new Map() +const ptyTerminalHandle = new Map() // Why: node-pty's onData/onExit register native NAPI ThreadSafeFunction // callbacks. If the PTY is killed without disposing these listeners, the // stale callbacks survive into node::FreeEnvironment() where NAPI attempts @@ -187,6 +188,7 @@ function clearPtyState(id: string): void { ptyProcesses.delete(id) ptyShellName.delete(id) ptyAgentForegroundContextPaths.delete(id) + ptyTerminalHandle.delete(id) ptyLoadGeneration.delete(id) } @@ -680,6 +682,9 @@ export class LocalPtyProvider implements IPtyProvider { const proc = spawnResult.process ptyProcesses.set(id, proc) ptyShellName.set(id, getSpawnedShellName(shellPath)) + if (finalEnv.ORCA_TERMINAL_HANDLE) { + ptyTerminalHandle.set(id, finalEnv.ORCA_TERMINAL_HANDLE) + } ptyAgentForegroundContextPaths.set( id, getAgentForegroundContextPaths({ cwd: args.cwd, worktreeId: args.worktreeId }) @@ -945,11 +950,12 @@ export class LocalPtyProvider implements IPtyProvider { /* re-spawning handles local revival */ } - async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { + async listProcesses(): Promise { return Array.from(ptyProcesses.entries()).map(([id, proc]) => ({ id, cwd: '', - title: proc.process || ptyShellName.get(id) || 'shell' + title: proc.process || ptyShellName.get(id) || 'shell', + ...(ptyTerminalHandle.get(id) ? { terminalHandle: ptyTerminalHandle.get(id) } : {}) })) } diff --git a/src/main/providers/local-pty-utils-windows-fallback.test.ts b/src/main/providers/local-pty-utils-windows-fallback.test.ts index 48b35cd27c0..0de16d03f17 100644 --- a/src/main/providers/local-pty-utils-windows-fallback.test.ts +++ b/src/main/providers/local-pty-utils-windows-fallback.test.ts @@ -76,13 +76,13 @@ describe('spawnShellWithFallback on Windows', () => { 1, PWSH7, attempts[0].shellArgs, - expect.objectContaining({ cwd: 'C:\\repo' }) + expect.objectContaining({ cwd: 'C:\\repo', useConptyDll: true }) ) expect(ptySpawn).toHaveBeenNthCalledWith( 2, WINDOWS_POWERSHELL, attempts[1].shellArgs, - expect.objectContaining({ cwd: 'C:\\repo' }) + expect.objectContaining({ cwd: 'C:\\repo', useConptyDll: true }) ) }) diff --git a/src/main/providers/local-pty-utils.ts b/src/main/providers/local-pty-utils.ts index 0ad6effc9c9..c8eb87c249d 100644 --- a/src/main/providers/local-pty-utils.ts +++ b/src/main/providers/local-pty-utils.ts @@ -160,6 +160,14 @@ export type ShellSpawnResult = { * executables with per-shell args, so when the primary fails we retry with the * next safe shell instead of leaving the user with no terminal. */ +// Why: match the daemon spawn path (pty-subprocess.ts) — the bundled ConPTY +// has the modern wrap-marker behavior xterm expects; legacy system ConPTY can +// corrupt full-width TUI rows in scrollback. Without this, degraded-mode and +// fresh-local spawns silently behave differently from daemon terminals. +function windowsConptyDllOptions(): { useConptyDll: true } | Record { + return process.platform === 'win32' ? { useConptyDll: true } : {} +} + function spawnWindowsFallbackChain( params: ShellSpawnParams, primaryError: string @@ -174,7 +182,8 @@ function spawnWindowsFallbackChain( cols, rows, cwd: attempt.effectiveCwd, - env + env, + ...windowsConptyDllOptions() }) console.warn( `[pty] Primary shell "${params.shellPath}" failed (${primaryError}), fell back to "${attempt.shellPath}"` @@ -217,7 +226,14 @@ export function spawnShellWithFallback(params: ShellSpawnParams): ShellSpawnResu if (!primaryError) { try { return { - process: ptySpawn(shellPath, shellArgs, { name: termName, cols, rows, cwd, env }), + process: ptySpawn(shellPath, shellArgs, { + name: termName, + cols, + rows, + cwd, + env, + ...windowsConptyDllOptions() + }), shellPath } } catch (err) { diff --git a/src/main/providers/ssh-filesystem-provider-sftp.ts b/src/main/providers/ssh-filesystem-provider-sftp.ts new file mode 100644 index 00000000000..31c003303d8 --- /dev/null +++ b/src/main/providers/ssh-filesystem-provider-sftp.ts @@ -0,0 +1,46 @@ +import type { SFTPWrapper, Stats } from 'ssh2' +import type { FileStat } from './types' + +export function fileStatFromSftpStats(stats: Stats): FileStat { + let type: FileStat['type'] = 'file' + if (stats.isDirectory()) { + type = 'directory' + } else if (stats.isSymbolicLink()) { + type = 'symlink' + } + const maybeNlink = (stats as Stats & { nlink?: unknown }).nlink + return { + size: stats.size, + type, + mtime: stats.mtime * 1000, + ...(typeof maybeNlink === 'number' ? { nlink: maybeNlink } : {}) + } +} + +export function lstatViaSftp(sftp: SFTPWrapper, filePath: string): Promise { + return new Promise((resolve, reject) => { + sftp.lstat(filePath, (err, stats) => { + if (err) { + reject(err) + return + } + resolve(fileStatFromSftpStats(stats)) + }) + }) +} + +export function fastGetViaSftp( + sftp: SFTPWrapper, + sourcePath: string, + destinationPath: string +): Promise { + return new Promise((resolve, reject) => { + sftp.fastGet(sourcePath, destinationPath, (err) => { + if (err) { + reject(err) + return + } + resolve() + }) + }) +} diff --git a/src/main/providers/ssh-filesystem-provider-watch.ts b/src/main/providers/ssh-filesystem-provider-watch.ts new file mode 100644 index 00000000000..7beea078ac3 --- /dev/null +++ b/src/main/providers/ssh-filesystem-provider-watch.ts @@ -0,0 +1,86 @@ +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import type { FsChangeEvent } from '../../shared/types' + +export type WatchRegistration = { + callbacks: Set<(events: FsChangeEvent[]) => void> + setupPromise: Promise +} + +export async function registerSshFilesystemWatch(args: { + mux: SshChannelMultiplexer + disposed: () => boolean + registrations: Map + rootPath: string + callback: (events: FsChangeEvent[]) => void +}): Promise<() => void> { + if (args.disposed()) { + throw new Error('SSH filesystem provider disposed') + } + let registration = args.registrations.get(args.rootPath) + if (registration) { + registration.callbacks.add(args.callback) + await registration.setupPromise + assertActiveWatch(args, registration) + return createSshFilesystemWatchUnsubscribe(args, registration) + } + + const callbacks = new Set<(events: FsChangeEvent[]) => void>([args.callback]) + const setupPromise = args.mux.request('fs.watch', { rootPath: args.rootPath }).then( + () => undefined, + (error) => { + if (args.registrations.get(args.rootPath) === registration) { + args.registrations.delete(args.rootPath) + } + throw error + } + ) + registration = { callbacks, setupPromise } + args.registrations.set(args.rootPath, registration) + await setupPromise + if (args.disposed() || args.registrations.get(args.rootPath) !== registration) { + notifySshFilesystemUnwatch(args.mux, args.rootPath) + throw new Error('SSH filesystem provider disposed') + } + + return createSshFilesystemWatchUnsubscribe(args, registration) +} + +export function notifySshFilesystemUnwatch(mux: SshChannelMultiplexer, rootPath: string): void { + try { + mux.notify('fs.unwatch', { rootPath }) + } catch {} +} + +function assertActiveWatch( + args: { + disposed: () => boolean + registrations: Map + rootPath: string + }, + registration: WatchRegistration +): void { + if (args.disposed() || args.registrations.get(args.rootPath) !== registration) { + throw new Error('SSH filesystem provider disposed') + } +} + +function createSshFilesystemWatchUnsubscribe( + args: { + mux: SshChannelMultiplexer + registrations: Map + rootPath: string + callback: (events: FsChangeEvent[]) => void + }, + registration: WatchRegistration +): () => void { + return () => { + registration.callbacks.delete(args.callback) + if ( + registration.callbacks.size === 0 && + args.registrations.get(args.rootPath) === registration + ) { + args.registrations.delete(args.rootPath) + notifySshFilesystemUnwatch(args.mux, args.rootPath) + } + } +} diff --git a/src/main/providers/ssh-filesystem-provider.test.ts b/src/main/providers/ssh-filesystem-provider.test.ts index be483d2d651..f008c380dde 100644 --- a/src/main/providers/ssh-filesystem-provider.test.ts +++ b/src/main/providers/ssh-filesystem-provider.test.ts @@ -84,6 +84,38 @@ describe('SshFilesystemProvider', () => { }) }) + describe('readTerminalArtifact', () => { + it('sends fs.readTerminalArtifact request with verification metadata', async () => { + mux.request.mockResolvedValue({ content: '{}', isBinary: false }) + + await expect( + provider.readTerminalArtifact('/tmp/result.json', { + expectedRealPath: '/tmp/result.json', + expectedStatIdentity: '1:2:3:4', + maxBytes: 1024 + }) + ).resolves.toEqual({ content: '{}', isBinary: false }) + expect(mux.request).toHaveBeenCalledWith('fs.readTerminalArtifact', { + filePath: '/tmp/result.json', + expectedRealPath: '/tmp/result.json', + expectedStatIdentity: '1:2:3:4', + maxBytes: 1024 + }) + }) + + it('asks the user to reconnect when the relay is too old for artifact reads', async () => { + mux.request.mockRejectedValue(Object.assign(new Error('Method not found'), { code: -32601 })) + + await expect( + provider.readTerminalArtifact('/tmp/result.json', { + expectedRealPath: '/tmp/result.json', + expectedStatIdentity: '1:2:3:4', + maxBytes: 1024 + }) + ).rejects.toThrow('Reconnect the SSH target') + }) + }) + describe('writeFile', () => { it('sends fs.writeFile request', async () => { await provider.writeFile('/home/user/file.txt', 'new content') @@ -94,6 +126,39 @@ describe('SshFilesystemProvider', () => { }) }) + describe('writeTerminalArtifact', () => { + it('returns the verified artifact write stat', async () => { + mux.request.mockResolvedValue({ stat: { type: 'file', size: 2, mtime: 3 } }) + + await expect( + provider.writeTerminalArtifact('/tmp/result.json', '{}', { + expectedRealPath: '/tmp/result.json', + expectedStatIdentity: '1:2:3:4', + maxBytes: 1024 + }) + ).resolves.toEqual({ type: 'file', size: 2, mtime: 3 }) + expect(mux.request).toHaveBeenCalledWith('fs.writeTerminalArtifact', { + filePath: '/tmp/result.json', + content: '{}', + expectedRealPath: '/tmp/result.json', + expectedStatIdentity: '1:2:3:4', + maxBytes: 1024 + }) + }) + + it('asks the user to reconnect when the relay is too old for artifact writes', async () => { + mux.request.mockRejectedValue(Object.assign(new Error('Method not found'), { code: -32601 })) + + await expect( + provider.writeTerminalArtifact('/tmp/result.json', '{}', { + expectedRealPath: '/tmp/result.json', + expectedStatIdentity: '1:2:3:4', + maxBytes: 1024 + }) + ).rejects.toThrow('Reconnect the SSH target') + }) + }) + describe('getTempDir', () => { it('reads and caches the remote temp directory from the relay', async () => { mux.request.mockResolvedValue('/var/folders/remote') diff --git a/src/main/providers/ssh-filesystem-provider.ts b/src/main/providers/ssh-filesystem-provider.ts index 4ac373dd88e..3adeeec6cb8 100644 --- a/src/main/providers/ssh-filesystem-provider.ts +++ b/src/main/providers/ssh-filesystem-provider.ts @@ -1,74 +1,33 @@ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import { isMethodNotFoundError, readFileViaStream } from '../ssh/ssh-filesystem-stream-reader' import { uploadBuffer } from '../ssh/sftp-upload' -import type { IFilesystemProvider, FileStat, FileReadResult } from './types' +import { fastGetViaSftp, lstatViaSftp } from './ssh-filesystem-provider-sftp' +import { + notifySshFilesystemUnwatch, + registerSshFilesystemWatch, + type WatchRegistration +} from './ssh-filesystem-provider-watch' +import type { + IFilesystemProvider, + FileStat, + FileReadResult, + TerminalArtifactAccessOptions +} from './types' import type { DirEntry, FsChangeEvent, SearchOptions, SearchResult } from '../../shared/types' import { isPathInsideOrEqual } from '../../shared/cross-platform-path' import type { WorkspaceSpaceDirectoryScanResult } from '../../shared/workspace-space-types' -import type { SFTPWrapper, Stats } from 'ssh2' +import type { SFTPWrapper } from 'ssh2' type SftpFactory = () => Promise -type WatchRegistration = { - callbacks: Set<(events: FsChangeEvent[]) => void> - setupPromise: Promise -} - const WORKSPACE_SPACE_SCAN_TIMEOUT_MS = 130_000 -function fileStatFromSftpStats(stats: Stats): FileStat { - let type: FileStat['type'] = 'file' - if (stats.isDirectory()) { - type = 'directory' - } else if (stats.isSymbolicLink()) { - type = 'symlink' - } - return { size: stats.size, type, mtime: stats.mtime * 1000 } -} - -function lstatViaSftp(sftp: SFTPWrapper, filePath: string): Promise { - return new Promise((resolve, reject) => { - sftp.lstat(filePath, (err, stats) => { - if (err) { - reject(err) - return - } - resolve(fileStatFromSftpStats(stats)) - }) - }) -} - -function fastGetViaSftp( - sftp: SFTPWrapper, - sourcePath: string, - destinationPath: string -): Promise { - return new Promise((resolve, reject) => { - sftp.fastGet(sourcePath, destinationPath, (err) => { - if (err) { - reject(err) - return - } - resolve() - }) - }) -} - export class SshFilesystemProvider implements IFilesystemProvider { private connectionId: string private mux: SshChannelMultiplexer - // Why: each watch() call registers for a specific rootPath, but the relay - // sends all fs.changed events on one notification channel. Keying by rootPath - // prevents cross-pollination between different worktree watchers. private watchListeners = new Map() - // Why: store the unsubscribe handle so dispose() can detach from the - // multiplexer. Without this, notification callbacks keep firing after - // the provider is torn down on disconnect, routing events to stale state. private unsubscribeNotifications: (() => void) | null = null private tempDirPromise: Promise | null = null private disposed = false - // Why: relays from a previous build may not implement fs.readFileStream. - // We log the fallback once per session at warn level so users on stale - // relays get diagnosed quickly without per-read log spam. private loggedStreamFallback = false constructor( @@ -104,7 +63,7 @@ export class SshFilesystemProvider implements IFilesystemProvider { this.unsubscribeNotifications = null } for (const rootPath of this.watchListeners.keys()) { - this.notifyUnwatch(rootPath) + notifySshFilesystemUnwatch(this.mux, rootPath) } this.watchListeners.clear() } @@ -139,6 +98,27 @@ export class SshFilesystemProvider implements IFilesystemProvider { } } + async readTerminalArtifact( + filePath: string, + options: TerminalArtifactAccessOptions + ): Promise { + try { + return (await this.mux.request('fs.readTerminalArtifact', { + filePath, + expectedRealPath: options.expectedRealPath, + expectedStatIdentity: options.expectedStatIdentity, + maxBytes: options.maxBytes + })) as FileReadResult + } catch (err) { + if (isMethodNotFoundError(err)) { + throw new Error( + 'Remote terminal artifact access is unavailable. Reconnect the SSH target before retrying.' + ) + } + throw err + } + } + async downloadFile(sourcePath: string, destinationPath: string): Promise { if (!this.createSftp) { throw new Error('Remote file download is unavailable. Reconnect the SSH target and retry.') @@ -169,6 +149,34 @@ export class SshFilesystemProvider implements IFilesystemProvider { await this.mux.request('fs.writeFile', { filePath, content }) } + async writeTerminalArtifact( + filePath: string, + content: string, + options: TerminalArtifactAccessOptions + ): Promise { + let result: { stat?: FileStat } + try { + result = (await this.mux.request('fs.writeTerminalArtifact', { + filePath, + content, + expectedRealPath: options.expectedRealPath, + expectedStatIdentity: options.expectedStatIdentity, + maxBytes: options.maxBytes + })) as { stat?: FileStat } + } catch (err) { + if (isMethodNotFoundError(err)) { + throw new Error( + 'Remote terminal artifact access is unavailable. Reconnect the SSH target before retrying.' + ) + } + throw err + } + if (!result.stat) { + throw new Error('terminal_file_grant_stale') + } + return result.stat + } + async writeFileBase64(filePath: string, contentBase64: string): Promise { await this.writeFileBase64Chunk(filePath, contentBase64, false) } @@ -276,9 +284,6 @@ export class SshFilesystemProvider implements IFilesystemProvider { } async listFiles(rootPath: string, options?: { excludePaths?: string[] }): Promise { - // Why: older relays ignore unknown fields, so sending excludePaths to a - // pre-refactor relay is a non-regression. The relay validates the shape - // and treats malformed input as "no exclusions" rather than failing. const params: Record = { rootPath } if (options?.excludePaths && options.excludePaths.length > 0) { params.excludePaths = options.excludePaths @@ -287,59 +292,12 @@ export class SshFilesystemProvider implements IFilesystemProvider { } async watch(rootPath: string, callback: (events: FsChangeEvent[]) => void): Promise<() => void> { - if (this.disposed) { - throw new Error('SSH filesystem provider disposed') - } - let registration = this.watchListeners.get(rootPath) - if (registration) { - registration.callbacks.add(callback) - await registration.setupPromise - if (this.disposed || this.watchListeners.get(rootPath) !== registration) { - throw new Error('SSH filesystem provider disposed') - } - return this.createWatchUnsubscribe(rootPath, registration, callback) - } - - const callbacks = new Set<(events: FsChangeEvent[]) => void>([callback]) - const setupPromise = this.mux.request('fs.watch', { rootPath }).then( - () => undefined, - (error) => { - if (this.watchListeners.get(rootPath) === registration) { - this.watchListeners.delete(rootPath) - } - throw error - } - ) - registration = { callbacks, setupPromise } - this.watchListeners.set(rootPath, registration) - await setupPromise - if (this.disposed || this.watchListeners.get(rootPath) !== registration) { - this.notifyUnwatch(rootPath) - throw new Error('SSH filesystem provider disposed') - } - - return this.createWatchUnsubscribe(rootPath, registration, callback) - } - - private notifyUnwatch(rootPath: string): void { - try { - this.mux.notify('fs.unwatch', { rootPath }) - } catch { - // Connection teardown may already have closed the mux; disposal must continue. - } - } - - private createWatchUnsubscribe( - rootPath: string, - registration: WatchRegistration, - callback: (events: FsChangeEvent[]) => void - ): () => void { - return () => { - registration.callbacks.delete(callback) - if (registration.callbacks.size === 0 && this.watchListeners.get(rootPath) === registration) { - this.watchListeners.delete(rootPath) - this.notifyUnwatch(rootPath) - } - } + return registerSshFilesystemWatch({ + mux: this.mux, + disposed: () => this.disposed, + registrations: this.watchListeners, + rootPath, + callback + }) } } diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index 7284f2353ba..398cc935142 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -1,5 +1,5 @@ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from './types' +import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from './types' import { toAppSshPtyId, toRelaySshPtyId } from './ssh-pty-id' import { seedPowerlevel10kWizardEnv } from '../pty/powerlevel10k-wizard-env' @@ -254,9 +254,9 @@ export class SshPtyProvider implements IPtyProvider { await this.mux.request('pty.revive', { state }) } - async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { + async listProcesses(): Promise { const result = await this.mux.request('pty.listProcesses') - return (result as { id: string; cwd: string; title: string }[]).map((session) => ({ + return (result as PtyProcessInfo[]).map((session) => ({ ...session, id: this.toAppPtyId(session.id) })) diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 0b148bf6ada..ec9061c5130 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -99,6 +99,14 @@ export type PtySpawnResult = { } } +export type PtyProcessInfo = { + id: string + cwd: string + title: string + /** Trusted ORCA_TERMINAL_HANDLE exported into this PTY, when known. */ + terminalHandle?: string +} + export type IPtyProvider = { spawn(opts: PtySpawnOptions): Promise attach(id: string): Promise @@ -128,7 +136,7 @@ export type IPtyProvider = { getForegroundProcess(id: string): Promise serialize(ids: string[]): Promise revive(state: string): Promise - listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> + listProcesses(): Promise getDefaultShell(): Promise getProfiles(): Promise<{ name: string; path: string }[]> onData(callback: (payload: { id: string; data: string }) => void): () => void @@ -142,6 +150,10 @@ export type FileStat = { size: number type: 'file' | 'directory' | 'symlink' mtime: number + mtimeMs?: number + dev?: number + ino?: number + nlink?: number } export type FileReadResult = { @@ -154,9 +166,18 @@ export type FileReadResult = { export type IFilesystemProvider = { readDir(dirPath: string): Promise readFile(filePath: string): Promise + readTerminalArtifact?( + filePath: string, + options: TerminalArtifactAccessOptions + ): Promise downloadFile?(sourcePath: string, destinationPath: string): Promise getTempDir?(): Promise writeFile(filePath: string, content: string): Promise + writeTerminalArtifact?( + filePath: string, + content: string, + options: TerminalArtifactAccessOptions + ): Promise writeFileBase64(filePath: string, contentBase64: string): Promise writeFileBase64Chunk(filePath: string, contentBase64: string, append: boolean): Promise stat(filePath: string): Promise @@ -178,6 +199,12 @@ export type IFilesystemProvider = { watch(rootPath: string, callback: (events: FsChangeEvent[]) => void): Promise<() => void> } +export type TerminalArtifactAccessOptions = { + expectedRealPath: string + expectedStatIdentity: string | null + maxBytes: number +} + // ─── Git Provider ─────────────────────────────────────────────────── export type GitProviderStatusOptions = { diff --git a/src/main/rate-limits/claude-fetcher.test.ts b/src/main/rate-limits/claude-fetcher.test.ts index 0790374ce0b..6571177a928 100644 --- a/src/main/rate-limits/claude-fetcher.test.ts +++ b/src/main/rate-limits/claude-fetcher.test.ts @@ -1,14 +1,17 @@ /* eslint-disable max-lines -- Why: Claude rate-limit fallback tests share account/keychain/PTY mocks that would be noisier split apart. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fetchClaudeRateLimits, fetchManagedAccountUsage } from './claude-fetcher' import { fetchViaPty } from './claude-pty' import { + deleteActiveClaudeKeychainCredentialsStrict, readActiveClaudeKeychainCredentials, readActiveClaudeKeychainCredentialsStrict, - readManagedClaudeKeychainCredentials + readManagedClaudeKeychainCredentials, + writeActiveClaudeKeychainCredentials, + writeManagedClaudeKeychainCredentials } from '../claude-accounts/keychain' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' @@ -46,9 +49,12 @@ vi.mock('./claude-pty', () => ({ })) vi.mock('../claude-accounts/keychain', () => ({ + deleteActiveClaudeKeychainCredentialsStrict: vi.fn(), readActiveClaudeKeychainCredentials: vi.fn(), readActiveClaudeKeychainCredentialsStrict: vi.fn(), - readManagedClaudeKeychainCredentials: vi.fn() + readManagedClaudeKeychainCredentials: vi.fn(), + writeActiveClaudeKeychainCredentials: vi.fn(), + writeManagedClaudeKeychainCredentials: vi.fn() })) const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') @@ -71,6 +77,9 @@ describe('fetchClaudeRateLimits', () => { vi.mocked(readActiveClaudeKeychainCredentials).mockResolvedValue(null) vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValue(null) vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValue(null) + vi.mocked(writeActiveClaudeKeychainCredentials).mockResolvedValue() + vi.mocked(deleteActiveClaudeKeychainCredentialsStrict).mockResolvedValue() + vi.mocked(writeManagedClaudeKeychainCredentials).mockResolvedValue() appGetPathMock.mockReturnValue('/tmp/orca-claude-fetcher-test') resolveProxyMock.mockResolvedValue('DIRECT') netFetchMock.mockResolvedValue( @@ -205,6 +214,111 @@ describe('fetchClaudeRateLimits', () => { }) }) + it('supplements managed-account OAuth usage with Fable from the CLI usage panel', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: { CLAUDE_CONFIG_DIR: configDir }, + stripAuthEnv: false, + provenance: 'managed:account-1' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'oauth-token', + expiresAt: Date.now() + 60_000 + } + }) + ) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + five_hour: { used_percentage: 23.5, resets_at: 1770000000 }, + seven_day: { used_percentage: 41.2, resets_at: 1770604800 } + }), + { status: 200 } + ) + ) + vi.mocked(fetchViaPty).mockResolvedValueOnce({ + provider: 'claude', + session: { usedPercent: 91, windowMinutes: 300, resetsAt: null, resetDescription: null }, + weekly: null, + fableWeekly: { + usedPercent: 12.3, + windowMinutes: 10080, + resetsAt: null, + resetDescription: '3d 2h' + }, + updatedAt: 1, + error: null, + status: 'ok' + }) + + await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { usedPercent: 23.5, resetsAt: 1770000000000 }, + weekly: { usedPercent: 41.2, resetsAt: 1770604800000 }, + fableWeekly: { usedPercent: 12.3, resetDescription: '3d 2h' }, + usageMetadata: { + source: 'oauth', + attemptedSources: ['oauth', 'cli'] + } + }) + expect(fetchViaPty).toHaveBeenCalledWith({ authPreparation }) + }) + + it('supplements system OAuth usage when the service explicitly allows usage-panel reads', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: { CLAUDE_CONFIG_DIR: configDir }, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'oauth-token', + expiresAt: Date.now() + 60_000 + } + }) + ) + vi.mocked(fetchViaPty).mockResolvedValueOnce({ + provider: 'claude', + session: null, + weekly: null, + fableWeekly: { + usedPercent: 58, + windowMinutes: 10080, + resetsAt: null, + resetDescription: '4d' + }, + updatedAt: 1, + error: null, + status: 'ok' + }) + + await expect( + fetchClaudeRateLimits({ + authPreparation, + allowPtyFallback: false, + allowUsagePanelSupplement: true + }) + ).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { usedPercent: 12 }, + weekly: { usedPercent: 34 }, + fableWeekly: { usedPercent: 58, resetDescription: '4d' }, + usageMetadata: { + source: 'oauth', + attemptedSources: ['oauth', 'cli'] + } + }) + expect(fetchViaPty).toHaveBeenCalledWith({ authPreparation }) + }) + it('ignores bare Fable OAuth usage because the window length is ambiguous', async () => { const configDir = '/Users/test/.claude' const authPreparation: ClaudeRuntimeAuthPreparation = { @@ -843,6 +957,239 @@ describe('fetchClaudeRateLimits', () => { expect(readFileMock).not.toHaveBeenCalled() }) + it('supplements inactive managed account OAuth usage with Fable from its usage panel', async () => { + setPlatform('linux') + tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) + appGetPathMock.mockReturnValue(tempDir) + const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') + mkdirSync(ownedAuthPath, { recursive: true }) + writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + const canonicalAuthPath = realpathSync(ownedAuthPath) + writeFileSync( + join(ownedAuthPath, '.credentials.json'), + JSON.stringify({ + claudeAiOauth: { + accessToken: 'inactive-token', + expiresAt: Date.now() + 60_000 + } + }), + 'utf-8' + ) + vi.mocked(fetchViaPty).mockResolvedValueOnce({ + provider: 'claude', + session: null, + weekly: null, + fableWeekly: { + usedPercent: 42, + windowMinutes: 10080, + resetsAt: null, + resetDescription: '2d' + }, + updatedAt: 1, + error: null, + status: 'ok' + }) + + await expect( + fetchManagedAccountUsage( + { id: 'account-1', managedAuthPath: ownedAuthPath }, + { allowUsagePanelSupplement: true } + ) + ).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { usedPercent: 12 }, + weekly: { usedPercent: 34 }, + fableWeekly: { usedPercent: 42, resetDescription: '2d' } + }) + expect(fetchViaPty).toHaveBeenCalledWith({ + authPreparation: expect.objectContaining({ + configDir: canonicalAuthPath, + envPatch: { CLAUDE_CONFIG_DIR: canonicalAuthPath }, + provenance: 'managed:account-1:inactive-preview', + stripAuthEnv: true + }) + }) + }) + + it('stages macOS inactive account credentials in a scoped Keychain for Fable preview', async () => { + setPlatform('darwin') + tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) + appGetPathMock.mockReturnValue(tempDir) + const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') + const credentialsJson = JSON.stringify({ + claudeAiOauth: { + accessToken: 'managed-keychain-token', + expiresAt: Date.now() + 60_000 + } + }) + mkdirSync(ownedAuthPath, { recursive: true }) + writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + const canonicalAuthPath = realpathSync(ownedAuthPath) + vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValueOnce(credentialsJson) + vi.mocked(fetchViaPty).mockResolvedValueOnce({ + provider: 'claude', + session: { + usedPercent: 12, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + }, + weekly: { + usedPercent: 34, + windowMinutes: 10080, + resetsAt: null, + resetDescription: null + }, + fableWeekly: { + usedPercent: 58, + windowMinutes: 10080, + resetsAt: null, + resetDescription: '3d' + }, + updatedAt: 1, + error: null, + status: 'ok' + }) + + const result = await fetchManagedAccountUsage( + { id: 'account-1', managedAuthPath: ownedAuthPath }, + { allowUsagePanelSupplement: true } + ) + + expect(result.fableWeekly).toMatchObject({ usedPercent: 58, resetDescription: '3d' }) + expect(writeActiveClaudeKeychainCredentials).toHaveBeenCalledWith( + credentialsJson, + canonicalAuthPath + ) + expect(deleteActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(canonicalAuthPath) + }) + + it('stages refreshed macOS inactive account credentials before Fable preview', async () => { + setPlatform('darwin') + tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) + appGetPathMock.mockReturnValue(tempDir) + const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') + const staleCredentialsJson = JSON.stringify({ + claudeAiOauth: { + accessToken: 'stale-access', + refreshToken: 'stale-refresh', + expiresAt: Date.now() - 60_000 + } + }) + mkdirSync(ownedAuthPath, { recursive: true }) + writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValueOnce(staleCredentialsJson) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'fresh-access', + expires_in: 3600, + refresh_token: 'fresh-refresh' + }), + { status: 200 } + ) + ) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ five_hour: { utilization: 12 }, seven_day: { utilization: 34 } }), + { + status: 200 + } + ) + ) + vi.mocked(fetchViaPty).mockResolvedValueOnce({ + provider: 'claude', + session: { + usedPercent: 12, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + }, + weekly: { + usedPercent: 34, + windowMinutes: 10080, + resetsAt: null, + resetDescription: null + }, + fableWeekly: { + usedPercent: 58, + windowMinutes: 10080, + resetsAt: null, + resetDescription: '3d' + }, + updatedAt: 1, + error: null, + status: 'ok' + }) + + const result = await fetchManagedAccountUsage( + { id: 'account-1', managedAuthPath: ownedAuthPath }, + { allowUsagePanelSupplement: true } + ) + + const stagedCredentialsJson = vi.mocked(writeActiveClaudeKeychainCredentials).mock.calls[0]?.[0] + expect(result.fableWeekly).toMatchObject({ usedPercent: 58, resetDescription: '3d' }) + expect(JSON.parse(stagedCredentialsJson ?? '{}')).toMatchObject({ + claudeAiOauth: { + accessToken: 'fresh-access', + refreshToken: 'fresh-refresh' + } + }) + expect(writeManagedClaudeKeychainCredentials).toHaveBeenCalledWith( + 'account-1', + stagedCredentialsJson + ) + }) + + it('does not merge macOS inactive Fable preview when usage windows belong to another account', async () => { + setPlatform('darwin') + tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) + appGetPathMock.mockReturnValue(tempDir) + const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') + mkdirSync(ownedAuthPath, { recursive: true }) + writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'managed-keychain-token', + expiresAt: Date.now() + 60_000 + } + }) + ) + vi.mocked(fetchViaPty).mockResolvedValueOnce({ + provider: 'claude', + session: { + usedPercent: 91, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + }, + weekly: { + usedPercent: 3, + windowMinutes: 10080, + resetsAt: null, + resetDescription: null + }, + fableWeekly: { + usedPercent: 58, + windowMinutes: 10080, + resetsAt: null, + resetDescription: '3d' + }, + updatedAt: 1, + error: null, + status: 'ok' + }) + + const result = await fetchManagedAccountUsage( + { id: 'account-1', managedAuthPath: ownedAuthPath }, + { allowUsagePanelSupplement: true } + ) + + expect(result.fableWeekly).toBeNull() + }) + it('refreshes and persists an expiring inactive account before fetching usage', async () => { setPlatform('linux') tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) diff --git a/src/main/rate-limits/claude-fetcher.ts b/src/main/rate-limits/claude-fetcher.ts index c89aef0473c..b546d21b83e 100644 --- a/src/main/rate-limits/claude-fetcher.ts +++ b/src/main/rate-limits/claude-fetcher.ts @@ -17,9 +17,11 @@ import { parseWslUncPath } from '../../shared/wsl-paths' import { fetchViaPty } from './claude-pty' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' import { + deleteActiveClaudeKeychainCredentialsStrict, readActiveClaudeKeychainCredentials, readActiveClaudeKeychainCredentialsStrict, - readManagedClaudeKeychainCredentials + readManagedClaudeKeychainCredentials, + writeActiveClaudeKeychainCredentials } from '../claude-accounts/keychain' import { readClaudeManagedAuthFile, @@ -518,6 +520,64 @@ async function fetchClaudeUsageViaCli(input: { ) } +function isManagedClaudeAuth(authPreparation: ClaudeRuntimeAuthPreparation | undefined): boolean { + return authPreparation?.provenance.startsWith('managed:') === true +} + +function canSupplementOAuthUsageFromCli(input: { + oauthLimits: ProviderRateLimits + authPreparation?: ClaudeRuntimeAuthPreparation + allowUsagePanelSupplement: boolean +}): boolean { + // Why: Fable is visible in Claude's interactive /usage panel even when the + // OAuth usage endpoint only reports documented 5h/7d windows. This runs only + // after OAuth succeeds, so it must not become a broad auth-recovery fallback. + return Boolean( + input.allowUsagePanelSupplement && + !input.authPreparation?.managedRefreshDeferredByLivePty && + !input.oauthLimits.fableWeekly && + (input.oauthLimits.session || input.oauthLimits.weekly) + ) +} + +function mergeClaudeUsageWindows( + primary: ProviderRateLimits, + supplement: ProviderRateLimits | null +): ProviderRateLimits { + if (!supplement) { + return primary + } + return { + ...primary, + session: primary.session ?? supplement.session, + weekly: primary.weekly ?? supplement.weekly, + fableWeekly: primary.fableWeekly ?? supplement.fableWeekly ?? null + } +} + +async function supplementOAuthUsageFromCli(input: { + oauthLimits: ProviderRateLimits + authPreparation?: ClaudeRuntimeAuthPreparation + oauthCredentials: OAuthCredentialReadResult + attempts: ClaudeUsageAttemptState + allowUsagePanelSupplement: boolean +}): Promise { + if (!canSupplementOAuthUsageFromCli(input)) { + return input.oauthLimits + } + try { + const cliLimits = await fetchClaudeUsageViaCli({ + authPreparation: input.authPreparation, + oauthCredentials: input.oauthCredentials, + attempts: input.attempts + }) + return mergeClaudeUsageWindows(input.oauthLimits, cliLimits) + } catch (err) { + warnClaudeUsageFetchFailure(input.authPreparation, input.oauthCredentials, err) + return input.oauthLimits + } +} + function shouldDeferForLiveClaude( authPreparation: ClaudeRuntimeAuthPreparation | undefined, classification: ClaudeUsageErrorClassification @@ -588,8 +648,9 @@ async function attemptCliRepairThenRetryOAuth(input: { recordAttempt(input.attempts, 'oauth') try { const oauthRetry = await fetchViaOAuth(refreshedCredentials.token) + const supplemented = mergeClaudeUsageWindows(oauthRetry, cliResult) return withClaudeUsageMetadata( - oauthRetry, + supplemented, metadataForAttempt({ attemptedSources: input.attempts.attemptedSources, oauthCredentials: refreshedCredentials, @@ -612,6 +673,7 @@ async function attemptCliRepairThenRetryOAuth(input: { export type FetchClaudeRateLimitsOptions = { authPreparation?: ClaudeRuntimeAuthPreparation allowPtyFallback?: boolean + allowUsagePanelSupplement?: boolean } export async function fetchClaudeRateLimits( @@ -643,7 +705,15 @@ export async function fetchClaudeRateLimits( if (plan.steps.some((step) => step.source === 'oauth') && oauthCredentials.token) { recordAttempt(attempts, 'oauth') try { - const limits = await fetchViaOAuth(oauthCredentials.token) + const oauthLimits = await fetchViaOAuth(oauthCredentials.token) + const limits = await supplementOAuthUsageFromCli({ + oauthLimits, + authPreparation: options?.authPreparation, + oauthCredentials, + attempts, + allowUsagePanelSupplement: + options?.allowUsagePanelSupplement ?? isManagedClaudeAuth(options?.authPreparation) + }) return withClaudeUsageMetadata( limits, metadataForAttempt({ @@ -817,7 +887,7 @@ export type InactiveClaudeAccountInfo = { } type ManagedCredentialsLocation = - | { kind: 'keychain'; accountId: string } + | { kind: 'keychain'; accountId: string; managedAuthPath: string } | { kind: 'file'; managedAuthPath: string } // Why: resolves where an inactive account's credentials live without @@ -839,7 +909,7 @@ function resolveManagedCredentialsLocation( // macOS stores host managed credentials in the Keychain; everything else // (and WSL, handled above) stores them as a file under the managed dir. if (process.platform === 'darwin') { - return { kind: 'keychain', accountId: account.id } + return { kind: 'keychain', accountId: account.id, managedAuthPath } } return { kind: 'file', managedAuthPath } } @@ -898,12 +968,127 @@ function resolveOwnedWslClaudeManagedAuthPath(account: InactiveClaudeAccountInfo } } -export async function fetchManagedAccountUsage( +function getManagedUsagePanelAuthPreparation( + account: InactiveClaudeAccountInfo, + location: ManagedCredentialsLocation +): ClaudeRuntimeAuthPreparation | null { + if (process.platform === 'win32') { + return null + } + if (account.managedAuthRuntime === 'wsl') { + if (!account.wslLinuxAuthPath || !account.wslDistro) { + return null + } + return { + configDir: location.managedAuthPath, + runtime: 'wsl', + wslDistro: account.wslDistro, + wslLinuxConfigDir: account.wslLinuxAuthPath, + envPatch: { CLAUDE_CONFIG_DIR: account.wslLinuxAuthPath }, + stripAuthEnv: true, + provenance: `managed:${account.id}:inactive-preview` + } + } + return { + configDir: location.managedAuthPath, + runtime: 'host', + wslDistro: null, + wslLinuxConfigDir: null, + envPatch: { CLAUDE_CONFIG_DIR: location.managedAuthPath }, + stripAuthEnv: true, + provenance: `managed:${account.id}:inactive-preview` + } +} + +function windowsAgree(left: RateLimitWindow | null, right: RateLimitWindow | null): boolean { + return Boolean(left && right && Math.abs(left.usedPercent - right.usedPercent) <= 1) +} + +function canTrustManagedUsagePanelSupplement( + oauthLimits: ProviderRateLimits, + cliLimits: ProviderRateLimits, + options: { requireMatchingOAuthWindow: boolean } +): boolean { + if (!options.requireMatchingOAuthWindow) { + return true + } + const sharedWindowMatches = [ + oauthLimits.session && cliLimits.session + ? windowsAgree(oauthLimits.session, cliLimits.session) + : null, + oauthLimits.weekly && cliLimits.weekly + ? windowsAgree(oauthLimits.weekly, cliLimits.weekly) + : null + ].filter((match): match is boolean => match !== null) + // Why: macOS inactive previews temporarily stage managed credentials in a + // scoped Keychain item. If an older Claude build ignores scoped Keychains, + // matching OAuth windows prevent active-account Fable data from leaking in. + return sharedWindowMatches.length > 0 && sharedWindowMatches.every(Boolean) +} + +async function withManagedPreviewKeychainCredentials( + location: ManagedCredentialsLocation, + credentialsJson: string, + fn: () => Promise +): Promise { + if (location.kind !== 'keychain') { + return fn() + } + await writeActiveClaudeKeychainCredentials(credentialsJson, location.managedAuthPath) + try { + return await fn() + } finally { + await deleteActiveClaudeKeychainCredentialsStrict(location.managedAuthPath).catch(() => {}) + } +} + +async function readStagedManagedPreviewCredentials( + location: ManagedCredentialsLocation +): Promise { + if (location.kind !== 'keychain') { + return null + } + try { + return await readActiveClaudeKeychainCredentialsStrict(location.managedAuthPath) + } catch { + return null + } +} + +async function fetchManagedUsagePanelSupplement(input: { account: InactiveClaudeAccountInfo + location: ManagedCredentialsLocation + credentialsJson: string + oauthLimits: ProviderRateLimits +}): Promise { + const authPreparation = getManagedUsagePanelAuthPreparation(input.account, input.location) + if (!authPreparation) { + return null + } + return withManagedPreviewKeychainCredentials(input.location, input.credentialsJson, async () => { + const cliLimits = await fetchViaPty({ authPreparation }) + if ( + !canTrustManagedUsagePanelSupplement(input.oauthLimits, cliLimits, { + requireMatchingOAuthWindow: input.location.kind === 'keychain' + }) + ) { + return null + } + const refreshedCredentials = await readStagedManagedPreviewCredentials(input.location) + if (refreshedCredentials && refreshedCredentials !== input.credentialsJson) { + await writeManagedCredentialsJson(input.location, refreshedCredentials) + } + return cliLimits + }) +} + +export async function fetchManagedAccountUsage( + account: InactiveClaudeAccountInfo, + options: { allowUsagePanelSupplement?: boolean } = {} ): Promise { const location = resolveManagedCredentialsLocation(account) - const credentialsJson = location ? await readManagedCredentialsJson(location) : null - if (!credentialsJson) { + let credentialsJson = location ? await readManagedCredentialsJson(location) : null + if (!location || !credentialsJson) { return { provider: 'claude', session: null, @@ -920,7 +1105,7 @@ export async function fetchManagedAccountUsage( // single-use refresh tokens fresh so a later switch-in never materializes a // stale token. Persistence failure is non-fatal: we still try the fetch. let token = parseOAuthCredentialsJson(credentialsJson, 'credentials-file').token - if (location && isOauthTokenExpiring(credentialsJson)) { + if (isOauthTokenExpiring(credentialsJson)) { const refreshed = await refreshClaudeOauthCredentials(credentialsJson) if (refreshed) { try { @@ -929,6 +1114,7 @@ export async function fetchManagedAccountUsage( // Keep going with the refreshed token in memory even if the write // failed; worst case the next poll refreshes again. } + credentialsJson = refreshed token = parseOAuthCredentialsJson(refreshed, 'credentials-file').token } } @@ -945,7 +1131,32 @@ export async function fetchManagedAccountUsage( } // Why: PTY fallback is intentionally omitted for inactive accounts. The PTY - // path materializes credentials via ClaudeRuntimeAuthService, which would - // interfere with the active account's auth state. - return fetchViaOAuth(token) + // path is used only as a supplement after OAuth succeeds, and it points + // directly at the managed account's isolated config so selection is unchanged. + const oauthLimits = await fetchViaOAuth(token) + if ( + !canSupplementOAuthUsageFromCli({ + oauthLimits, + authPreparation: undefined, + allowUsagePanelSupplement: options.allowUsagePanelSupplement === true + }) + ) { + return oauthLimits + } + try { + const cliLimits = await fetchManagedUsagePanelSupplement({ + account, + location, + credentialsJson, + oauthLimits + }) + return mergeClaudeUsageWindows(oauthLimits, cliLimits) + } catch (err) { + warnClaudeUsageFetchFailure( + undefined, + parseOAuthCredentialsJson(credentialsJson, 'credentials-file'), + err + ) + return oauthLimits + } } diff --git a/src/main/rate-limits/claude-pty.test.ts b/src/main/rate-limits/claude-pty.test.ts index a613194868e..acea13231c7 100644 --- a/src/main/rate-limits/claude-pty.test.ts +++ b/src/main/rate-limits/claude-pty.test.ts @@ -258,6 +258,49 @@ describe('fetchViaPty', () => { }) }) + it('parses Claude current-week Fable usage as a distinct weekly window', async () => { + const term = makeMockTerm() + spawnMock.mockReturnValue(term) + + const resultPromise = fetchViaPty() + + await vi.advanceTimersByTimeAsync(2_000) + term.emitData(` + Plan usage limits + + Current session + 8% used + Resets 3:39am + + Current week (all models) + 33% used + Resets Jul 3 at 12:59pm + + Current week (Fable) + 62% used + Resets Jul 3 at 12:59pm + `) + await vi.advanceTimersByTimeAsync(2_000) + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { + usedPercent: 8, + resetDescription: '3:39am' + }, + weekly: { + usedPercent: 33, + resetDescription: 'Jul 3 at 12:59pm' + }, + fableWeekly: { + usedPercent: 62, + resetDescription: 'Jul 3 at 12:59pm' + }, + error: null + }) + }) + it('does not let an incomplete Fable section consume later usage sections', async () => { const term = makeMockTerm() spawnMock.mockReturnValue(term) diff --git a/src/main/rate-limits/claude-pty.ts b/src/main/rate-limits/claude-pty.ts index f0363eba3f2..20919a391ca 100644 --- a/src/main/rate-limits/claude-pty.ts +++ b/src/main/rate-limits/claude-pty.ts @@ -22,6 +22,8 @@ const SESSION_RE = /current\s*session/i const WEEKLY_RE = /(?:current\s*week|weekly\s*(?:limits?|usage|rate\s*limits?)|7\s*[- ]?\s*day)/i const FABLE_WORD_RE = /\bfable\b/i const FABLE_LABEL_RE = /^\s*fable\s*$/i +const FABLE_WEEKLY_LABEL_RE = + /(?:current\s*week|weekly\s*(?:limits?|usage|rate\s*limits?)|7\s*[- ]?\s*day)\s*(?:\([^)]*\bfable\b[^)]*\)|[-:]?\s*\bfable\b)/i const PERCENT_RE = /(\d{1,3})(?:\.\d+)?\s*%\s*(used|consumed|left|remaining|available)/i const RESET_LINE_RE = /resets?\s+(?:at\s+|in\s+)?(.+)/i const ESC = String.fromCharCode(27) @@ -45,6 +47,12 @@ function matchesFableBoundary(line: string): boolean { return FABLE_LABEL_RE.test(line) || (FABLE_WORD_RE.test(line) && WEEKLY_RE.test(line)) } +function matchesFableUsageLabel(line: string): boolean { + // Why: broad Fable-weekly copy should stop nearby scans, but only a real + // Fable usage heading should produce the distinct Fable meter. + return FABLE_LABEL_RE.test(line) || FABLE_WEEKLY_LABEL_RE.test(line) +} + function isSectionLabel(line: string): boolean { return SESSION_RE.test(line) || matchesWeeklyLabel(line) || matchesFableBoundary(line) } @@ -104,7 +112,7 @@ function parsePtyUsage(output: string): { const sessionPct = extractPercentAfterLabel(lines, (line) => SESSION_RE.test(line)) const weeklyPct = extractPercentAfterLabel(lines, matchesWeeklyLabel) - const fableWeeklyPct = extractPercentAfterLabel(lines, (line) => FABLE_LABEL_RE.test(line)) + const fableWeeklyPct = extractPercentAfterLabel(lines, matchesFableUsageLabel) const session: RateLimitWindow | null = sessionPct !== null @@ -132,7 +140,7 @@ function parsePtyUsage(output: string): { usedPercent: Math.min(100, Math.max(0, fableWeeklyPct)), windowMinutes: 10080, resetsAt: null, - resetDescription: extractResetAfterLabel(lines, (line) => FABLE_LABEL_RE.test(line)) + resetDescription: extractResetAfterLabel(lines, matchesFableUsageLabel) } : null diff --git a/src/main/rate-limits/service.test.ts b/src/main/rate-limits/service.test.ts index 30394246253..5af9abca04f 100644 --- a/src/main/rate-limits/service.test.ts +++ b/src/main/rate-limits/service.test.ts @@ -353,7 +353,8 @@ describe('RateLimitService', () => { expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) expect(fetchClaudeRateLimits).toHaveBeenCalledWith({ authPreparation: undefined, - allowPtyFallback: false + allowPtyFallback: false, + allowUsagePanelSupplement: true }) expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1) expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(1) @@ -459,7 +460,8 @@ describe('RateLimitService', () => { wslLinuxConfigDir: '/home/jin/.claude', stripAuthEnv: true }), - allowPtyFallback: true + allowPtyFallback: true, + allowUsagePanelSupplement: true }) expect(service.getState().claudeTarget).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) }) @@ -483,7 +485,8 @@ describe('RateLimitService', () => { expect(fetchClaudeRateLimits).toHaveBeenCalledWith({ authPreparation: expect.objectContaining({ provenance: 'system' }), - allowPtyFallback: false + allowPtyFallback: false, + allowUsagePanelSupplement: true }) }) @@ -497,7 +500,8 @@ describe('RateLimitService', () => { expect(fetchClaudeRateLimits).toHaveBeenCalledWith({ authPreparation: undefined, - allowPtyFallback: false + allowPtyFallback: false, + allowUsagePanelSupplement: true }) }) @@ -521,7 +525,8 @@ describe('RateLimitService', () => { expect(fetchClaudeRateLimits).toHaveBeenCalledWith({ authPreparation: expect.objectContaining({ provenance: 'wsl:Ubuntu:system' }), - allowPtyFallback: false + allowPtyFallback: false, + allowUsagePanelSupplement: true }) }) @@ -580,7 +585,7 @@ describe('RateLimitService', () => { }) expect(fetchClaudeRateLimits).toHaveBeenLastCalledWith( - expect.objectContaining({ allowPtyFallback: true }) + expect.objectContaining({ allowPtyFallback: true, allowUsagePanelSupplement: true }) ) expect(service.getState().inactiveClaudeAccounts).not.toEqual( @@ -616,6 +621,19 @@ describe('RateLimitService', () => { ]) }) + it('allows usage-panel Fable supplements for inactive Claude account previews', async () => { + const service = new RateLimitService() + const account = { id: 'account-1', managedAuthPath: '/tmp/account-1/auth' } + service.setInactiveClaudeAccountsResolver(() => [account]) + vi.mocked(fetchManagedAccountUsage).mockResolvedValueOnce(okProvider('claude', 33, Date.now())) + + await service.fetchInactiveClaudeAccountsOnOpen() + + expect(fetchManagedAccountUsage).toHaveBeenCalledWith(account, { + allowUsagePanelSupplement: true + }) + }) + it('does not start overlapping inactive Codex preview fetches', async () => { const service = new RateLimitService() const accountFetch = deferred() diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index 42d8f8c3580..5225485ee27 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -382,7 +382,9 @@ export class RateLimitService { continue } try { - const fresh = await fetchManagedAccountUsage(account) + const fresh = await fetchManagedAccountUsage(account, { + allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement() + }) if ( fetchGeneration !== this.inactiveClaudeAccountsGeneration || !this.isCurrentInactiveClaudeAccount(account.id) @@ -818,6 +820,12 @@ export class RateLimitService { return !isSystemDefaultClaudeAuth(authPreparation) } + private shouldAllowClaudeUsagePanelSupplement(): boolean { + // Why: this supplement runs only after OAuth has already returned usage + // data. Keep it off on Windows where hidden PTYs are still less reliable. + return process.platform !== 'win32' + } + private withFetchingStatus( current: ProviderRateLimits | null, provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' @@ -881,7 +889,8 @@ export class RateLimitService { await Promise.allSettled([ fetchClaudeRateLimits({ authPreparation: claudeAuthPreparation, - allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation) + allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), + allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement() }), missingWslCodexHome ?? fetchCodexRateLimits({ @@ -1056,7 +1065,8 @@ export class RateLimitService { const claude = await fetchClaudeRateLimits({ authPreparation: claudeAuthPreparation, - allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation) + allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), + allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement() }).catch( (err): ProviderRateLimits => ({ provider: 'claude', diff --git a/src/main/runtime/orca-runtime-files.test.ts b/src/main/runtime/orca-runtime-files.test.ts index d1605dc64c6..8412a7740be 100644 --- a/src/main/runtime/orca-runtime-files.test.ts +++ b/src/main/runtime/orca-runtime-files.test.ts @@ -3,6 +3,9 @@ setup that makes cross-command filesystem behavior comparable. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { EventEmitter } from 'node:events' +import { link, mkdtemp, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import type * as Fs from 'node:fs' import type * as FsPromises from 'node:fs/promises' import type * as FilesystemAuth from '../ipc/filesystem-auth' @@ -10,6 +13,7 @@ import type * as GitRunner from '../git/runner' const { lstatMock, + openMock, readdirMock, renameMock, resolveAuthorizedPathMock, @@ -23,6 +27,7 @@ const { checkRgAvailableMock: vi.fn(), getLocalGitOptionsForRegisteredWorktreeMock: vi.fn(), lstatMock: vi.fn(), + openMock: vi.fn(), readdirMock: vi.fn(), renameMock: vi.fn(), resolveAuthorizedPathMock: vi.fn(), @@ -45,6 +50,10 @@ vi.mock('fs/promises', async () => { return { ...actual, lstat: lstatMock, + open: (...args: Parameters) => { + const impl = openMock.getMockImplementation() + return impl ? openMock(...args) : actual.open(...args) + }, readdir: readdirMock, rename: renameMock, stat: statMock @@ -128,6 +137,10 @@ function createRuntimeFileCommands(options?: { openDiff?: ReturnType resolveRuntimeFileTarget?: ReturnType resolveRuntimeGitTarget?: ReturnType + resolveTerminalCwd?: ReturnType + resolveTerminalContext?: ReturnType + resolveTerminalFileUriHostname?: ReturnType + hasRecentTerminalOutputPath?: ReturnType }) { const store = { getRepo: vi.fn((_repoId?: string) => undefined as { connectionId?: string } | undefined) @@ -148,6 +161,17 @@ function createRuntimeFileCommands(options?: { worktree, connectionId: store.getRepo(worktree.repoId)?.connectionId })), + resolveTerminalCwd: options?.resolveTerminalCwd ?? vi.fn(() => path), + resolveTerminalContext: + options?.resolveTerminalContext ?? + vi.fn(() => ({ + worktreeId: worktree.id, + connectionId: store.getRepo(worktree.repoId)?.connectionId ?? null + })), + ...(options?.resolveTerminalFileUriHostname + ? { resolveTerminalFileUriHostname: options.resolveTerminalFileUriHostname } + : {}), + hasRecentTerminalOutputPath: options?.hasRecentTerminalOutputPath ?? vi.fn(() => true), resolveRuntimeGitTarget: options?.resolveRuntimeGitTarget ?? vi.fn(), openFile: options?.openFile ?? vi.fn(), ...(options?.openDiff ? { openDiff: options.openDiff } : {}) @@ -170,6 +194,7 @@ describe('RuntimeFileCommands', () => { beforeEach(() => { vi.useFakeTimers() lstatMock.mockReset() + openMock.mockReset() readdirMock.mockReset() renameMock.mockReset() resolveAuthorizedPathMock.mockReset() @@ -177,6 +202,7 @@ describe('RuntimeFileCommands', () => { watchInWorkerMock.mockReset() watchMock.mockReset() checkRgAvailableMock.mockReset() + vi.mocked(getSshFilesystemProvider).mockReset() getLocalGitOptionsForRegisteredWorktreeMock.mockReset() wslAwareSpawnMock.mockReset() getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({}) @@ -510,9 +536,72 @@ describe('RuntimeFileCommands', () => { }) describe('resolveTerminalPath', () => { + let tempDirs: string[] = [] + + afterEach(async () => { + await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true }))) + tempDirs = [] + }) + + async function tempFile(name: string, content: string): Promise { + const dir = await mkdtemp(join(tmpdir(), 'orca-terminal-artifact-')) + tempDirs.push(dir) + const filePath = join(dir, name) + await writeFile(filePath, content) + return filePath + } + + function absoluteFileTarget(result: { + openTarget?: { kind: string; absolutePath?: string; grantId?: string } + }): { absolutePath: string; grantId: string } { + if ( + result.openTarget?.kind !== 'absolute-file' || + typeof result.openTarget.absolutePath !== 'string' || + typeof result.openTarget.grantId !== 'string' + ) { + throw new Error('Expected an absolute terminal artifact target') + } + return result.openTarget as { absolutePath: string; grantId: string } + } + function statAsFile() { resolveAuthorizedPathMock.mockImplementation(async (p: string) => p) - statMock.mockResolvedValue({ isDirectory: () => false }) + statMock.mockResolvedValue({ isDirectory: () => false, size: 12, dev: 1, ino: 2, mtimeMs: 3 }) + } + + function resolveTerminalArtifactPath( + commands: RuntimeFileCommands, + pathText: string, + cwd: string | null = null, + clientId = 'client-a' + ) { + return commands.resolveTerminalPath('id:wt-1', pathText, cwd, clientId, 'term-1') + } + + function createRemoteTerminalArtifactGrantFixture(artifactPath = '/tmp/result.json') { + const { commands, store } = createRuntimeFileCommands({ path: '/repo' }) + store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) + let realArtifactPath = artifactPath + const stat = vi.fn().mockResolvedValue({ type: 'file', size: 11, mtime: 3 }) + const readTerminalArtifact = vi + .fn() + .mockResolvedValue({ content: '{"ok":true}', isBinary: false }) + const writeTerminalArtifact = vi.fn().mockResolvedValue({ type: 'file', size: 12, mtime: 4 }) + const realpath = vi.fn(async (p: string) => (p === artifactPath ? realArtifactPath : p)) + vi.mocked(getSshFilesystemProvider).mockReturnValue({ + stat, + readTerminalArtifact, + realpath, + writeTerminalArtifact + } as never) + return { + commands, + readTerminalArtifact, + writeTerminalArtifact, + moveArtifactTarget: (nextPath: string) => { + realArtifactPath = nextPath + } + } } it('resolves an absolute path inside the worktree to a relative path', async () => { @@ -526,7 +615,13 @@ describe('RuntimeFileCommands', () => { relativePath: 'src/index.ts', absolutePath: '/repo/src/index.ts', exists: true, - isDirectory: false + isDirectory: false, + openTarget: { + kind: 'worktree-file', + provider: 'local', + relativePath: 'src/index.ts', + absolutePath: '/repo/src/index.ts' + } }) }) @@ -539,6 +634,56 @@ describe('RuntimeFileCommands', () => { expect(result).toMatchObject({ relativePath: 'src/index.ts', exists: true }) }) + it('prefers the source terminal cwd over stale mobile cached cwd metadata', async () => { + const resolveTerminalCwd = vi.fn(() => '/repo/current') + const { commands } = createRuntimeFileCommands({ path: '/repo', resolveTerminalCwd }) + statAsFile() + + const result = await commands.resolveTerminalPath( + 'id:wt-1', + 'index.ts', + '/repo/stale', + 'client-a', + 'term-1' + ) + + expect(resolveTerminalCwd).toHaveBeenCalledWith('term-1') + expect(result).toMatchObject({ relativePath: 'current/index.ts', exists: true }) + }) + + it('falls back to the source terminal cwd when mobile has not cached cwd metadata', async () => { + const resolveTerminalCwd = vi.fn(() => '/repo/src') + const { commands } = createRuntimeFileCommands({ path: '/repo', resolveTerminalCwd }) + statAsFile() + + const result = await commands.resolveTerminalPath( + 'id:wt-1', + 'index.ts', + null, + 'client-a', + 'term-1' + ) + + expect(resolveTerminalCwd).toHaveBeenCalledWith('term-1') + expect(result).toMatchObject({ relativePath: 'src/index.ts', exists: true }) + }) + + it('awaits async source terminal cwd fallback from the PTY provider', async () => { + const resolveTerminalCwd = vi.fn(async () => '/repo/packages/app') + const { commands } = createRuntimeFileCommands({ path: '/repo', resolveTerminalCwd }) + statAsFile() + + const result = await commands.resolveTerminalPath( + 'id:wt-1', + 'package.json', + null, + 'client-a', + 'term-1' + ) + + expect(result).toMatchObject({ relativePath: 'packages/app/package.json', exists: true }) + }) + it('resolves a relative path against the worktree root when no cwd is given', async () => { const { commands } = createRuntimeFileCommands({ path: '/repo' }) statAsFile() @@ -558,21 +703,939 @@ describe('RuntimeFileCommands', () => { expect(result).toMatchObject({ relativePath: 'src', isDirectory: true, exists: true }) }) - it('returns null relativePath for a path outside the worktree', async () => { + it('returns an absolute open target for an existing local temp path outside the worktree', async () => { + const artifactPath = await tempFile('result.json', '{}') + const { commands } = createRuntimeFileCommands({ path: '/repo' }) + resolveAuthorizedPathMock.mockImplementation(async (p: string) => p) + statMock.mockResolvedValue({ isDirectory: () => false, size: 12, dev: 1, ino: 2, mtimeMs: 3 }) + const canonicalPath = await realpath(artifactPath) + + const result = await resolveTerminalArtifactPath(commands, artifactPath) + + expect(result).toMatchObject({ + worktree: 'wt-1', + relativePath: null, + absolutePath: canonicalPath, + exists: true, + isDirectory: false, + openTarget: { + kind: 'absolute-file', + provider: 'local', + absolutePath: canonicalPath + } + }) + expect(result.openTarget?.kind === 'absolute-file' ? result.openTarget.grantId : '').toMatch( + /\S/ + ) + expect(resolveAuthorizedPathMock).not.toHaveBeenCalledWith(canonicalPath, expect.anything()) + expect(statMock).not.toHaveBeenCalled() + }) + + it('does not mint an absolute terminal artifact grant without a source terminal', async () => { + const artifactPath = await tempFile('result.json', '{}') const { commands } = createRuntimeFileCommands({ path: '/repo' }) - const result = await commands.resolveTerminalPath('id:wt-1', '/etc/passwd') + const result = await commands.resolveTerminalPath('id:wt-1', artifactPath, null, 'client-a') + + expect(result).toMatchObject({ + worktree: 'wt-1', + relativePath: null, + exists: false, + isDirectory: false + }) + expect(result.openTarget).toBeUndefined() + }) + + it('does not mint an absolute terminal artifact grant for an unobserved path', async () => { + const artifactPath = await tempFile('result.json', '{}') + const hasRecentTerminalOutputPath = vi.fn( + (_terminalHandle: string, _pathText: string, _absolutePath: string) => false + ) + const { commands } = createRuntimeFileCommands({ + path: '/repo', + hasRecentTerminalOutputPath + }) + + const result = await resolveTerminalArtifactPath(commands, artifactPath) + + expect(result).toMatchObject({ + worktree: 'wt-1', + relativePath: null, + exists: false, + isDirectory: false + }) + expect(result.openTarget).toBeUndefined() + expect(hasRecentTerminalOutputPath).toHaveBeenCalledTimes(1) + const observed = hasRecentTerminalOutputPath.mock.calls[0]! + expect(observed[0]).toBe('term-1') + expect(observed[1]).toBe(artifactPath) + expect(observed[2]).toContain('result.json') + expect(observed[1]).toContain('result.json') + }) + + it('does not mint an absolute artifact grant from a relative path observed under stale cwd', async () => { + const dir = await mkdtemp(join(tmpdir(), 'orca-terminal-artifact-')) + tempDirs.push(dir) + const artifactPath = join(dir, 'result.json') + await writeFile(artifactPath, '{}') + const hasRecentTerminalOutputPath = vi.fn( + (_terminalHandle: string, pathText: string, _absolutePath: string) => + pathText === 'result.json' + ) + const { commands } = createRuntimeFileCommands({ + path: '/repo', + hasRecentTerminalOutputPath + }) + resolveAuthorizedPathMock.mockImplementation(async (p: string) => p) + statMock.mockRejectedValue(enoent()) + + const result = await resolveTerminalArtifactPath(commands, 'result.json', dir) + + expect(result).toMatchObject({ + relativePath: 'result.json', + absolutePath: '/repo/result.json', + exists: false + }) + expect(result.openTarget).toBeUndefined() + expect(hasRecentTerminalOutputPath).not.toHaveBeenCalled() + }) + + it('does not mint an artifact grant from a terminal attached to a different worktree', async () => { + const artifactPath = await tempFile('result.json', '{}') + const { commands } = createRuntimeFileCommands({ + path: '/repo', + resolveTerminalContext: vi.fn(() => ({ worktreeId: 'other-wt', connectionId: null })) + }) + + const result = await resolveTerminalArtifactPath(commands, artifactPath) + + expect(result).toMatchObject({ + worktree: 'wt-1', + relativePath: null, + exists: false, + isDirectory: false + }) + expect(result.openTarget).toBeUndefined() + }) + + it('does not mint a local artifact grant from an SSH terminal handle', async () => { + const artifactPath = await tempFile('result.json', '{}') + const { commands } = createRuntimeFileCommands({ + path: '/repo', + resolveTerminalContext: vi.fn(() => ({ worktreeId: 'wt-1', connectionId: 'ssh-1' })) + }) + + const result = await resolveTerminalArtifactPath(commands, artifactPath) + + expect(result).toMatchObject({ + worktree: 'wt-1', + relativePath: null, + exists: false, + isDirectory: false + }) + expect(result.openTarget).toBeUndefined() + }) + + it('uses the canonical local temp artifact path for the exact grant', async () => { + const dir = await mkdtemp(join(tmpdir(), 'orca-terminal-artifact-')) + tempDirs.push(dir) + const artifactPath = join(dir, 'result.json') + const linkPath = join(dir, 'link-result.json') + await writeFile(artifactPath, '{}') + await symlink(artifactPath, linkPath) + const { commands } = createRuntimeFileCommands({ path: '/repo' }) + resolveAuthorizedPathMock.mockImplementation(async (p: string) => p) + statMock.mockResolvedValue({ isDirectory: () => false, size: 2, dev: 1, ino: 2, mtimeMs: 3 }) + const canonicalPath = await realpath(artifactPath) + + const result = await resolveTerminalArtifactPath(commands, linkPath) + + expect(result).toMatchObject({ + relativePath: null, + absolutePath: canonicalPath, + exists: true, + openTarget: { + kind: 'absolute-file', + provider: 'local', + absolutePath: canonicalPath + } + }) + expect(resolveAuthorizedPathMock).not.toHaveBeenCalledWith(canonicalPath, expect.anything()) + expect(resolveAuthorizedPathMock).not.toHaveBeenCalledWith(linkPath, expect.anything()) + }) + + it('does not grant hard-linked local temp artifacts', async () => { + const dir = await mkdtemp(join(tmpdir(), 'orca-terminal-artifact-')) + tempDirs.push(dir) + const originalPath = join(dir, 'outside.json') + const artifactPath = join(dir, 'result.json') + await writeFile(originalPath, '{"secret":true}') + await link(originalPath, artifactPath) + const { commands } = createRuntimeFileCommands({ path: '/repo' }) + + const result = await resolveTerminalArtifactPath(commands, artifactPath) + + expect(result).toMatchObject({ + relativePath: null, + absolutePath: artifactPath, + exists: false + }) + expect(result.openTarget).toBeUndefined() + }) + + it('does not grant arbitrary absolute local paths outside temp roots', async () => { + const { commands } = createRuntimeFileCommands({ path: '/repo' }) + + const result = await commands.resolveTerminalPath('id:wt-1', '/etc/passwd', null, 'client-a') expect(result).toEqual({ worktree: 'wt-1', relativePath: null, - absolutePath: null, + absolutePath: '/etc/passwd', exists: false, isDirectory: false }) + expect(resolveAuthorizedPathMock).not.toHaveBeenCalled() expect(statMock).not.toHaveBeenCalled() }) + it('uses the canonical remote temp artifact path for the exact grant', async () => { + const { commands, store } = createRuntimeFileCommands({ path: '/repo' }) + store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) + const stat = vi.fn().mockResolvedValue({ type: 'file', size: 2, mtime: 3 }) + const realpath = vi.fn(async (p: string) => + p === '/tmp/link-result.json' ? '/tmp/result.json' : p + ) + vi.mocked(getSshFilesystemProvider).mockReturnValue({ stat, realpath } as never) + + const result = await resolveTerminalArtifactPath(commands, '/tmp/link-result.json') + + expect(stat).toHaveBeenCalledWith('/tmp/result.json') + expect(result).toMatchObject({ + relativePath: null, + absolutePath: '/tmp/result.json', + exists: true, + openTarget: { + kind: 'absolute-file', + provider: 'ssh', + absolutePath: '/tmp/result.json' + } + }) + }) + + it('does not grant hard-linked remote temp artifacts', async () => { + const { commands, store } = createRuntimeFileCommands({ path: '/repo' }) + store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) + const stat = vi.fn().mockResolvedValue({ type: 'file', size: 2, mtime: 3, nlink: 2 }) + const realpath = vi.fn(async (p: string) => p) + vi.mocked(getSshFilesystemProvider).mockReturnValue({ stat, realpath } as never) + + const result = await resolveTerminalArtifactPath(commands, '/tmp/result.json') + + expect(result).toMatchObject({ + relativePath: null, + absolutePath: '/tmp/result.json', + exists: false + }) + expect(result.openTarget).toBeUndefined() + }) + + it('allows canonical remote macOS private temp artifacts', async () => { + const { commands, store } = createRuntimeFileCommands({ path: '/repo' }) + store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) + const stat = vi.fn().mockResolvedValue({ type: 'file', size: 2, mtime: 3 }) + const realpath = vi.fn(async (p: string) => p) + vi.mocked(getSshFilesystemProvider).mockReturnValue({ stat, realpath } as never) + + const result = await resolveTerminalArtifactPath(commands, '/private/tmp/result.json') + + expect(stat).toHaveBeenCalledWith('/private/tmp/result.json') + expect(result).toMatchObject({ + relativePath: null, + absolutePath: '/private/tmp/result.json', + exists: true, + openTarget: { + kind: 'absolute-file', + provider: 'ssh', + absolutePath: '/private/tmp/result.json' + } + }) + }) + + it('does not grant remote temp artifacts that resolve outside allowed temp roots', async () => { + const { commands, store } = createRuntimeFileCommands({ path: '/repo' }) + store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) + const stat = vi.fn() + const realpath = vi.fn(async (p: string) => + p === '/tmp/link-result.json' ? '/home/me/.ssh/config' : p + ) + vi.mocked(getSshFilesystemProvider).mockReturnValue({ stat, realpath } as never) + + const result = await commands.resolveTerminalPath( + 'id:wt-1', + '/tmp/link-result.json', + null, + 'client-a' + ) + + expect(result).toEqual({ + worktree: 'wt-1', + relativePath: null, + absolutePath: '/tmp/link-result.json', + exists: false, + isDirectory: false + }) + expect(stat).not.toHaveBeenCalled() + }) + + it('translates WSL absolute in-worktree paths before checking containment', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const worktreePath = '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo' + const expectedPath = '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo\\src\\index.ts' + const { commands } = createRuntimeFileCommands({ path: worktreePath }) + statAsFile() + + const result = await commands.resolveTerminalPath('id:wt-1', '/home/me/repo/src/index.ts') + + expect(result).toMatchObject({ + relativePath: 'src/index.ts', + absolutePath: expectedPath, + exists: true, + openTarget: { + kind: 'worktree-file', + provider: 'local', + relativePath: 'src/index.ts', + absolutePath: expectedPath + } + }) + expect(resolveAuthorizedPathMock).toHaveBeenCalledWith(expectedPath, expect.anything()) + }) + + it('does not translate UNC-style paths as WSL POSIX paths', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const worktreePath = '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo' + const { commands } = createRuntimeFileCommands({ path: worktreePath }) + + const result = await commands.resolveTerminalPath( + 'id:wt-1', + '//remote-host/tmp/result.json', + null, + 'client-a' + ) + + expect(result).toEqual({ + worktree: 'wt-1', + relativePath: null, + absolutePath: '//remote-host/tmp/result.json', + exists: false, + isDirectory: false + }) + expect(resolveAuthorizedPathMock).not.toHaveBeenCalled() + expect(statMock).not.toHaveBeenCalled() + }) + + it('preserves UNC-style paths for local Windows terminal links', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const worktreePath = '\\\\server\\share\\repo' + const expectedPath = '//server/share/repo/src/index.ts' + const { commands } = createRuntimeFileCommands({ path: worktreePath }) + statAsFile() + + const result = await commands.resolveTerminalPath('id:wt-1', expectedPath) + + expect(result).toMatchObject({ + relativePath: 'src/index.ts', + absolutePath: expectedPath, + exists: true, + openTarget: { + kind: 'worktree-file', + provider: 'local', + relativePath: 'src/index.ts', + absolutePath: expectedPath + } + }) + }) + + it('preserves local Windows UNC file URI authority even when OSC7 reported the host', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const worktreePath = '\\\\server\\share\\repo' + const expectedPath = '//server/share/repo/src/index.ts' + const resolveTerminalFileUriHostname = vi.fn(() => 'server') + const { commands } = createRuntimeFileCommands({ + path: worktreePath, + resolveTerminalFileUriHostname + }) + statAsFile() + + const result = await commands.resolveTerminalPath( + 'id:wt-1', + expectedPath, + null, + 'client-a', + 'term-1' + ) + + expect(resolveTerminalFileUriHostname).toHaveBeenCalledWith('term-1') + expect(result).toMatchObject({ + relativePath: 'src/index.ts', + absolutePath: expectedPath, + exists: true, + openTarget: { + kind: 'worktree-file', + provider: 'local', + relativePath: 'src/index.ts', + absolutePath: expectedPath + } + }) + }) + + it('preserves host-qualified local POSIX terminal links from unverified OSC7 host metadata', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + const artifactPath = await tempFile('result.json', '{}') + const resolveTerminalFileUriHostname = vi.fn(() => 'laptop.local') + const { commands } = createRuntimeFileCommands({ + path: '/repo', + resolveTerminalFileUriHostname + }) + + const result = await resolveTerminalArtifactPath(commands, `//laptop.local${artifactPath}`) + + expect(resolveTerminalFileUriHostname).toHaveBeenCalledWith('term-1') + expect(result).toMatchObject({ + relativePath: null, + absolutePath: `//laptop.local${artifactPath}`, + exists: false + }) + expect(result.openTarget).toBeUndefined() + }) + + it('opens IPv4 loopback local POSIX terminal links as local paths', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + const artifactPath = await tempFile('result.json', '{}') + const { commands } = createRuntimeFileCommands({ path: '/repo' }) + const canonicalPath = await realpath(artifactPath) + + const result = await resolveTerminalArtifactPath(commands, `//127.0.0.1${artifactPath}`) + + expect(result).toMatchObject({ + relativePath: null, + absolutePath: canonicalPath, + exists: true, + openTarget: { + kind: 'absolute-file', + provider: 'local', + absolutePath: canonicalPath + } + }) + }) + + it('opens host-qualified remote POSIX terminal links when the source terminal verified the host', async () => { + const resolveTerminalFileUriHostname = vi.fn(() => 'remote-host') + const hasRecentTerminalOutputPath = vi.fn(() => true) + const { commands, store } = createRuntimeFileCommands({ + path: '/home/me/repo', + resolveTerminalFileUriHostname, + hasRecentTerminalOutputPath + }) + store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) + const stat = vi.fn().mockResolvedValue({ type: 'file', size: 2, mtime: 3 }) + const realpath = vi.fn(async (p: string) => p) + vi.mocked(getSshFilesystemProvider).mockReturnValue({ stat, realpath } as never) + + const result = await resolveTerminalArtifactPath(commands, '//remote-host/tmp/result.json') + + expect(resolveTerminalFileUriHostname).toHaveBeenCalledWith('term-1') + expect(hasRecentTerminalOutputPath).toHaveBeenCalledWith( + 'term-1', + '//remote-host/tmp/result.json', + '/tmp/result.json' + ) + expect(stat).toHaveBeenCalledWith('/tmp/result.json') + expect(result).toMatchObject({ + relativePath: null, + absolutePath: '/tmp/result.json', + exists: true, + openTarget: { + kind: 'absolute-file', + provider: 'ssh', + absolutePath: '/tmp/result.json' + } + }) + }) + + it('opens host-qualified Windows SSH worktree file URLs with a drive path', async () => { + const resolveTerminalFileUriHostname = vi.fn(() => 'remote-host') + const { commands, store } = createRuntimeFileCommands({ + path: 'C:/Users/me/repo', + resolveTerminalFileUriHostname + }) + store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) + const stat = vi.fn().mockResolvedValue({ type: 'file', size: 2, mtime: 3 }) + const realpath = vi.fn(async (p: string) => p) + vi.mocked(getSshFilesystemProvider).mockReturnValue({ stat, realpath } as never) + + const result = await commands.resolveTerminalPath( + 'id:wt-1', + '//remote-host/C:/Users/me/repo/src/app.ts', + null, + 'client-a', + 'term-1' + ) + + expect(result).toMatchObject({ + relativePath: 'src/app.ts', + absolutePath: 'C:/Users/me/repo/src/app.ts', + exists: true, + openTarget: { + kind: 'worktree-file', + provider: 'ssh', + relativePath: 'src/app.ts', + absolutePath: 'C:/Users/me/repo/src/app.ts' + } + }) + }) + + it('rejects host-qualified remote POSIX terminal links without a verified host match', async () => { + const { commands, store } = createRuntimeFileCommands({ path: '/home/me/repo' }) + store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) + const stat = vi.fn().mockResolvedValue({ type: 'file', size: 2, mtime: 3 }) + const realpath = vi.fn(async (p: string) => p) + vi.mocked(getSshFilesystemProvider).mockReturnValue({ stat, realpath } as never) + + const result = await commands.resolveTerminalPath( + 'id:wt-1', + '//remote-host/tmp/result.json', + null, + 'client-a' + ) + + expect(stat).not.toHaveBeenCalled() + expect(result).toEqual({ + worktree: 'wt-1', + relativePath: null, + absolutePath: '//remote-host/tmp/result.json', + exists: false, + isDirectory: false + }) + }) + + it('translates WSL temp artifacts before granting the exact path', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const worktreePath = '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo' + const artifactPath = '\\\\wsl.localhost\\Ubuntu\\tmp\\result.json' + const { commands } = createRuntimeFileCommands({ path: worktreePath }) + resolveAuthorizedPathMock.mockImplementation(async (p: string) => p) + statMock.mockResolvedValue({ isDirectory: () => false, size: 2, dev: 1, ino: 2, mtimeMs: 3 }) + openMock.mockResolvedValue({ + stat: vi.fn(async () => ({ + isDirectory: () => false, + size: 2, + dev: 1, + ino: 2, + mtimeMs: 3 + })), + close: vi.fn(async () => undefined) + }) + + const result = await resolveTerminalArtifactPath(commands, '/tmp/result.json') + + expect(result).toMatchObject({ + relativePath: null, + absolutePath: artifactPath, + exists: true, + openTarget: { + kind: 'absolute-file', + provider: 'local', + absolutePath: artifactPath + } + }) + expect(resolveAuthorizedPathMock).not.toHaveBeenCalledWith(artifactPath, expect.anything()) + }) + + it('reads an absolute terminal artifact only for the client that received the grant', async () => { + const artifactPath = await tempFile('result.json', '{"ok":true}') + const { commands } = createRuntimeFileCommands({ path: '/repo' }) + resolveAuthorizedPathMock.mockImplementation(async (p: string) => p) + statMock.mockResolvedValue({ isDirectory: () => false, size: 11, dev: 1, ino: 2, mtimeMs: 3 }) + + const result = await resolveTerminalArtifactPath(commands, artifactPath) + const target = absoluteFileTarget(result) + + await expect( + commands.readTerminalArtifactFile( + 'id:wt-1', + target.grantId, + target.absolutePath, + 'client-a' + ) + ).resolves.toMatchObject({ + relativePath: target.absolutePath, + content: '{"ok":true}', + truncated: false + }) + await expect( + commands.readTerminalArtifactFile( + 'id:wt-1', + target.grantId, + target.absolutePath, + 'client-b' + ) + ).rejects.toThrow('terminal_file_grant_mismatch') + }) + + it('revokes absolute terminal artifact grants when the owning client disconnects', async () => { + const artifactPath = await tempFile('result.json', '{}') + const { commands } = createRuntimeFileCommands({ path: '/repo' }) + resolveAuthorizedPathMock.mockImplementation(async (p: string) => p) + statMock.mockResolvedValue({ isDirectory: () => false, size: 2, dev: 1, ino: 2, mtimeMs: 3 }) + + const result = await resolveTerminalArtifactPath(commands, artifactPath) + const target = absoluteFileTarget(result) + + commands.revokeTerminalFileGrantsForClient('client-a') + + await expect( + commands.readTerminalArtifactFile( + 'id:wt-1', + target.grantId, + target.absolutePath, + 'client-a' + ) + ).rejects.toThrow('terminal_file_grant_expired') + }) + + it('expires absolute terminal artifact grants without waiting for another tap', async () => { + const artifactPath = await tempFile('result.json', '{}') + const { commands } = createRuntimeFileCommands({ path: '/repo' }) + resolveAuthorizedPathMock.mockImplementation(async (p: string) => p) + statMock.mockResolvedValue({ isDirectory: () => false, size: 2, dev: 1, ino: 2, mtimeMs: 3 }) + + const result = await resolveTerminalArtifactPath(commands, artifactPath) + const target = absoluteFileTarget(result) + + await vi.advanceTimersByTimeAsync(10 * 60 * 1000 + 1) + + await expect( + commands.readTerminalArtifactFile( + 'id:wt-1', + target.grantId, + target.absolutePath, + 'client-a' + ) + ).rejects.toThrow('terminal_file_grant_expired') + expect(statMock).not.toHaveBeenCalled() + }) + + it('rejects stale absolute terminal artifact writes before changing the file', async () => { + const artifactPath = await tempFile('result.json', '{"ok":true}') + const { commands } = createRuntimeFileCommands({ path: '/repo' }) + resolveAuthorizedPathMock.mockImplementation(async (p: string) => p) + + const result = await resolveTerminalArtifactPath(commands, artifactPath) + const target = absoluteFileTarget(result) + await rm(artifactPath) + await writeFile(artifactPath, '{"ok":"ext"}') + + await expect( + commands.writeTerminalArtifactFile( + 'id:wt-1', + target.grantId, + target.absolutePath, + '{"ok":false}', + 'client-a' + ) + ).rejects.toThrow('terminal_file_grant_stale') + await expect(readFile(artifactPath, 'utf8')).resolves.toBe('{"ok":"ext"}') + }) + + it('keeps the original terminal artifact when atomic commit fails', async () => { + const artifactPath = await tempFile('result.json', '{"ok":true}') + const { commands } = createRuntimeFileCommands({ path: '/repo' }) + resolveAuthorizedPathMock.mockImplementation(async (p: string) => p) + renameMock.mockRejectedValueOnce(new Error('ENOSPC')) + + const result = await resolveTerminalArtifactPath(commands, artifactPath) + const target = absoluteFileTarget(result) + + await expect( + commands.writeTerminalArtifactFile( + 'id:wt-1', + target.grantId, + target.absolutePath, + '{"ok":false}', + 'client-a' + ) + ).rejects.toThrow('ENOSPC') + await expect(readFile(artifactPath, 'utf8')).resolves.toBe('{"ok":true}') + }) + + it('rejects hard-linked terminal artifact writes after a grant is created', async () => { + const artifactPath = await tempFile('result.json', '{"ok":true}') + const hardLinkPath = join(artifactPath, '..', 'linked-result.json') + const { commands } = createRuntimeFileCommands({ path: '/repo' }) + resolveAuthorizedPathMock.mockImplementation(async (p: string) => p) + + const result = await resolveTerminalArtifactPath(commands, artifactPath) + const target = absoluteFileTarget(result) + await link(artifactPath, hardLinkPath) + + await expect( + commands.writeTerminalArtifactFile( + 'id:wt-1', + target.grantId, + target.absolutePath, + '{"ok":false}', + 'client-a' + ) + ).rejects.toThrow('terminal_file_grant_stale') + await expect(readFile(artifactPath, 'utf8')).resolves.toBe('{"ok":true}') + }) + + it('rejects stale absolute terminal artifact reads before returning changed content', async () => { + const artifactPath = await tempFile('result.json', '{"ok":true}') + const { commands } = createRuntimeFileCommands({ path: '/repo' }) + resolveAuthorizedPathMock.mockImplementation(async (p: string) => p) + + const result = await resolveTerminalArtifactPath(commands, artifactPath) + const target = absoluteFileTarget(result) + + await rm(artifactPath) + await writeFile(artifactPath, '{"ok":false}') + + await expect( + commands.readTerminalArtifactFile( + 'id:wt-1', + target.grantId, + target.absolutePath, + 'client-a' + ) + ).rejects.toThrow('terminal_file_grant_stale') + }) + + it('rejects retargeted symlink terminal artifact reads before returning outside content', async () => { + const artifactPath = await tempFile('result.json', '{"ok":true}') + const outsidePath = join(artifactPath, '..', 'outside.json') + await writeFile(outsidePath, '{"secret":true}') + const { commands } = createRuntimeFileCommands({ path: '/repo' }) + resolveAuthorizedPathMock.mockImplementation(async (p: string) => p) + + const result = await resolveTerminalArtifactPath(commands, artifactPath) + const target = absoluteFileTarget(result) + await rm(artifactPath) + await symlink(outsidePath, artifactPath) + + await expect( + commands.readTerminalArtifactFile( + 'id:wt-1', + target.grantId, + target.absolutePath, + 'client-a' + ) + ).rejects.toThrow('terminal_file_grant_stale') + }) + + it('rejects retargeted symlink terminal artifact writes before changing outside content', async () => { + const artifactPath = await tempFile('result.json', '{"ok":true}') + const outsidePath = join(artifactPath, '..', 'outside.json') + await writeFile(outsidePath, '{"secret":true}') + const { commands } = createRuntimeFileCommands({ path: '/repo' }) + resolveAuthorizedPathMock.mockImplementation(async (p: string) => p) + + const result = await resolveTerminalArtifactPath(commands, artifactPath) + const target = absoluteFileTarget(result) + await rm(artifactPath) + await symlink(outsidePath, artifactPath) + + await expect( + commands.writeTerminalArtifactFile( + 'id:wt-1', + target.grantId, + target.absolutePath, + '{"ok":false}', + 'client-a' + ) + ).rejects.toThrow('terminal_file_grant_stale') + await expect(readFile(outsidePath, 'utf8')).resolves.toBe('{"secret":true}') + }) + + it('does not renew stale terminal artifact grants', async () => { + const artifactPath = await tempFile('result.json', '{"ok":true}') + const { commands } = createRuntimeFileCommands({ path: '/repo' }) + resolveAuthorizedPathMock.mockImplementation(async (p: string) => p) + + const result = await resolveTerminalArtifactPath(commands, artifactPath) + const target = absoluteFileTarget(result) + await rm(artifactPath) + await writeFile(artifactPath, '{"ok":false}') + + await expect( + commands.readTerminalArtifactFile( + 'id:wt-1', + target.grantId, + target.absolutePath, + 'client-a' + ) + ).rejects.toThrow('terminal_file_grant_stale') + + await vi.advanceTimersByTimeAsync(10 * 60 * 1000 + 1) + + await expect( + commands.readTerminalArtifactFile( + 'id:wt-1', + target.grantId, + target.absolutePath, + 'client-a' + ) + ).rejects.toThrow('terminal_file_grant_expired') + }) + + it('rejects stale absolute terminal artifact previews before returning changed content', async () => { + const artifactPath = await tempFile('result.png', 'fake-png') + const { commands } = createRuntimeFileCommands({ path: '/repo' }) + resolveAuthorizedPathMock.mockImplementation(async (p: string) => p) + + const result = await resolveTerminalArtifactPath(commands, artifactPath) + const target = absoluteFileTarget(result) + + await rm(artifactPath) + await writeFile(artifactPath, 'changed!') + + await expect( + commands.readTerminalArtifactPreview( + 'id:wt-1', + target.grantId, + target.absolutePath, + 'client-a' + ) + ).rejects.toThrow('terminal_file_grant_stale') + }) + + it('rejects binary-extension terminal artifacts from the editable text path', async () => { + const artifactPath = await tempFile('report.pdf', '%PDF text-looking bytes') + const { commands } = createRuntimeFileCommands({ path: '/repo' }) + resolveAuthorizedPathMock.mockImplementation(async (p: string) => p) + statMock.mockResolvedValue({ + isDirectory: () => false, + size: 23, + dev: 1, + ino: 2, + mtimeMs: 3 + }) + + const result = await resolveTerminalArtifactPath(commands, artifactPath) + const target = absoluteFileTarget(result) + + await expect( + commands.readTerminalArtifactFile( + 'id:wt-1', + target.grantId, + target.absolutePath, + 'client-a' + ) + ).rejects.toThrow('binary_file') + await expect( + commands.writeTerminalArtifactFile( + 'id:wt-1', + target.grantId, + target.absolutePath, + 'not a pdf', + 'client-a' + ) + ).rejects.toThrow('binary_file') + await expect(readFile(artifactPath, 'utf8')).resolves.toBe('%PDF text-looking bytes') + }) + + it('rejects remote binary terminal artifact writes before changing the file', async () => { + const { commands, store } = createRuntimeFileCommands({ path: '/repo' }) + store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) + const stat = vi + .fn() + .mockResolvedValue({ type: 'file', size: 4, mtimeMs: 3, isDirectory: () => false }) + const writeTerminalArtifact = vi.fn().mockRejectedValue(new Error('binary_file')) + const realpath = vi.fn(async (p: string) => p) + const writeFile = vi.fn() + vi.mocked(getSshFilesystemProvider).mockReturnValue({ + stat, + realpath, + writeFile, + writeTerminalArtifact + } as never) + + const result = await resolveTerminalArtifactPath(commands, '/tmp/result.txt') + const grantId = result.openTarget?.kind === 'absolute-file' ? result.openTarget.grantId : '' + + await expect( + commands.writeTerminalArtifactFile( + 'id:wt-1', + grantId, + '/tmp/result.txt', + 'not binary anymore', + 'client-a' + ) + ).rejects.toThrow('binary_file') + expect(writeFile).not.toHaveBeenCalled() + expect(writeTerminalArtifact).toHaveBeenCalled() + }) + + it('rejects remote terminal artifact reads when a grant no longer resolves to the granted path', async () => { + const { commands, readTerminalArtifact, moveArtifactTarget } = + createRemoteTerminalArtifactGrantFixture() + const result = await resolveTerminalArtifactPath(commands, '/tmp/result.json') + const target = absoluteFileTarget(result) + + moveArtifactTarget('/home/me/.ssh/config') + + await expect( + commands.readTerminalArtifactFile( + 'id:wt-1', + target.grantId, + target.absolutePath, + 'client-a' + ) + ).rejects.toThrow('terminal_file_grant_stale') + expect(readTerminalArtifact).not.toHaveBeenCalled() + }) + + it('rejects remote terminal artifact previews when a grant no longer resolves to the granted path', async () => { + const { commands, readTerminalArtifact, moveArtifactTarget } = + createRemoteTerminalArtifactGrantFixture('/tmp/result.png') + const result = await resolveTerminalArtifactPath(commands, '/tmp/result.png') + const target = absoluteFileTarget(result) + + moveArtifactTarget('/tmp/other.png') + + await expect( + commands.readTerminalArtifactPreview( + 'id:wt-1', + target.grantId, + target.absolutePath, + 'client-a' + ) + ).rejects.toThrow('terminal_file_grant_stale') + expect(readTerminalArtifact).not.toHaveBeenCalled() + }) + + it('rejects remote terminal artifact writes when a grant no longer resolves to the granted path', async () => { + const { commands, readTerminalArtifact, writeTerminalArtifact, moveArtifactTarget } = + createRemoteTerminalArtifactGrantFixture() + const result = await resolveTerminalArtifactPath(commands, '/tmp/result.json') + const target = absoluteFileTarget(result) + + moveArtifactTarget('/home/me/.ssh/config') + + await expect( + commands.writeTerminalArtifactFile( + 'id:wt-1', + target.grantId, + target.absolutePath, + '{"ok":false}', + 'client-a' + ) + ).rejects.toThrow('terminal_file_grant_stale') + expect(readTerminalArtifact).not.toHaveBeenCalled() + expect(writeTerminalArtifact).not.toHaveBeenCalled() + }) + it('reports a nonexistent in-worktree path as not existing', async () => { const { commands } = createRuntimeFileCommands({ path: '/repo' }) resolveAuthorizedPathMock.mockImplementation(async (p: string) => p) diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index 9d8d6826c6e..e8a74e3591c 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -1,7 +1,10 @@ /* eslint-disable max-lines -- Why: filesystem, editor-file, and search commands share the same local/SSH path authorization rules. Keeping that IO adapter together prevents separate command paths from drifting on safety checks. */ import type { ChildProcess } from 'node:child_process' +import { randomUUID } from 'node:crypto' import { watch as watchFs } from 'node:fs' +import type { FileHandle } from 'node:fs/promises' import { + chmod, constants, copyFile, lstat, @@ -10,11 +13,12 @@ import { readFile, readdir, rename, + realpath, rm, stat, writeFile } from 'node:fs/promises' -import { homedir } from 'node:os' +import { homedir, tmpdir } from 'node:os' import { basename, dirname, extname, join } from 'node:path' import type { DirEntry, @@ -26,7 +30,9 @@ import type { Worktree } from '../../shared/types' import { + isPathInsideOrEqual, isRuntimePathAbsolute, + isWindowsAbsolutePathLike, relativePathInsideRoot, resolveRuntimePath } from '../../shared/cross-platform-path' @@ -63,6 +69,7 @@ import { getSshFilesystemProvider, SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE } from '../providers/ssh-filesystem-dispatch' +import type { FileStat, IFilesystemProvider } from '../providers/types' import { assertNoClobberRenameDestinationAvailable } from '../../shared/filesystem-rename-collision' import { joinWorktreeRelativePath, normalizeRuntimeRelativePath } from './runtime-relative-paths' @@ -70,6 +77,8 @@ const MOBILE_FILE_LIST_LIMIT = 5000 const MOBILE_FILE_READ_MAX_BYTES = 512 * 1024 const RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES = 10 * 1024 * 1024 const WINDOWS_RUNTIME_FILE_WATCH_DEBOUNCE_MS = 150 +const TERMINAL_FILE_GRANT_TTL_MS = 10 * 60 * 1000 +const OPEN_NOFOLLOW = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0 // Why: runtime files.watch subscriptions are cleaned up through synchronous RPC // callbacks. Track native Parcel unsubscribe work so app shutdown can drain it. const pendingRuntimeFileWatcherUnsubscribes = new Set>() @@ -102,6 +111,28 @@ const MOBILE_PREVIEWABLE_IMAGE_EXTENSIONS = new Set([ '.ico' ]) +type RuntimeFileStatLike = { + size?: number + dev?: number + ino?: number + nlink?: number + mtime?: number | Date + mtimeMs?: number + isDirectory?: () => boolean +} + +type TerminalFileGrant = { + id: string + worktreeId: string + absolutePath: string + provider: 'local' | 'ssh' + connectionId?: string + clientId?: string + expiresAt: number + statIdentity: string | null + expiryTimer?: ReturnType +} + function isMobilePreviewableImagePath(relativePath: string): boolean { const basename = basenameFromRelativePath(relativePath) const dotIndex = basename.lastIndexOf('.') @@ -153,6 +184,16 @@ export type RuntimeFileCommandHost = { requireStore(): Store resolveWorktreeSelector(selector: string): Promise resolveRuntimeFileTarget(selector: string): Promise + resolveTerminalCwd?(terminalHandle: string): string | null | Promise + resolveTerminalContext?( + terminalHandle: string + ): { worktreeId: string; connectionId: string | null } | null + resolveTerminalFileUriHostname?(terminalHandle: string): string | null | Promise + hasRecentTerminalOutputPath?( + terminalHandle: string, + pathText: string, + absolutePath: string + ): boolean | Promise resolveRuntimeGitTarget( selector: string ): Promise<{ worktree: ResolvedRuntimeFileWorktree; connectionId?: string }> @@ -173,6 +214,7 @@ export type RuntimeFileCommandHost = { export class RuntimeFileCommands { private activeRuntimeTextSearches = new Map() + private terminalFileGrants = new Map() constructor(private readonly host: RuntimeFileCommandHost) {} @@ -285,19 +327,28 @@ export class RuntimeFileCommands { // Resolves a path tapped in the mobile terminal (absolute, relative, or ~/…) // to a worktree-relative path the file RPCs can open, plus existence. // Relative paths resolve against `cwd` when the caller supplies it, else - // against the worktree root. NOTE: the mobile tap path does not yet forward a - // cwd, so a token relative to a subdirectory currently resolves against the - // root and may miss — absolute and root-relative paths always resolve. - // (Threading the terminal's tracked cwd is a follow-up.) + // against the worktree root. async resolveTerminalPath( worktreeSelector: string, pathText: string, - cwd?: string | null + cwd?: string | null, + clientId?: string, + terminalHandle?: string | null ): Promise { const store = this.host.requireStore() const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) const { worktree, connectionId } = target - const base = cwd && cwd.trim().length > 0 ? cwd : worktree.path + // Why: mobile may attach after OSC7 cwd metadata was emitted; the runtime + // still owns the terminal's latest cwd and can resolve the tap correctly. + const normalizedTerminalHandle = + terminalHandle && terminalHandle.trim().length > 0 ? terminalHandle.trim() : null + const terminalCwd = normalizedTerminalHandle + ? await this.host.resolveTerminalCwd?.(normalizedTerminalHandle) + : null + const terminalFileUriHostname = normalizedTerminalHandle + ? await this.host.resolveTerminalFileUriHostname?.(normalizedTerminalHandle) + : null + const base = terminalCwd || (cwd && cwd.trim().length > 0 ? cwd : worktree.path) const empty: RuntimeTerminalPathResolution = { worktree: worktree.id, @@ -315,26 +366,99 @@ export class RuntimeFileCommands { return empty } const expanded = isTilde ? resolveRuntimePath(homedir(), pathText.slice(2)) : pathText - const absolutePath = isRuntimePathAbsolute(expanded) - ? expanded - : resolveRuntimePath(base, expanded) + const absolutePath = resolveTerminalAbsolutePath({ + base, + expanded, + worktreePath: worktree.path, + connectionId, + terminalFileUriHostname + }) const relativePath = relativePathInsideRoot(worktree.path, absolutePath) - // Outside the worktree, or not a safe relative path → not openable here. - if (relativePath === null || relativePath === '' || !isSafeMobileRelativePath(relativePath)) { - return empty - } - try { + if (relativePath !== null && relativePath !== '' && isSafeMobileRelativePath(relativePath)) { + const stats = connectionId + ? await this.statRemoteTerminalPath(absolutePath, connectionId) + : await stat(await resolveAuthorizedPath(absolutePath, store)) + return { + worktree: worktree.id, + relativePath, + absolutePath, + exists: true, + isDirectory: stats.isDirectory(), + openTarget: stats.isDirectory() + ? undefined + : { + kind: 'worktree-file', + provider: connectionId ? 'ssh' : 'local', + relativePath, + absolutePath + } + } + } + + // Why: mobile taps can point at agent-created artifacts outside the + // worktree. Authorize and grant the exact existing path instead of + // widening worktree-relative file RPCs to arbitrary absolute paths. + if (!normalizedTerminalHandle || !terminalCwd) { + return { ...empty, relativePath, absolutePath } + } + const terminalContext = this.host.resolveTerminalContext?.(normalizedTerminalHandle) + if ( + !terminalContext || + terminalContext.worktreeId !== worktree.id || + (terminalContext.connectionId ?? undefined) !== connectionId + ) { + return { ...empty, relativePath, absolutePath } + } + const artifactPath = await this.resolveAllowedTerminalArtifactPath({ + absolutePath, + connectionId, + worktreePath: worktree.path + }) + if (!artifactPath) { + return { ...empty, relativePath, absolutePath } + } + if ( + !(await this.host.hasRecentTerminalOutputPath?.( + normalizedTerminalHandle, + provenancePathCandidate(pathText, absolutePath), + artifactPath + )) + ) { + return { ...empty, relativePath, absolutePath } + } const stats = connectionId - ? await this.statRemoteTerminalPath(absolutePath, connectionId) - : await stat(await resolveAuthorizedPath(absolutePath, store)) + ? await this.statRemoteTerminalPath(artifactPath, connectionId) + : await this.statLocalTerminalPath(artifactPath) + const isDirectory = stats.isDirectory() + if (!isDirectory && isTerminalArtifactHardLinked(stats)) { + return { ...empty, relativePath, absolutePath } + } + const grant = isDirectory + ? null + : this.createTerminalFileGrant({ + worktreeId: worktree.id, + absolutePath: artifactPath, + provider: connectionId ? 'ssh' : 'local', + connectionId, + clientId, + stats + }) return { worktree: worktree.id, - relativePath, - absolutePath, + relativePath: null, + absolutePath: artifactPath, exists: true, - isDirectory: stats.isDirectory() + isDirectory, + openTarget: grant + ? { + kind: 'absolute-file', + provider: grant.provider, + absolutePath: artifactPath, + grantId: grant.id + } + : undefined } } catch (error) { // A genuine "not found" → the path simply doesn't exist (report it, not an @@ -361,13 +485,408 @@ export class RuntimeFileCommands { private async statRemoteTerminalPath( absolutePath: string, connectionId: string - ): Promise<{ isDirectory: () => boolean }> { + ): Promise boolean }> { const provider = getSshFilesystemProvider(connectionId) if (!provider) { throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) } const stats = await provider.stat(absolutePath) - return { isDirectory: () => stats.type === 'directory' } + return { ...stats, isDirectory: () => stats.type === 'directory' } + } + + private async resolveAllowedTerminalArtifactPath(args: { + absolutePath: string + connectionId?: string + worktreePath: string + }): Promise { + if (args.connectionId) { + return this.resolveAllowedRemoteTerminalArtifactPath(args.absolutePath, args.connectionId) + } + return resolveAllowedLocalTerminalArtifactPath(args.absolutePath, args.worktreePath) + } + + private async resolveAllowedRemoteTerminalArtifactPath( + absolutePath: string, + connectionId: string + ): Promise { + const provider = getSshFilesystemProvider(connectionId) + if (!provider) { + throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) + } + const roots = ['/tmp', '/private/tmp'] + const providerTempDir = await provider.getTempDir?.().catch(() => null) + if (providerTempDir) { + roots.push(providerTempDir) + } + if (!roots.some((root) => isPathInsideOrEqual(root, absolutePath))) { + return null + } + const [realArtifactPath, ...realRoots] = await Promise.all([ + provider.realpath(absolutePath), + ...roots.map((root) => provider.realpath(root).catch(() => root)) + ]) + // Why: SSH reads and writes follow symlinks on the relay. Grant the + // canonical target so a /tmp link cannot escape the temp-artifact boundary. + return realRoots.some((root) => isPathInsideOrEqual(root, realArtifactPath)) + ? realArtifactPath + : null + } + + private async statLocalTerminalPath( + absolutePath: string + ): Promise boolean }> { + await assertLocalTerminalArtifactPathStillCanonical(absolutePath) + const handle = await open(absolutePath, 'r') + try { + return handle.stat() + } finally { + await handle.close() + } + } + + private createTerminalFileGrant(args: { + worktreeId: string + absolutePath: string + provider: 'local' | 'ssh' + connectionId?: string + clientId?: string + stats: RuntimeFileStatLike + }): TerminalFileGrant { + assertTerminalArtifactNotHardLinked(args.stats) + const grant: TerminalFileGrant = { + id: randomUUID(), + worktreeId: args.worktreeId, + absolutePath: args.absolutePath, + provider: args.provider, + ...(args.connectionId ? { connectionId: args.connectionId } : {}), + ...(args.clientId ? { clientId: args.clientId } : {}), + expiresAt: Date.now() + TERMINAL_FILE_GRANT_TTL_MS, + statIdentity: terminalFileStatIdentity(args.stats) + } + this.terminalFileGrants.set(grant.id, grant) + this.scheduleTerminalFileGrantExpiry(grant) + return grant + } + + private async requireTerminalFileGrant( + worktreeSelector: string, + grantId: string, + absolutePath: string, + clientId?: string + ): Promise<{ grant: TerminalFileGrant; target: ResolvedRuntimeFileTarget }> { + const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) + this.pruneExpiredTerminalFileGrants() + const grant = this.terminalFileGrants.get(grantId) + if (!grant) { + throw new Error('terminal_file_grant_expired') + } + if (grant.expiresAt <= Date.now()) { + this.releaseTerminalFileGrant(grantId, grant) + throw new Error('terminal_file_grant_expired') + } + if ( + grant.worktreeId !== target.worktree.id || + grant.absolutePath !== absolutePath || + grant.connectionId !== target.connectionId || + grant.clientId !== clientId + ) { + throw new Error('terminal_file_grant_mismatch') + } + return { grant, target } + } + + private refreshTerminalFileGrant(grant: TerminalFileGrant): void { + grant.expiresAt = Date.now() + TERMINAL_FILE_GRANT_TTL_MS + this.scheduleTerminalFileGrantExpiry(grant) + } + + private pruneExpiredTerminalFileGrants(): void { + const now = Date.now() + for (const [id, grant] of this.terminalFileGrants) { + if (grant.expiresAt <= now) { + this.releaseTerminalFileGrant(id, grant) + } + } + } + + revokeTerminalFileGrantsForClient(clientId: string): void { + for (const [id, grant] of this.terminalFileGrants) { + if (grant.clientId === clientId) { + this.releaseTerminalFileGrant(id, grant) + } + } + } + + private releaseTerminalFileGrant(id: string, grant: TerminalFileGrant): void { + this.terminalFileGrants.delete(id) + if (grant.expiryTimer) { + clearTimeout(grant.expiryTimer) + grant.expiryTimer = undefined + } + } + + private scheduleTerminalFileGrantExpiry(grant: TerminalFileGrant): void { + if (grant.expiryTimer) { + clearTimeout(grant.expiryTimer) + } + grant.expiryTimer = setTimeout( + () => { + if (this.terminalFileGrants.get(grant.id) === grant && grant.expiresAt <= Date.now()) { + this.releaseTerminalFileGrant(grant.id, grant) + } + }, + Math.max(1, grant.expiresAt - Date.now()) + ) + grant.expiryTimer.unref?.() + } + + async readTerminalArtifactFile( + worktreeSelector: string, + grantId: string, + absolutePath: string, + clientId?: string + ): Promise { + const { grant, target } = await this.requireTerminalFileGrant( + worktreeSelector, + grantId, + absolutePath, + clientId + ) + if (isMobileBinaryPath(grant.absolutePath)) { + throw new Error('binary_file') + } + let content: string + if (grant.connectionId) { + const provider = await this.assertRemoteTerminalFileGrantFreshForRead(grant) + content = await this.readRemoteTerminalArtifactFile( + provider, + grant, + MOBILE_FILE_READ_MAX_BYTES + ) + } else { + const handle = await openLocalTerminalArtifactGrant(grant, constants.O_RDONLY) + try { + content = await readLocalTerminalArtifactFileFromHandle(handle, grant) + } finally { + await handle.close() + } + } + this.refreshTerminalFileGrant(grant) + const truncated = truncateMobileFilePreview(content) + + return { + worktree: target.worktree.id, + relativePath: grant.absolutePath, + content: truncated.content, + truncated: truncated.truncated, + byteLength: truncated.byteLength + } + } + + async readTerminalArtifactPreview( + worktreeSelector: string, + grantId: string, + absolutePath: string, + clientId?: string + ): Promise { + const { grant } = await this.requireTerminalFileGrant( + worktreeSelector, + grantId, + absolutePath, + clientId + ) + if (grant.connectionId) { + const provider = await this.assertRemoteTerminalFileGrantFreshForRead(grant) + this.refreshTerminalFileGrant(grant) + return this.readRemoteTerminalArtifactPreview(provider, grant) + } + const handle = await openLocalTerminalArtifactGrant(grant, constants.O_RDONLY) + try { + const preview = await readLocalTerminalArtifactPreviewFromHandle(handle, grant) + this.refreshTerminalFileGrant(grant) + return preview + } finally { + await handle.close() + } + } + + async writeTerminalArtifactFile( + worktreeSelector: string, + grantId: string, + absolutePath: string, + content: string, + clientId?: string + ): Promise<{ ok: true }> { + if (Buffer.byteLength(content, 'utf8') > MOBILE_FILE_READ_MAX_BYTES) { + throw new Error('file_too_large') + } + const { grant } = await this.requireTerminalFileGrant( + worktreeSelector, + grantId, + absolutePath, + clientId + ) + if (isMobileBinaryPath(grant.absolutePath)) { + throw new Error('binary_file') + } + if (grant.connectionId) { + const { provider, fileStat } = await this.assertRemoteTerminalFileGrantFresh(grant) + if (fileStat.type === 'directory') { + throw new Error('Cannot write to a directory') + } + if (fileStat.size > MOBILE_FILE_READ_MAX_BYTES) { + throw new Error('file_too_large') + } + if (!provider.writeTerminalArtifact) { + throw new Error('terminal_file_grant_unavailable') + } + const nextStat = await provider.writeTerminalArtifact( + grant.absolutePath, + content, + this.terminalArtifactAccessOptions(grant, MOBILE_FILE_READ_MAX_BYTES) + ) + grant.statIdentity = terminalFileStatIdentity(nextStat) + this.refreshTerminalFileGrant(grant) + return { ok: true } + } + + let originalMode: number | null = null + const handle = await openLocalTerminalArtifactGrant(grant, constants.O_RDONLY) + try { + const fileStats = await handle.stat() + originalMode = fileStats.mode + if (fileStats.isDirectory()) { + throw new Error('Cannot write to a directory') + } + if (fileStats.size > MOBILE_FILE_READ_MAX_BYTES) { + throw new Error('file_too_large') + } + assertTerminalFileGrantFresh(grant, fileStats) + if ( + isBinaryBuffer(await readFileHandleBufferBounded(handle, MOBILE_FILE_READ_MAX_BYTES + 1)) + ) { + throw new Error('binary_file') + } + } finally { + await handle.close() + } + const tempPath = join( + dirname(grant.absolutePath), + `.${basename(grant.absolutePath)}.${randomUUID()}.tmp` + ) + try { + await writeFile(tempPath, content, { encoding: 'utf-8', flag: 'wx' }) + if (typeof originalMode === 'number') { + await chmod(tempPath, originalMode & 0o7777) + } + const freshHandle = await openLocalTerminalArtifactGrant(grant, constants.O_RDONLY) + try { + assertTerminalFileGrantFresh(grant, await freshHandle.stat()) + } finally { + await freshHandle.close() + } + await rename(tempPath, grant.absolutePath) + grant.statIdentity = terminalFileStatIdentity( + await this.statLocalTerminalPath(grant.absolutePath) + ) + this.refreshTerminalFileGrant(grant) + return { ok: true } + } finally { + await rm(tempPath, { force: true }).catch(() => {}) + } + } + + private async readRemoteTerminalArtifactPreview( + provider: IFilesystemProvider, + grant: TerminalFileGrant + ): Promise { + const preview = await this.readRemoteTerminalArtifact( + provider, + grant, + RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES + ) + if ( + !preview.isBinary && + Buffer.byteLength(preview.content, 'utf8') > MOBILE_FILE_READ_MAX_BYTES + ) { + throw new Error('file_too_large') + } + return preview + } + + private async readRemoteTerminalArtifactFile( + provider: IFilesystemProvider, + grant: TerminalFileGrant, + maxBytes: number + ): Promise { + const result = await this.readRemoteTerminalArtifact(provider, grant, maxBytes) + if (result.isBinary) { + throw new Error('binary_file') + } + return result.content + } + + private async readRemoteTerminalArtifact( + provider: IFilesystemProvider, + grant: TerminalFileGrant, + maxBytes: number + ): Promise { + if (!provider.readTerminalArtifact) { + throw new Error('terminal_file_grant_unavailable') + } + return provider.readTerminalArtifact( + grant.absolutePath, + this.terminalArtifactAccessOptions(grant, maxBytes) + ) + } + + private terminalArtifactAccessOptions( + grant: TerminalFileGrant, + maxBytes: number + ): { expectedRealPath: string; expectedStatIdentity: string | null; maxBytes: number } { + return { + expectedRealPath: grant.absolutePath, + expectedStatIdentity: grant.statIdentity, + maxBytes + } + } + + private async assertRemoteTerminalFileGrantFreshForRead( + grant: TerminalFileGrant + ): Promise { + const { provider } = await this.assertRemoteTerminalFileGrantFresh(grant) + return provider + } + + private async assertRemoteTerminalFileGrantFresh( + grant: TerminalFileGrant + ): Promise<{ provider: IFilesystemProvider; fileStat: FileStat }> { + const provider = await this.assertRemoteTerminalFileGrantPathStillCanonical(grant) + const fileStat = await provider.stat(grant.absolutePath) + assertTerminalFileGrantFresh(grant, fileStat) + return { provider, fileStat } + } + + private async assertRemoteTerminalFileGrantPathStillCanonical( + grant: TerminalFileGrant + ): Promise { + if (!grant.connectionId) { + throw new Error('terminal_file_grant_mismatch') + } + const provider = getSshFilesystemProvider(grant.connectionId) + if (!provider) { + throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) + } + const allowedPath = await this.resolveAllowedRemoteTerminalArtifactPath( + grant.absolutePath, + grant.connectionId + ) + // Why: relay stat/read/write follow symlinks, so a remote temp artifact + // grant must be re-canonicalized after the terminal process can mutate it. + if (allowedPath !== grant.absolutePath) { + throw new Error('terminal_file_grant_stale') + } + return provider } async readFileExplorerDir(worktreeSelector: string, relativePath: string): Promise { @@ -1125,6 +1644,221 @@ async function readLocalMobileFile(filePath: string, store: Store): Promise { + const fileStat = await handle.stat() + if (fileStat.isDirectory()) { + throw new Error('Cannot read a directory') + } + if (fileStat.size > MOBILE_FILE_READ_MAX_BYTES) { + throw new Error('file_too_large') + } + assertTerminalFileGrantFresh(grant, fileStat) + const buffer = await readFileHandleBufferBounded(handle, MOBILE_FILE_READ_MAX_BYTES + 1) + if (isBinaryBuffer(buffer)) { + throw new Error('binary_file') + } + return buffer.toString('utf8') +} + +async function readLocalTerminalArtifactPreviewFromHandle( + handle: FileHandle, + grant: TerminalFileGrant +): Promise { + const fileStats = await handle.stat() + if (fileStats.isDirectory()) { + throw new Error('Cannot preview a directory') + } + assertTerminalFileGrantFresh(grant, fileStats) + const mimeType = RUNTIME_PREVIEWABLE_BINARY_MIME_TYPES[extname(grant.absolutePath).toLowerCase()] + if (mimeType) { + if (fileStats.size > RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES) { + throw new Error('file_too_large') + } + const buffer = await readFileHandleBufferBounded( + handle, + RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES + 1 + ) + return { + content: buffer.toString('base64'), + isBinary: true, + isImage: true, + mimeType + } + } + + const content = await readLocalTerminalArtifactFileFromHandle(handle, grant) + return { content, isBinary: false } +} + +async function assertLocalTerminalArtifactPathStillCanonical(filePath: string): Promise { + const currentPath = await canonicalPathForArtifactComparison(filePath) + if (currentPath !== filePath) { + throw new Error('terminal_file_grant_stale') + } +} + +async function openLocalTerminalArtifactGrant( + grant: TerminalFileGrant, + flags: number +): Promise { + await assertLocalTerminalArtifactPathStillCanonical(grant.absolutePath) + try { + return await open(grant.absolutePath, flags | OPEN_NOFOLLOW) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ELOOP') { + throw new Error('terminal_file_grant_stale') + } + throw error + } +} + +function resolveTerminalAbsolutePath(args: { + base: string + expanded: string + worktreePath: string + connectionId?: string + terminalFileUriHostname?: string | null +}): string { + const expanded = normalizeTerminalFileUriAuthorityPath( + args.expanded, + args.connectionId, + args.terminalFileUriHostname, + args.worktreePath + ) + const absolutePath = isRuntimePathAbsolute(expanded) + ? expanded + : resolveRuntimePath(args.base, expanded) + if (args.connectionId) { + return normalizeLeadingSlashDrivePath(absolutePath, args.worktreePath) + } + const wsl = parseWslPath(args.worktreePath) + if (wsl && absolutePath.startsWith('/') && !absolutePath.startsWith('//')) { + return toWindowsWslPath(absolutePath, wsl.distro) + } + return absolutePath +} + +function normalizeTerminalFileUriAuthorityPath( + pathText: string, + connectionId?: string, + terminalFileUriHostname?: string | null, + worktreePath?: string +): string { + if (!pathText.startsWith('//')) { + return pathText + } + const match = /^\/\/([^/\\]+)([/\\].*)$/.exec(pathText) + if (!match) { + return pathText + } + const host = match[1]!.toLowerCase() + if (terminalFileUriHostname && host === terminalFileUriHostname.toLowerCase() && connectionId) { + return normalizeLeadingSlashDrivePath(match[2]!, worktreePath) + } + if (isLoopbackFileUriHostname(host) && (connectionId || process.platform !== 'win32')) { + return normalizeLeadingSlashDrivePath(match[2]!, worktreePath) + } + // Why: a file URI authority names a host. Without a verified host match, + // stripping it could open a same-path local or SSH artifact on the wrong machine. + return pathText +} + +function provenancePathCandidate(pathText: string, absolutePath: string): string { + return pathText.startsWith('//') ? pathText : absolutePath +} + +function isLoopbackFileUriHostname(hostname: string): boolean { + return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' +} + +function normalizeLeadingSlashDrivePath(pathText: string, worktreePath?: string): string { + return worktreePath && + isWindowsAbsolutePathLike(worktreePath) && + /^\/[A-Za-z]:[\\/]/.test(pathText) + ? pathText.slice(1) + : pathText +} + +async function resolveAllowedLocalTerminalArtifactPath( + absolutePath: string, + worktreePath: string +): Promise { + const roots = await localTerminalArtifactRoots(worktreePath) + const canonicalPath = await canonicalPathForArtifactComparison(absolutePath) + return roots.some((root) => isPathInsideOrEqual(root, canonicalPath)) ? canonicalPath : null +} + +async function localTerminalArtifactRoots(worktreePath: string): Promise { + const roots = new Set([tmpdir()]) + if (process.platform !== 'win32') { + roots.add('/tmp') + roots.add('/private/tmp') + } + const wsl = parseWslPath(worktreePath) + if (wsl) { + roots.add(toWindowsWslPath('/tmp', wsl.distro)) + } + const canonicalRoots = await Promise.all( + Array.from(roots).map((root) => canonicalPathForArtifactComparison(root)) + ) + return Array.from(new Set([...roots, ...canonicalRoots])) +} + +async function canonicalPathForArtifactComparison(path: string): Promise { + try { + return await realpath(path) + } catch { + return path + } +} + +async function readFileHandleBufferBounded(handle: FileHandle, limit: number): Promise { + const buffer = Buffer.alloc(limit) + const { bytesRead } = await handle.read(buffer, 0, limit, 0) + return buffer.subarray(0, bytesRead) +} + +function terminalFileStatIdentity(stats: RuntimeFileStatLike): string | null { + const dev = typeof stats.dev === 'number' ? stats.dev : null + const ino = typeof stats.ino === 'number' ? stats.ino : null + const nlink = typeof stats.nlink === 'number' ? stats.nlink : null + const size = typeof stats.size === 'number' ? stats.size : null + const mtimeMs = + typeof stats.mtimeMs === 'number' + ? stats.mtimeMs + : typeof stats.mtime === 'number' + ? stats.mtime + : null + if (dev !== null && ino !== null && size !== null && mtimeMs !== null) { + return `${dev}:${ino}:${nlink ?? 'unknown'}:${size}:${mtimeMs}` + } + if (size !== null && mtimeMs !== null) { + return `${size}:${mtimeMs}` + } + return null +} + +function assertTerminalFileGrantFresh(grant: TerminalFileGrant, stats: RuntimeFileStatLike): void { + assertTerminalArtifactNotHardLinked(stats) + const nextIdentity = terminalFileStatIdentity(stats) + if (grant.statIdentity !== null && nextIdentity !== null && grant.statIdentity !== nextIdentity) { + throw new Error('terminal_file_grant_stale') + } +} + +function assertTerminalArtifactNotHardLinked(stats: RuntimeFileStatLike): void { + if (isTerminalArtifactHardLinked(stats)) { + throw new Error('terminal_file_grant_stale') + } +} + +function isTerminalArtifactHardLinked(stats: RuntimeFileStatLike): boolean { + return typeof stats.nlink === 'number' && stats.nlink > 1 +} + function truncateMobileFilePreview(content: string): { content: string truncated: boolean diff --git a/src/main/runtime/orca-runtime-terminal-cwd.test.ts b/src/main/runtime/orca-runtime-terminal-cwd.test.ts new file mode 100644 index 00000000000..214d2a20894 --- /dev/null +++ b/src/main/runtime/orca-runtime-terminal-cwd.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { + on: vi.fn(), + removeListener: vi.fn() + }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +function stubLaunchScope(runtime: OrcaRuntimeService, path = '/repo/app'): void { + const internals = runtime as unknown as { + resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise<{ + id: string + path: string + connectionId: string | null + repo: null + folderWorkspace: null + }> + } + vi.spyOn(internals, 'resolveTerminalWorkspaceLaunchScope').mockResolvedValue({ + id: 'wt-1', + path, + connectionId: null, + repo: null, + folderWorkspace: null + }) +} + +describe('OrcaRuntimeService terminal startup cwd', () => { + it('spawns terminals inside the requested worktree subdirectory', async () => { + const runtime = new OrcaRuntimeService() + stubLaunchScope(runtime) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-1' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await runtime.createTerminal('id:wt-1', { cwd: '/repo/app/packages/web' }) + + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: '/repo/app/packages/web', + worktreeId: 'wt-1' + }) + ) + }) + + it('rejects requested terminal cwd values outside the selected worktree', async () => { + const runtime = new OrcaRuntimeService() + stubLaunchScope(runtime) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-1' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await expect(runtime.createTerminal('id:wt-1', { cwd: '/repo/app-other' })).rejects.toThrow( + 'Terminal cwd must be inside the selected worktree.' + ) + expect(spawn).not.toHaveBeenCalled() + }) + + it('reveals main-spawned terminals with their nested startup cwd', async () => { + const runtime = new OrcaRuntimeService() + stubLaunchScope(runtime) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-1' }) + const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-1' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + + await runtime.createTerminal('id:wt-1', { cwd: '/repo/app/packages/web' }) + + expect(revealTerminalSession).toHaveBeenCalledWith( + 'wt-1', + expect.objectContaining({ cwd: '/repo/app/packages/web' }) + ) + }) + + it('materializes restored headless mobile tabs in the persisted startup cwd', async () => { + const store = { + getWorkspaceSession: () => ({ + activeRepoId: null, + activeWorktreeId: 'wt-1', + activeTabId: 'tab-1', + tabsByWorktree: { + 'wt-1': [ + { + id: 'tab-1', + ptyId: null, + worktreeId: 'wt-1', + title: 'Terminal 1', + defaultTitle: 'Terminal 1', + startupCwd: '/repo/app/packages/web', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + ] + }, + terminalLayoutsByTabId: {} + }) + } + const runtime = new OrcaRuntimeService(store as never) + stubLaunchScope(runtime) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-1' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + const listed = await runtime.listMobileSessionTabs('id:wt-1') + const tab = listed.tabs.find((candidate) => candidate.type === 'terminal') + expect(tab).toMatchObject({ startupCwd: '/repo/app/packages/web' }) + + await runtime.activateMobileSessionTab('id:wt-1', tab!.id) + + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: '/repo/app/packages/web', + worktreeId: 'wt-1' + }) + ) + }) +}) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index a5523e8a496..8b3a65720c1 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1,5 +1,6 @@ /* eslint-disable max-lines -- Why: runtime behavior is stateful and cross-cutting, so these tests stay in one file to preserve the end-to-end invariants around handles, waits, and graph sync. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { performance } from 'node:perf_hooks' import { EventEmitter } from 'node:events' import { randomUUID } from 'node:crypto' import { execFileSync } from 'node:child_process' @@ -44,10 +45,14 @@ import type { MessagePriority, MessageRow, MessageType } from './orchestration/t import { appendNormalizedToTailBuffer, appendRecentPtyOutput, + appendRecentPtyPathCandidates, buildPreview, OrcaRuntimeService, + recentTerminalPathCandidatesIncludePath, + recentTerminalOutputIncludesPath, type RuntimeTerminalAgentStatusEvent } from './orca-runtime' +import { HeadlessEmulator } from '../daemon/headless-emulator' import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' import { TERMINAL_INPUT_CHUNK_MAX_BYTES, @@ -715,6 +720,64 @@ function syncSinglePty( }) } +function makeDeferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} + +function makeStatusFrame(index: number, first: boolean): string { + const pad = '·'.repeat(60) + const rows = [ + `✻ 执行任务中… (esc to interrupt) [${index}] ${pad}`, + ` ⎿ 正在分析代码库结构与依赖关系,请稍候… ${pad}`, + ` ⎿ tokens: ${1000 + index * 137} · elapsed: ${index}s ${pad}` + ] + return `${first ? '' : '\x1b[2A'}\r${rows.map((row) => `\x1b[K${row}`).join('\r\n')}\r` +} + +async function writeHeadless(emulator: HeadlessEmulator, data: string): Promise { + await emulator.write(data) +} + +function visibleNonEmptyLines(emulator: HeadlessEmulator): string[] { + return emulator.getVisibleLines().filter((line) => line.length > 0) +} + +async function parseHeadlessSnapshotLines( + snapshot: { data: string; cols: number; rows: number }, + display: { cols: number; rows: number } +): Promise { + const restored = new HeadlessEmulator({ cols: display.cols, rows: display.rows }) + try { + restored.resize(snapshot.cols, snapshot.rows) + await writeHeadless(restored, `\x1b[2J\x1b[3J\x1b[H${snapshot.data}`) + restored.resize(display.cols, display.rows) + return visibleNonEmptyLines(restored) + } finally { + restored.dispose() + } +} + +async function referenceStatusFrameLines( + spawn: { cols: number; rows: number }, + resized: { cols: number; rows: number } +): Promise { + const truth = new HeadlessEmulator({ cols: spawn.cols, rows: spawn.rows }) + try { + await writeHeadless(truth, 'user@host % claude\r\n') + truth.resize(resized.cols, resized.rows) + for (let index = 0; index < 5; index += 1) { + await writeHeadless(truth, makeStatusFrame(index, index === 0)) + } + return visibleNonEmptyLines(truth) + } finally { + truth.dispose() + } +} + const TEST_WINDOW_ID = 1 const TEST_REPO_ID = 'repo-1' const TEST_REPO_PATH = '/tmp/repo' @@ -5407,9 +5470,94 @@ describe('OrcaRuntimeService', () => { }) }) + it('resizes the headless mirror after an accepted desktop PTY resize', async () => { + const spawn = { cols: 80, rows: 24 } + const resized = { cols: 120, rows: 30 } + let currentSize = spawn + const runtime = createRuntime() + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + getSize: () => currentSize + }) + syncSinglePty(runtime, 'pty-1') + + runtime.onPtyData('pty-1', 'user@host % claude\r\n', 100) + currentSize = resized + runtime.onExternalPtyResize('pty-1', resized.cols, resized.rows) + for (let index = 0; index < 5; index += 1) { + runtime.onPtyData('pty-1', makeStatusFrame(index, index === 0), 200 + index) + } + + const snapshot = await runtime.serializeMainTerminalBuffer('pty-1', { scrollbackRows: 5000 }) + expect(snapshot).toMatchObject({ cols: resized.cols, rows: resized.rows, source: 'headless' }) + await expect(parseHeadlessSnapshotLines(snapshot!, resized)).resolves.toEqual( + await referenceStatusFrameLines(spawn, resized) + ) + }) + + it('orders headless mirror resizes behind queued PTY writes', async () => { + const spawn = { cols: 80, rows: 10 } + const resized = { cols: 120, rows: 10 } + let currentSize = spawn + const runtime = createRuntime() + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + getSize: () => currentSize, + resize: () => { + currentSize = resized + return true + } + }) + syncSinglePty(runtime, 'pty-1') + runtime.onPtyData('pty-1', 'prompt\r\n', 100) + await runtime.serializeMainTerminalBuffer('pty-1') + + type HeadlessStateForTest = { + emulator: HeadlessEmulator + writeChain: Promise + } + const headless = ( + runtime as unknown as { headlessTerminals: Map } + ).headlessTerminals.get('pty-1') + expect(headless).toBeDefined() + const originalWrite = headless!.emulator.write.bind(headless!.emulator) + const queuedWriteStarted = makeDeferred() + const releaseQueuedWrite = makeDeferred() + headless!.emulator.write = async (data: string): Promise => { + queuedWriteStarted.resolve() + await releaseQueuedWrite.promise + await originalWrite(data) + } + + try { + runtime.onPtyData('pty-1', '\x1b[90GOLD', 200) + await queuedWriteStarted.promise + await runtime.updateDesktopViewport('pty-1', resized) + runtime.onPtyData('pty-1', '\r\nNEXT', 300) + releaseQueuedWrite.resolve() + + const snapshot = await runtime.serializeMainTerminalBuffer('pty-1', { scrollbackRows: 100 }) + expect(snapshot).toMatchObject({ cols: resized.cols, rows: resized.rows }) + await expect(parseHeadlessSnapshotLines(snapshot!, resized)).resolves.toEqual([ + 'prompt', + ' O', + 'LD', + 'NEXT' + ]) + } finally { + headless!.emulator.write = originalWrite + releaseQueuedWrite.resolve() + } + }) + it('adopts renderer-seeded titles into headless main terminal snapshots', async () => { + const artifactPath = '/tmp/renderer-seeded-artifact.json' const serializeBuffer = vi.fn().mockResolvedValue({ - data: 'renderer scrollback\n', + data: `renderer scrollback\nwrote ${artifactPath}\n`, cols: 100, rows: 30, lastTitle: 'Renderer seeded Codex' @@ -5424,6 +5572,7 @@ describe('OrcaRuntimeService', () => { getSize: () => ({ cols: 100, rows: 30 }) }) syncSinglePty(runtime, 'pty-1') + const [terminal] = (await runtime.listTerminals()).terminals runtime.onPtyData('pty-1', 'live output without title\n', 100) @@ -5436,14 +5585,20 @@ describe('OrcaRuntimeService', () => { scrollbackRows: expect.any(Number), altScreenForcesZeroRows: true }) + expect(runtime.hasRecentTerminalOutputPath(terminal.handle, artifactPath, artifactPath)).toBe( + true + ) }) it('returns cwd metadata from seeded headless main terminal snapshots', async () => { const runtime = createRuntime() + syncSinglePty(runtime, 'pty-1') + const [terminal] = (await runtime.listTerminals()).terminals + const artifactPath = '/tmp/restored-scrollback-artifact.json' runtime.seedHeadlessTerminal( 'pty-1', - 'restored scrollback\n', + `restored scrollback\nwrote ${artifactPath}\n`, { cols: 100, rows: 30 }, { cwd: '/projects/restored' @@ -5455,6 +5610,23 @@ describe('OrcaRuntimeService', () => { source: 'headless', cwd: '/projects/restored' }) + expect(runtime.hasRecentTerminalOutputPath(terminal.handle, artifactPath, artifactPath)).toBe( + true + ) + }) + + it('adopts OSC7 host metadata from seeded headless terminal scrollback', async () => { + const runtime = createRuntime() + syncSinglePty(runtime, 'pty-1') + const [terminal] = (await runtime.listTerminals()).terminals + + runtime.seedHeadlessTerminal( + 'pty-1', + '\x1b]7;file://remote-host/tmp\x07restored scrollback\n', + { cols: 100, rows: 30 } + ) + + expect(runtime.resolveTerminalFileUriHostname(terminal.handle)).toBe('remote-host') }) it('falls back to the renderer snapshot for hidden-output recovery without headless state', async () => { @@ -5639,6 +5811,87 @@ describe('OrcaRuntimeService', () => { ]) }) + it('keeps SSH OSC7 cwd POSIX when the desktop runtime is on Windows', async () => { + setPlatform('win32') + const runtime = new OrcaRuntimeService(store) + runtime.registerPty('pty-ssh', TEST_WORKTREE_ID, 'ssh-conn-1') + + runtime.onPtyData('pty-ssh', '\x1b]7;file://remote-host/home/me/repo/src\x07', 123) + + const internals = runtime as unknown as { + terminalCwdByPtyId: Map + terminalFileUriHostnameByPtyId: Map + } + expect(internals.terminalCwdByPtyId.get('pty-ssh')).toBe('/home/me/repo/src') + expect(internals.terminalFileUriHostnameByPtyId.get('pty-ssh')).toBe('remote-host') + }) + + it('clears stale terminal file URI hostnames after empty-host OSC7 cwd updates', () => { + const runtime = new OrcaRuntimeService(store) + runtime.registerPty('pty-ssh', TEST_WORKTREE_ID, 'ssh-conn-1') + + runtime.onPtyData('pty-ssh', '\x1b]7;file://remote-host/home/me/repo/src\x07', 123) + runtime.onPtyData('pty-ssh', '\x1b]7;file:///home/me/repo/src\x07', 124) + + const internals = runtime as unknown as { + terminalCwdByPtyId: Map + terminalFileUriHostnameByPtyId: Map + } + expect(internals.terminalCwdByPtyId.get('pty-ssh')).toBe('/home/me/repo/src') + expect(internals.terminalFileUriHostnameByPtyId.has('pty-ssh')).toBe(false) + }) + + it('serializes SSH headless OSC7 cwd as POSIX when the desktop runtime is on Windows', async () => { + setPlatform('win32') + const runtime = new OrcaRuntimeService(store) + runtime.registerPty('pty-ssh', TEST_WORKTREE_ID, 'ssh-conn-1') + + runtime.onPtyData('pty-ssh', '\x1b]7;file://remote-host/home/me/repo/src\x07hello', 123) + + const snapshot = await ( + runtime as unknown as { + serializeHeadlessTerminalBuffer: ( + ptyId: string, + opts: { includeEmpty?: boolean } + ) => Promise<{ cwd?: string | null } | null> + } + ).serializeHeadlessTerminalBuffer('pty-ssh', { includeEmpty: true }) + + expect(snapshot?.cwd).toBe('/home/me/repo/src') + }) + + it('keeps Windows SSH OSC7 cwd as a drive path when the desktop runtime is POSIX', () => { + setPlatform('darwin') + const runtime = new OrcaRuntimeService(store) + runtime.registerPty('pty-ssh-win', `${TEST_REPO_ID}::C:/Users/me/repo`, 'ssh-conn-1') + + runtime.onPtyData('pty-ssh-win', '\x1b]7;file:///C:/Users/me/repo/src\x07', 123) + + const internals = runtime as unknown as { + terminalCwdByPtyId: Map + } + expect(internals.terminalCwdByPtyId.get('pty-ssh-win')).toBe('C:/Users/me/repo/src') + }) + + it('serializes Windows SSH headless OSC7 cwd as a drive path on POSIX desktops', async () => { + setPlatform('darwin') + const runtime = new OrcaRuntimeService(store) + runtime.registerPty('pty-ssh-win', `${TEST_REPO_ID}::C:/Users/me/repo`, 'ssh-conn-1') + + runtime.onPtyData('pty-ssh-win', '\x1b]7;file:///C:/Users/me/repo/src\x07hello', 123) + + const snapshot = await ( + runtime as unknown as { + serializeHeadlessTerminalBuffer: ( + ptyId: string, + opts: { includeEmpty?: boolean } + ) => Promise<{ cwd?: string | null } | null> + } + ).serializeHeadlessTerminalBuffer('pty-ssh-win', { includeEmpty: true }) + + expect(snapshot?.cwd).toBe('C:/Users/me/repo/src') + }) + it('infers restored SSH connection identity from app-scoped PTY ids', () => { const statuses: RuntimeTerminalAgentStatusEvent[] = [] const runtime = new OrcaRuntimeService(store, undefined, { @@ -7667,7 +7920,7 @@ describe('OrcaRuntimeService', () => { runtime.onPtyData( 'pty-bg', - `${Array.from({ length: 20 }, (_, i) => `line-${i}`).join('\n')}`, + `${Array.from({ length: 20 }, (_, i) => `line-${i}`).join('\n')}\nwrote /tmp/exited-result.json\n`, 100 ) await expect(runtime.readTerminal(handle)).resolves.toMatchObject({ @@ -7688,6 +7941,7 @@ describe('OrcaRuntimeService', () => { tailTruncated: boolean } > + recentPtyPathCandidatesById: Map } ).ptysById.get('pty-bg') expect(pty).toMatchObject({ @@ -7700,6 +7954,11 @@ describe('OrcaRuntimeService', () => { status: 'exited', tail: [] }) + expect( + ( + runtime as unknown as { recentPtyPathCandidatesById: Map } + ).recentPtyPathCandidatesById.has('pty-bg') + ).toBe(false) }) it('bounds disconnected background PTY records and their synthetic handles', async () => { @@ -7718,6 +7977,7 @@ describe('OrcaRuntimeService', () => { for (let index = 0; index < 140; index += 1) { const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) handles.push(handle) + runtime.onPtyData(`pty-bg-${index}`, `wrote /tmp/result-${index}.json\n`, 100 + index) runtime.onPtyExit(`pty-bg-${index}`, 0) } @@ -7725,12 +7985,14 @@ describe('OrcaRuntimeService', () => { ptysById: Map handles: Map handleByPtyId: Map + recentPtyPathCandidatesById: Map } expect(internals.ptysById.size).toBeLessThanOrEqual(128) expect(internals.ptysById.has('pty-bg-0')).toBe(false) expect(internals.ptysById.has('pty-bg-139')).toBe(true) expect(internals.handleByPtyId.has('pty-bg-0')).toBe(false) expect(internals.handles.has(handles[0]!)).toBe(false) + expect(internals.recentPtyPathCandidatesById.has('pty-bg-0')).toBe(false) await expect(runtime.readTerminal(handles[0]!)).rejects.toThrow('terminal_handle_stale') await expect(runtime.readTerminal(handles.at(-1)!)).resolves.toMatchObject({ @@ -9272,11 +9534,237 @@ describe('OrcaRuntimeService', () => { it('keeps recent PTY replay output capped without needing previous data for large chunks', () => { const previous = 'old-output'.repeat(1000) const data = 'new-output'.repeat(1000) - const expected = `${previous}${data}`.slice(-4096) + const outputLimit = 64 * 1024 + const expected = `${previous}${data}`.slice(-outputLimit) - expect(appendRecentPtyOutput(previous, 'tail')).toBe(`${previous}tail`.slice(-4096)) + expect(appendRecentPtyOutput(previous, 'tail')).toBe(`${previous}tail`.slice(-outputLimit)) expect(appendRecentPtyOutput(previous, data)).toBe(expected) - expect(appendRecentPtyOutput(undefined, data)).toBe(data.slice(-4096)) + expect(appendRecentPtyOutput(undefined, data)).toBe(data.slice(-outputLimit)) + }) + + it('keeps mobile-visible artifact paths in bounded PTY path candidates', () => { + const artifactPath = '/tmp/result-visible-in-mobile-scrollback.json' + const prefix = 'x'.repeat(8 * 1024) + const candidates = appendRecentPtyPathCandidates(undefined, `${artifactPath}\n${prefix}`) + + expect(candidates.length).toBeGreaterThan(0) + expect(recentTerminalPathCandidatesIncludePath(candidates, artifactPath, artifactPath)).toBe( + true + ) + }) + + it('keeps dotted spaced artifact paths in bounded PTY path candidates', () => { + const artifactPath = '/tmp/v1.2 reports/result.json' + const prefix = 'x'.repeat(8 * 1024) + const candidates = appendRecentPtyPathCandidates(undefined, `wrote ${artifactPath}\n${prefix}`) + + expect(recentTerminalPathCandidatesIncludePath(candidates, artifactPath, artifactPath)).toBe( + true + ) + expect(recentTerminalPathCandidatesIncludePath(candidates, '/tmp/v1.2', '/tmp/v1.2')).toBe( + false + ) + }) + + it('does not swallow trailing prose ending in a filename into candidates', () => { + const candidates = appendRecentPtyPathCandidates( + undefined, + '/tmp/app.log failed to start app.py\n' + ) + + expect( + recentTerminalPathCandidatesIncludePath(candidates, '/tmp/app.log', '/tmp/app.log') + ).toBe(true) + expect( + recentTerminalPathCandidatesIncludePath( + candidates, + '/tmp/app.log failed to start app.py', + '/tmp/app.log failed to start app.py' + ) + ).toBe(false) + }) + + it('bounds candidate extraction cost on pathological separator floods', () => { + const flood = '/'.repeat(64 * 1024) + const start = performance.now() + const candidates = appendRecentPtyPathCandidates(undefined, flood) + const elapsed = performance.now() - start + + expect(candidates).toEqual([]) + // Why: the extension regex is quadratic per line; unbounded this took + // seconds on the main-process PTY hot path. Loose bound to avoid CI flake. + expect(elapsed).toBeLessThan(500) + }) + + it('strips retained terminal path line and hash locators before matching', () => { + const colonPath = '/tmp/orca report/result.json' + const hashPath = '/tmp/result-hash.json' + const candidates = appendRecentPtyPathCandidates( + undefined, + `wrote ${colonPath}:12:3 for you\nfile://${hashPath}#L12C3 generated\n` + ) + + expect(recentTerminalPathCandidatesIncludePath(candidates, colonPath, colonPath)).toBe(true) + expect(recentTerminalPathCandidatesIncludePath(candidates, hashPath, hashPath)).toBe(true) + }) + + it('keeps non-loopback file URI authorities in retained PTY path candidates', () => { + const candidates = appendRecentPtyPathCandidates( + undefined, + 'wrote file://remote-host/tmp/result.json\n' + ) + + expect( + recentTerminalPathCandidatesIncludePath( + candidates, + '//remote-host/tmp/result.json', + '//remote-host/tmp/result.json' + ) + ).toBe(true) + expect( + recentTerminalPathCandidatesIncludePath(candidates, '/tmp/result.json', '/tmp/result.json') + ).toBe(false) + }) + + it('keeps loopback file URI paths as local retained PTY path candidates', () => { + const candidates = appendRecentPtyPathCandidates( + undefined, + 'wrote file://localhost/tmp/result.json#L12\n' + ) + + expect( + recentTerminalPathCandidatesIncludePath(candidates, '/tmp/result.json', '/tmp/result.json') + ).toBe(true) + }) + + it('keeps tmpdir artifact paths in bounded PTY path candidates', () => { + const artifactPath = join(tmpdir(), 'orca-runtime-retained-result.json') + const candidates = appendRecentPtyPathCandidates(undefined, `wrote ${artifactPath}\n`) + + expect(recentTerminalPathCandidatesIncludePath(candidates, artifactPath, artifactPath)).toBe( + true + ) + }) + + it('matches WSL UNC artifact paths against POSIX terminal output candidates', () => { + const candidates = appendRecentPtyPathCandidates(undefined, 'wrote /tmp/result.json\n') + + expect( + recentTerminalPathCandidatesIncludePath( + candidates, + '\\\\wsl.localhost\\Ubuntu\\tmp\\result.json', + '\\\\wsl.localhost\\Ubuntu\\tmp\\result.json' + ) + ).toBe(true) + }) + + it('keeps bounded PTY path candidates under a total byte budget', () => { + const longCandidate = `/tmp/${'x'.repeat(5 * 1024)}.json` + const candidates = appendRecentPtyPathCandidates(undefined, `${longCandidate}\n`) + + expect(candidates).toEqual([]) + + let retained: string[] | undefined + for (let index = 0; index < 200; index += 1) { + retained = appendRecentPtyPathCandidates(retained, `/tmp/${'a'.repeat(900)}-${index}.json\n`) + } + const totalBytes = (retained ?? []).reduce( + (sum, candidate) => sum + Buffer.byteLength(candidate, 'utf8'), + 0 + ) + expect(totalBytes).toBeLessThanOrEqual(64 * 1024) + }) + + it('keeps Windows file URI drive paths in bounded PTY path candidates', () => { + const artifactPath = 'C:/Users/me/AppData/Local/Temp/result.json' + const prefix = 'x'.repeat(8 * 1024) + const candidates = appendRecentPtyPathCandidates( + undefined, + `file:///C:/Users/me/AppData/Local/Temp/result.json\n${prefix}` + ) + + expect(recentTerminalPathCandidatesIncludePath(candidates, artifactPath, artifactPath)).toBe( + true + ) + }) + + it('matches terminal artifact paths only when they appear in recent terminal output', () => { + expect( + recentTerminalOutputIncludesPath( + 'wrote /tmp/orca report/result.json:12:3', + '/tmp/orca report/result.json', + '/tmp/orca report/result.json' + ) + ).toBe(true) + expect( + recentTerminalOutputIncludesPath( + '\x1b]8;;file:///tmp/orca%20report/result.json\x1b\\result\x1b]8;;\x1b\\', + '/tmp/orca report/result.json', + '/tmp/orca report/result.json' + ) + ).toBe(true) + expect( + recentTerminalOutputIncludesPath( + '\x1b]8;;file:///tmp/caf%C3%A9.txt\x1b\\result\x1b]8;;\x1b\\', + '/tmp/café.txt', + '/tmp/café.txt' + ) + ).toBe(true) + expect( + recentTerminalOutputIncludesPath( + 'wrote /tmp/orca report/other.json', + '/tmp/orca report/result.json', + '/tmp/orca report/result.json' + ) + ).toBe(false) + }) + + it('does not match terminal artifact path prefixes as provenance', () => { + expect( + recentTerminalOutputIncludesPath( + 'wrote /tmp/result.json.bak', + '/tmp/result.json', + '/tmp/result.json' + ) + ).toBe(false) + expect( + recentTerminalOutputIncludesPath( + 'wrote /tmp/result.json:12:3', + '/tmp/result.json', + '/tmp/result.json' + ) + ).toBe(true) + }) + + it('matches terminal artifact paths inside loopback file URI output', () => { + expect( + recentTerminalOutputIncludesPath( + 'wrote file://127.0.0.1/tmp/result.json', + '/tmp/result.json', + '/tmp/result.json' + ) + ).toBe(true) + expect( + recentTerminalOutputIncludesPath( + 'wrote file://[::1]/tmp/result.json', + '/tmp/result.json', + '/tmp/result.json' + ) + ).toBe(true) + expect( + recentTerminalOutputIncludesPath( + 'wrote file:///C:/Users/me/AppData/Local/Temp/result.json', + 'C:/Users/me/AppData/Local/Temp/result.json', + 'C:/Users/me/AppData/Local/Temp/result.json' + ) + ).toBe(true) + expect( + recentTerminalOutputIncludesPath( + 'wrote file://localhost/C:/Users/me/AppData/Local/Temp/result.json', + 'C:/Users/me/AppData/Local/Temp/result.json', + 'C:/Users/me/AppData/Local/Temp/result.json' + ) + ).toBe(true) }) it('applies terminal redraw controls before retaining previews', async () => { @@ -10009,6 +10497,112 @@ describe('OrcaRuntimeService', () => { expect(read.tail).toEqual(['ready']) }) + it('recovers exported ORCA_TERMINAL_HANDLE from discovered live PTY sessions', async () => { + const runtime = new OrcaRuntimeService(store) + const writes: string[] = [] + runtime.setPtyController({ + write: (_ptyId, data) => { + writes.push(data) + return true + }, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { + id: 'pty-1', + cwd: TEST_WORKTREE_PATH, + title: 'claude', + terminalHandle: 'term_exported' + } + ] + }) + + const listed = await runtime.listTerminals() + expect(listed.terminals[0]?.handle).toBe('term_exported') + + runtime.onPtyData('pty-1', 'after restart\n', 100) + await expect(runtime.readTerminal('term_exported')).resolves.toMatchObject({ + handle: 'term_exported', + tail: ['after restart'] + }) + await expect( + runtime.sendTerminal('term_exported', { text: 'still writable' }) + ).resolves.toMatchObject({ + handle: 'term_exported', + accepted: true + }) + expect(writes).toEqual(['still writable']) + }) + + it('does not adopt a discovered terminal handle already bound to another live PTY', async () => { + const runtime = new OrcaRuntimeService(store) + const writesByPty = new Map() + runtime.setPtyController({ + write: (ptyId, data) => { + writesByPty.set(ptyId, [...(writesByPty.get(ptyId) ?? []), data]) + return true + }, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { + id: 'pty-victim', + cwd: TEST_WORKTREE_PATH, + title: 'claude', + terminalHandle: 'term_victim' + }, + { + id: 'pty-imposter', + cwd: TEST_WORKTREE_PATH, + title: 'claude', + terminalHandle: 'term_victim' + } + ] + }) + + const listed = await runtime.listTerminals() + const handles = listed.terminals.map((terminal) => terminal.handle) + expect(handles).toContain('term_victim') + expect(new Set(handles).size).toBe(handles.length) + + await expect( + runtime.sendTerminal('term_victim', { text: 'for victim' }) + ).resolves.toMatchObject({ accepted: true }) + expect(writesByPty.get('pty-victim')).toEqual(['for victim']) + expect(writesByPty.has('pty-imposter')).toBe(false) + }) + + it('keeps an already-bound terminal handle when discovery reports a different exported one', async () => { + const runtime = new OrcaRuntimeService(store) + const writes: string[] = [] + runtime.setPtyController({ + write: (_ptyId, data) => { + writes.push(data) + return true + }, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { + id: 'pty-1', + cwd: TEST_WORKTREE_PATH, + title: 'claude', + terminalHandle: 'term_from_env' + } + ] + }) + runtime.registerPreAllocatedHandleForPty('pty-1', 'term_already_bound') + + const listed = await runtime.listTerminals() + expect(listed.terminals[0]?.handle).toBe('term_already_bound') + await expect( + runtime.sendTerminal('term_already_bound', { text: 'still routed' }) + ).resolves.toMatchObject({ accepted: true }) + expect(writes).toEqual(['still routed']) + // the reported-but-not-adopted handle must not resolve to the live pty + await expect(runtime.readTerminal('term_from_env')).rejects.toThrow() + }) + it('binds advertised URLs for renderer-restored PTYs that skip registerPty', () => { const runtime = new OrcaRuntimeService(store) @@ -11668,6 +12262,303 @@ describe('OrcaRuntimeService', () => { expect(getForegroundProcess).toHaveBeenCalledTimes(2) }) + it('normalizes Pi-compatible mobile session status to OMP for an unknown-launch foreground omp PTY', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-omp' }) + const getForegroundProcess = vi.fn(async () => 'omp') + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess + }) + const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'typed-omp-tab', + leafId: HEADLESS_LEAF_ID, + title: 'Terminal' + }) + + runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi ready\x07', 123) + await new Promise((resolve) => setImmediate(resolve)) + + const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(getForegroundProcess).toHaveBeenCalledWith('pty-typed-omp') + expect(result.tabs[0]).toEqual( + expect.objectContaining({ + type: 'terminal', + title: 'OMP ready', + agentStatus: expect.objectContaining({ + state: 'done', + agentType: 'omp', + terminalHandle: terminal.handle, + terminalTitle: 'OMP ready' + }) + }) + ) + expect(result.tabs[0]).not.toHaveProperty('launchAgent') + }) + + it('waits for unknown-launch foreground owner before publishing Pi-compatible mobile status', async () => { + const foregroundProcess = deferred() + const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-omp' }) + const getForegroundProcess = vi.fn(() => foregroundProcess.promise) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess + }) + const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'typed-omp-tab', + leafId: HEADLESS_LEAF_ID, + title: 'Terminal' + }) + const events: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + + runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi ready\x07', 123) + await new Promise((resolve) => setImmediate(resolve)) + + expect(getForegroundProcess).toHaveBeenCalledWith('pty-typed-omp') + expect(events).toHaveLength(0) + + foregroundProcess.resolve('omp') + await new Promise((resolve) => setImmediate(resolve)) + + expect(events).toEqual([ + expect.objectContaining({ + tabs: [ + expect.objectContaining({ + type: 'terminal', + title: 'OMP ready', + agentStatus: expect.objectContaining({ + state: 'done', + agentType: 'omp', + terminalHandle: terminal.handle, + terminalTitle: 'OMP ready' + }) + }) + ] + }) + ]) + + unsubscribe() + }) + + it('keeps same-status Pi-compatible title changes queued behind the foreground owner probe', async () => { + const foregroundProcess = deferred() + const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-omp' }) + const getForegroundProcess = vi.fn(() => foregroundProcess.promise) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess + }) + const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'typed-omp-tab', + leafId: HEADLESS_LEAF_ID, + title: 'Terminal' + }) + const events: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + + runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi ready\x07', 123) + await new Promise((resolve) => setImmediate(resolve)) + runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi idle\x07', 124) + await new Promise((resolve) => setImmediate(resolve)) + + expect(getForegroundProcess).toHaveBeenCalledTimes(1) + expect(events).toHaveLength(0) + + foregroundProcess.resolve('omp') + await new Promise((resolve) => setImmediate(resolve)) + + expect(events).toEqual([ + expect.objectContaining({ + tabs: [ + expect.objectContaining({ + type: 'terminal', + title: 'OMP ready', + agentStatus: expect.objectContaining({ + state: 'done', + agentType: 'omp', + terminalHandle: terminal.handle, + terminalTitle: 'OMP ready' + }) + }) + ] + }) + ]) + + unsubscribe() + }) + + it('coalesces same-status title frames behind one post-title foreground probe', async () => { + const staleForegroundProcess = deferred() + const freshForegroundProcess = deferred() + const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-omp' }) + const getForegroundProcess = vi + .fn() + .mockReturnValueOnce(staleForegroundProcess.promise) + .mockReturnValueOnce(freshForegroundProcess.promise) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess + }) + const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'typed-omp-tab', + leafId: HEADLESS_LEAF_ID, + title: 'Terminal' + }) + const events: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + + runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi ready\x07', 123) + await new Promise((resolve) => setImmediate(resolve)) + runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi idle\x07', 124) + runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi done\x07', 125) + await new Promise((resolve) => setImmediate(resolve)) + + expect(getForegroundProcess).toHaveBeenCalledTimes(1) + expect(events).toHaveLength(0) + + staleForegroundProcess.resolve(null) + await new Promise((resolve) => setImmediate(resolve)) + + expect(getForegroundProcess).toHaveBeenCalledTimes(2) + expect(events).toHaveLength(0) + + freshForegroundProcess.resolve('omp') + await new Promise((resolve) => setImmediate(resolve)) + + expect(getForegroundProcess).toHaveBeenCalledTimes(2) + expect(events).toEqual([ + expect.objectContaining({ + tabs: [ + expect.objectContaining({ + type: 'terminal', + title: 'OMP ready', + agentStatus: expect.objectContaining({ + state: 'done', + agentType: 'omp', + terminalHandle: terminal.handle, + terminalTitle: 'OMP ready' + }) + }) + ] + }) + ]) + + unsubscribe() + }) + + it('starts a post-title foreground probe when an older pending probe finds no owner', async () => { + const staleForegroundProcess = deferred() + const freshForegroundProcess = deferred() + const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-omp' }) + const getForegroundProcess = vi + .fn() + .mockReturnValueOnce(staleForegroundProcess.promise) + .mockReturnValueOnce(freshForegroundProcess.promise) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess + }) + const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'typed-omp-tab', + leafId: HEADLESS_LEAF_ID, + title: 'Terminal' + }) + const events: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + + ;( + runtime as unknown as { + refreshPtyForegroundAgentFromController: (ptyId: string) => Promise + } + ).refreshPtyForegroundAgentFromController('pty-typed-omp') + runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi ready\x07', 123) + await new Promise((resolve) => setImmediate(resolve)) + + expect(getForegroundProcess).toHaveBeenCalledTimes(1) + expect(events).toHaveLength(0) + + staleForegroundProcess.resolve(null) + await new Promise((resolve) => setImmediate(resolve)) + + expect(getForegroundProcess).toHaveBeenCalledTimes(2) + expect(events).toHaveLength(0) + + freshForegroundProcess.resolve('omp') + await new Promise((resolve) => setImmediate(resolve)) + + expect(events).toEqual([ + expect.objectContaining({ + tabs: [ + expect.objectContaining({ + type: 'terminal', + title: 'OMP ready', + agentStatus: expect.objectContaining({ + state: 'done', + agentType: 'omp', + terminalHandle: terminal.handle, + terminalTitle: 'OMP ready' + }) + }) + ] + }) + ]) + + unsubscribe() + }) + + it('keeps Pi-compatible mobile session status as Pi for an unknown-launch foreground pi PTY', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-pi' }) + const getForegroundProcess = vi.fn(async () => 'pi') + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess + }) + const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'typed-pi-tab', + leafId: HEADLESS_LEAF_ID, + title: 'Terminal' + }) + + runtime.onPtyData('pty-typed-pi', '\x1b]0;Pi ready\x07', 123) + await new Promise((resolve) => setImmediate(resolve)) + + const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(getForegroundProcess).toHaveBeenCalledWith('pty-typed-pi') + expect(result.tabs[0]).toEqual( + expect.objectContaining({ + type: 'terminal', + title: 'Pi ready', + agentStatus: expect.objectContaining({ + state: 'done', + agentType: 'pi', + terminalHandle: terminal.handle, + terminalTitle: 'Pi ready' + }) + }) + ) + expect(result.tabs[0]).not.toHaveProperty('launchAgent') + }) + it('keeps renderer-vetted mobile agent status for custom-titled terminals', async () => { const runtime = new OrcaRuntimeService(store) const leafId = '11111111-1111-4111-8111-111111111111' diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 819fb34ea69..bbfa9731182 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -8,6 +8,8 @@ import { isShellProcess } from '../../shared/agent-detection' import { extractOscTitleScanTail } from '../../shared/osc-title-scan-tail' +import { extractLastOsc7Uri, extractOscScanTail } from '../daemon/osc7-uri-extraction' +import { parseFileUriPathParts } from '../daemon/osc7-file-uri' import type { AgentStatus } from '../../shared/agent-detection' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' import { @@ -18,6 +20,7 @@ import { type AgentStatusEntry } from '../../shared/agent-status-types' import { + hasCompatibleAgentTitleIdentity, normalizeCompatibleAgentStatusEntryForOwner, normalizeCompatibleAgentTitleForOwner } from '../../shared/agent-title-owner' @@ -198,6 +201,7 @@ import { isPathInsideOrEqual, normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { resolveTerminalStartupCwd } from '../../shared/terminal-startup-cwd' import { isWslUncPath } from '../../shared/wsl-paths' import { folderWorkspaceKey, @@ -655,10 +659,10 @@ import { prefetchWorktreeCreateBase } from '../worktree-create-base-prefetch' import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation' import { closeLocalWatcherForWorktreePath } from '../ipc/filesystem-watcher' -import { HeadlessEmulator } from '../daemon/headless-emulator' +import { HeadlessEmulator, type HeadlessEmulatorOptions } from '../daemon/headless-emulator' import { killAllProcessesForWorktree } from './worktree-teardown' import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from './scrollback-limits' -import type { IFilesystemProvider, IPtyProvider } from '../providers/types' +import type { IFilesystemProvider, IPtyProvider, PtyProcessInfo } from '../providers/types' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { assertFolderWorkspacePathUsable, @@ -939,6 +943,12 @@ type RuntimePtyWorktreeRecord = { waitBlockedAt: number | null } +type PtyForegroundAgentRefresh = { + promise: Promise + startedAfterTitleObservation: number + requestedAfterTitleObservation: number +} + function copySleepingAgentLaunchConfig( config: SleepingAgentLaunchConfig ): SleepingAgentLaunchConfig { @@ -1029,11 +1039,12 @@ type RuntimePtyController = { write(ptyId: string, data: string): boolean kill(ptyId: string): boolean stopAndWait?(ptyId: string, opts?: { keepHistory?: boolean }): Promise + getCwd?(ptyId: string): Promise getForegroundProcess(ptyId: string): Promise hasChildProcesses?(ptyId: string): Promise clearBuffer?(ptyId: string): Promise resize?(ptyId: string, cols: number, rows: number): boolean - listProcesses?(): Promise<{ id: string; cwd: string; title: string }[]> + listProcesses?(): Promise serializeBuffer?( ptyId: string, opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } @@ -1079,7 +1090,10 @@ const BRACKETED_PASTE_QUIET_MS = 1500 const DRAFT_PASTE_READY_TIMEOUT_MS = 8000 const MOBILE_TERMINAL_SURFACE_TIMEOUT_MS = 10_000 const MOBILE_TERMINAL_READY_FALLBACK_MS = 1000 -const RECENT_PTY_OUTPUT_LIMIT = 4096 +const RECENT_PTY_OUTPUT_LIMIT = 64 * 1024 +const RECENT_PTY_PATH_CANDIDATE_LIMIT = 1024 +const RECENT_PTY_PATH_CANDIDATE_MAX_BYTES = 4 * 1024 +const RECENT_PTY_PATH_CANDIDATE_TOTAL_BYTES = 64 * 1024 function createTerminalRevealWarning(handle: string, error?: unknown): string { const reason = @@ -1122,6 +1136,7 @@ type RuntimeNotifier = { worktreeId: string, opts: { command?: string + cwd?: string env?: Record title?: string presentation?: RuntimeTerminalPresentation @@ -1132,6 +1147,7 @@ type RuntimeNotifier = { opts: { ptyId: string title?: string | null + cwd?: string launchConfig?: SleepingAgentLaunchConfig launchToken?: string launchAgent?: TuiAgent @@ -1916,7 +1932,8 @@ export class OrcaRuntimeService { private resolvedWorktreeGeneration = 0 private cloneInFlightByPath = new Map>() private agentDetector: AgentDetector | null = null - private ptyForegroundAgentRefreshes = new Map>() + private ptyForegroundAgentRefreshes = new Map() + private ptyDelayedForegroundSnapshotTitleObservations = new Map() private _orchestrationDb: OrchestrationDb | null = null private messageWaitersByHandle = new Map>() // Why: mobile clients subscribe to terminal output via terminal.subscribe. @@ -1924,7 +1941,7 @@ export class OrcaRuntimeService { // without polling. Keyed by ptyId for O(1) lookup per data event. private dataListeners = new Map< string, - Set<(data: string, meta?: { seq?: number; rawLength?: number }) => void> + Set<(data: string, meta?: { seq?: number; rawLength?: number; cwd?: string }) => void> >() // Why: startup draft paste can subscribe after the agent already emitted its // ready marker. Keep a bounded raw buffer so fast startup output is replayed. @@ -1961,6 +1978,7 @@ export class OrcaRuntimeService { private titleObservationSequence = 0 private headlessTerminals = new Map() private ptyOutputSequenceById = new Map() + private recentPtyPathCandidatesById = new Map() // Why: OSC 9999 status can span PTY chunks. Keeping parser state in the // runtime lets hidden/model-owned terminals observe agent state without a // mounted xterm view. @@ -1971,6 +1989,11 @@ export class OrcaRuntimeService { // Why: ordinary OSC 0/1/2 titles can split across PTY chunks, especially over // SSH/relay buffering. Keep a small raw scan tail so status titles are not lost. private oscTitleScanTailByPtyId = new Map() + // Why: mobile file taps resolve relative paths on the host. OSC 7 is the + // terminal-owned cwd signal, and it can arrive in live output between snapshots. + private osc7ScanTailByPtyId = new Map() + private terminalCwdByPtyId = new Map() + private terminalFileUriHostnameByPtyId = new Map() // Why: latest agent-status payload per pane, retained so worktree.ps can serve // mobile the same inline agent rows the desktop sidebar renders. Cleared on pty // teardown so dead agents don't linger. See RuntimeAgentRowSnapshot. @@ -3238,7 +3261,7 @@ export class OrcaRuntimeService { /** * Publishes a PTY-backed terminal tab snapshot to the synced mobile session, - * normalizing Pi-compatible titles based on launch ownership. + * normalizing Pi-compatible titles based on launch or foreground ownership. */ private publishPtyBackedMobileSessionTerminal( worktreeId: string, @@ -3249,13 +3272,15 @@ export class OrcaRuntimeService { title: string | null activate: boolean selectIfNoActiveTab?: boolean + startupCwd?: string split?: { splitFromLeafId: string; direction: 'horizontal' | 'vertical' } } ): void { const existing = this.mobileSessionTabsByWorktree.get(worktreeId) + const ownerAgent = pty.launchAgent ?? pty.foregroundAgent const title = normalizeCompatibleAgentTitleForOwner( args.title ?? getLatestPtyTitle(pty) ?? 'Terminal', - pty.launchAgent + ownerAgent ) const existingTab = existing?.tabs.find( (candidate): candidate is RuntimeMobileSessionTerminalTab => @@ -3287,6 +3312,7 @@ export class OrcaRuntimeService { ptyId: pty.ptyId, title, ...(pty.launchAgent ? { launchAgent: pty.launchAgent } : {}), + ...(args.startupCwd ? { startupCwd: args.startupCwd } : {}), parentLayout, isActive: args.activate || (args.selectIfNoActiveTab !== false && existing?.activeTabId == null) @@ -3392,6 +3418,7 @@ export class OrcaRuntimeService { leafId, title, ...(ptyId ? { ptyId } : {}), + ...(tab.startupCwd ? { startupCwd: tab.startupCwd } : {}), ...(tab.launchAgent ? { launchAgent: tab.launchAgent } : {}), ...(layout ? { parentLayout: this.cloneTerminalLayoutSnapshot(layout) } : {}), ...(tab.color != null ? { color: tab.color } : {}), @@ -3823,21 +3850,16 @@ export class OrcaRuntimeService { group.tabOrder.includes(tab.parentTabId) )?.id try { - await this.createHeadlessMobileSessionTerminal( - worktreeId, - true, - undefined, - undefined, - undefined, - undefined, - { + await this.createHeadlessMobileSessionTerminal(worktreeId, true, undefined, { + identity: { tabId: tab.parentTabId, leafId: tab.leafId, sessionId }, - tab.launchAgent, + cwd: tab.startupCwd, + launchAgent: tab.launchAgent, targetGroupId - ) + }) } catch (err) { if (sessionId && parseAppSshPtyId(sessionId)) { // Why: an expired SSH reattach clears durable bindings in the store, @@ -4799,6 +4821,12 @@ export class OrcaRuntimeService { requireStore: () => this.requireStore(), resolveWorktreeSelector: (selector) => this.resolveWorktreeSelector(selector), resolveRuntimeFileTarget: (selector) => this.resolveRuntimeFileTarget(selector), + resolveTerminalCwd: (terminalHandle) => this.resolveTerminalCwd(terminalHandle), + resolveTerminalContext: (terminalHandle) => this.resolveTerminalContext(terminalHandle), + resolveTerminalFileUriHostname: (terminalHandle) => + this.resolveTerminalFileUriHostname(terminalHandle), + hasRecentTerminalOutputPath: (terminalHandle, pathText, absolutePath) => + this.hasRecentTerminalOutputPath(terminalHandle, pathText, absolutePath), resolveRuntimeGitTarget: (selector) => this.resolveRuntimeGitTarget(selector), openFile: (worktreeId, filePath, relativePath, runtimeEnvironmentId) => { if (!this.notifier?.openFile) { @@ -4828,6 +4856,14 @@ export class OrcaRuntimeService { ) resolveTerminalPath: RuntimeFileCommands['resolveTerminalPath'] = this.fileCommands.resolveTerminalPath.bind(this.fileCommands) + readTerminalArtifactFile: RuntimeFileCommands['readTerminalArtifactFile'] = + this.fileCommands.readTerminalArtifactFile.bind(this.fileCommands) + readTerminalArtifactPreview: RuntimeFileCommands['readTerminalArtifactPreview'] = + this.fileCommands.readTerminalArtifactPreview.bind(this.fileCommands) + writeTerminalArtifactFile: RuntimeFileCommands['writeTerminalArtifactFile'] = + this.fileCommands.writeTerminalArtifactFile.bind(this.fileCommands) + revokeTerminalFileGrantsForClient: RuntimeFileCommands['revokeTerminalFileGrantsForClient'] = + this.fileCommands.revokeTerminalFileGrantsForClient.bind(this.fileCommands) readFileExplorerDir: RuntimeFileCommands['readFileExplorerDir'] = this.fileCommands.readFileExplorerDir.bind(this.fileCommands) watchFileExplorer: RuntimeFileCommands['watchFileExplorer'] = @@ -5014,6 +5050,46 @@ export class OrcaRuntimeService { } } + private adoptControllerTerminalHandle(ptyId: string, handle: string | undefined): void { + const trimmed = handle?.trim() + if (!trimmed || !trimmed.startsWith('term_')) { + return + } + if (this.isTerminalHandleAdoptionBlocked(ptyId, trimmed)) { + return + } + // Why: after an app/runtime restart, the live PTY child still has its + // original ORCA_TERMINAL_HANDLE, but the runtime's in-memory map is gone. + this.registerPreAllocatedHandleForPty(ptyId, trimmed) + } + + // Why: adoption is best-effort restart recovery and must be first-wins. + // Re-keying a pty that already has a handle this session would strand + // waiters registered under the old handle, and provider-reported values + // are not trusted to be collision-free — a handle bound to a different + // pty must never be stolen by a later report. + private isTerminalHandleAdoptionBlocked(ptyId: string, handle: string): boolean { + if (this.handleByPtyId.get(ptyId) ?? this.findHandleForPtyRecord(ptyId)) { + return true + } + for (const leaf of this.getLeavesForPty(ptyId)) { + const issued = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId)) + if (issued && issued !== handle) { + return true + } + } + const existingRecord = this.handles.get(handle) + if (existingRecord && existingRecord.ptyId !== ptyId) { + return true + } + for (const [otherPtyId, otherHandle] of this.handleByPtyId) { + if (otherHandle === handle && otherPtyId !== ptyId) { + return true + } + } + return false + } + onPtySpawned(ptyId: string): void { const pty = this.getOrCreatePtyWorktreeRecord(ptyId) if (pty) { @@ -5038,11 +5114,11 @@ export class OrcaRuntimeService { onPtyData(ptyId: string, data: string, at: number): number { const outputSequence = (this.ptyOutputSequenceById.get(ptyId) ?? 0) + data.length this.ptyOutputSequenceById.set(ptyId, outputSequence) + const osc7Metadata = this.recordOsc7MetadataForPty(ptyId, data) + const cwd = osc7Metadata.cwd + const cwdChanged = osc7Metadata.cwdChanged const agentStatusChunk = this.processAgentStatusOscForPty(ptyId, data) - this.recentPtyOutputById.set( - ptyId, - appendRecentPtyOutput(this.recentPtyOutputById.get(ptyId), data) - ) + this.recordRecentPtyOutputForPathProvenance(ptyId, data) // Agent detection runs on raw data before leaf processing, since the // tail buffer logic normalizes away the OSC sequences we need. this.agentDetector?.onData(ptyId, data, at) @@ -5127,11 +5203,25 @@ export class OrcaRuntimeService { if (agentStatus === 'idle' && prevStatus !== 'idle') { this.resolvePtyTuiIdleWaiters(pty, ptyId) } + const shouldDelayMobileSnapshot = + shouldTouchPtyBackedSessionTabs && + this.shouldDelayPtyBackedMobileSnapshotForForegroundAgent(pty, oscTitle) + let foregroundRefresh: Promise | undefined // Why: gate on an actual status transition — braille spinner frames // mutate the title every tick, so probing per-title-change would stream // a foreground query per frame during active work. if (prevStatus !== pty.lastAgentStatus) { - this.refreshPtyForegroundAgent(ptyId) + foregroundRefresh = this.refreshPtyForegroundAgentFromController(ptyId, { + afterTitleObservation: observedAt + }) + } else if (shouldDelayMobileSnapshot) { + // Why: same-status compatible title changes can arrive before the + // foreground owner probe settles; publishing them would flicker. + foregroundRefresh = this.getPendingForegroundAgentRefreshForTitle(ptyId, observedAt) + } + if (foregroundRefresh && shouldDelayMobileSnapshot) { + shouldTouchPtyBackedSessionTabs = false + this.delayPtyBackedMobileSnapshotForForegroundAgent(ptyId, observedAt, foregroundRefresh) } } } @@ -5239,7 +5329,11 @@ export class OrcaRuntimeService { const listeners = this.dataListeners.get(ptyId) if (listeners) { - const meta = { seq: outputSequence, rawLength: data.length } + const meta = { + seq: outputSequence, + rawLength: data.length, + ...(cwdChanged && cwd !== null ? { cwd } : {}) + } for (const listener of listeners) { listener(data, meta) } @@ -5271,6 +5365,61 @@ export class OrcaRuntimeService { return extractLastOscTitle(input) } + private extractLastOsc7CwdForPty( + ptyId: string, + data: string + ): { path: string; hostname: string } | null { + const previousTail = this.osc7ScanTailByPtyId.get(ptyId) + if (!previousTail && !data.includes('\x1b]7;')) { + return null + } + const input = `${previousTail ?? ''}${data}` + const scanTail = extractOscScanTail(input, 4096) + if (scanTail.length > 0) { + this.osc7ScanTailByPtyId.set(ptyId, scanTail) + } else { + this.osc7ScanTailByPtyId.delete(ptyId) + } + const uri = extractLastOsc7Uri(input) + const pty = this.ptysById.get(ptyId) + const pathFlavor = this.pathFlavorForPty(pty) + return uri + ? parseFileUriPathParts(uri, { + pathFlavor, + remotePosixAuthority: !!pty?.connectionId && pathFlavor !== 'win32' + }) + : null + } + + private recordOsc7MetadataForPty( + ptyId: string, + data: string + ): { cwd: string | null; cwdChanged: boolean } { + const osc7 = this.extractLastOsc7CwdForPty(ptyId, data) + const cwd = osc7?.path ?? null + const cwdChanged = + cwd !== null && cwd.trim().length > 0 && this.terminalCwdByPtyId.get(ptyId) !== cwd + if (cwdChanged) { + this.terminalCwdByPtyId.set(ptyId, cwd) + } + if (osc7) { + if (osc7.hostname) { + this.terminalFileUriHostnameByPtyId.set(ptyId, osc7.hostname) + } else { + this.terminalFileUriHostnameByPtyId.delete(ptyId) + } + } + return { cwd, cwdChanged } + } + + private pathFlavorForPty(pty?: RuntimePtyWorktreeRecord | null): 'posix' | 'win32' { + if (!pty?.connectionId) { + return process.platform === 'win32' ? 'win32' : 'posix' + } + const worktreePath = splitWorktreeIdForFilesystem(pty.worktreeId)?.worktreePath + return worktreePath && isWindowsAbsolutePathLike(worktreePath) ? 'win32' : 'posix' + } + private emitTerminalAgentStatusEvents(ptyId: string, chunk: ProcessedAgentStatusChunk): void { // Why: snapshot retention (for mobile worktree.ps) must run even when no // renderer listener is attached, so we don't early-return on a missing @@ -5373,7 +5522,7 @@ export class OrcaRuntimeService { subscribeToTerminalData( ptyId: string, - listener: (data: string, meta?: { seq?: number; rawLength?: number }) => void + listener: (data: string, meta?: { seq?: number; rawLength?: number; cwd?: string }) => void ): () => void { return addListenerToMap(this.dataListeners, ptyId, listener) } @@ -5518,11 +5667,13 @@ export class OrcaRuntimeService { } const dims = size ?? this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } const state: RuntimeHeadlessTerminal = { - emulator: new HeadlessEmulator({ cols: dims.cols, rows: dims.rows }), + emulator: this.createHeadlessEmulator(ptyId, { cols: dims.cols, rows: dims.rows }), outputSequence: 0, writeChain: Promise.resolve() } this.headlessTerminals.set(ptyId, state) + this.recordOsc7MetadataForPty(ptyId, data) + this.recordRecentPtyOutputForPathProvenance(ptyId, data) state.writeChain = state.writeChain .then(async () => { await state.emulator.write(data) @@ -5567,7 +5718,7 @@ export class OrcaRuntimeService { this.headlessHydrationState.set(ptyId, 'pending') const dims = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } const state: RuntimeHeadlessTerminal = { - emulator: new HeadlessEmulator({ cols: dims.cols, rows: dims.rows }), + emulator: this.createHeadlessEmulator(ptyId, { cols: dims.cols, rows: dims.rows }), outputSequence: 0, writeChain: Promise.resolve() } @@ -5587,6 +5738,8 @@ export class OrcaRuntimeService { if (!rendered || rendered.data.length === 0) { return } + this.recordOsc7MetadataForPty(ptyId, rendered.data) + this.recordRecentPtyOutputForPathProvenance(ptyId, rendered.data) // Resize to renderer's dims so the seed reflows correctly into the // emulator's grid, then resize back to PTY dims (if known) so live // writes use the correct cell layout. @@ -5661,7 +5814,7 @@ export class OrcaRuntimeService { } const size = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } const state: RuntimeHeadlessTerminal = { - emulator: new HeadlessEmulator({ cols: size.cols, rows: size.rows }), + emulator: this.createHeadlessEmulator(ptyId, { cols: size.cols, rows: size.rows }), outputSequence: 0, writeChain: Promise.resolve() } @@ -5670,7 +5823,34 @@ export class OrcaRuntimeService { } private resizeHeadlessTerminal(ptyId: string, cols: number, rows: number): void { - this.headlessTerminals.get(ptyId)?.emulator.resize(cols, rows) + const state = this.headlessTerminals.get(ptyId) + if (!state) { + return + } + // Why: terminal reflow is a parser operation. It must sit in the same + // per-PTY stream as output bytes or restore snapshots can bake in wraps + // from the wrong terminal width. + state.writeChain = state.writeChain + .then(() => { + state.emulator.resize(cols, rows) + }) + .catch(() => { + // Best-effort mirror tracking; live PTY streaming must continue even + // if xterm rejects a raced resize during teardown. + }) + } + + private createHeadlessEmulator( + ptyId: string, + options: Omit + ): HeadlessEmulator { + const pathFlavor = this.pathFlavorForPty(this.ptysById.get(ptyId)) + return new HeadlessEmulator({ + ...options, + pathFlavor, + remotePosixFileUriAuthority: + !!this.ptysById.get(ptyId)?.connectionId && pathFlavor !== 'win32' + }) } private async clearHeadlessTerminalBuffer(ptyId: string): Promise { @@ -5740,7 +5920,13 @@ export class OrcaRuntimeService { // If renderer serialization races reload/unmount, callers can still use // their existing null fallback paths. } - return rendererSnapshot ? { ...rendererSnapshot, source: 'renderer' } : null + return rendererSnapshot + ? { + ...rendererSnapshot, + cwd: rendererSnapshot.cwd ?? this.terminalCwdByPtyId.get(ptyId), + source: 'renderer' + } + : null } private async withVisibleSnapshotFallback( @@ -5832,7 +6018,7 @@ export class OrcaRuntimeService { data, cols: snapshot.cols, rows: snapshot.rows, - cwd: snapshot.cwd, + cwd: snapshot.cwd ?? this.terminalCwdByPtyId.get(ptyId), lastTitle: snapshot.lastTitle, seq: state.outputSequence, source: 'headless', @@ -5870,6 +6056,59 @@ export class OrcaRuntimeService { return { ptyId: leaf.ptyId } } + async resolveTerminalCwd(handle: string): Promise { + const ptyId = this.resolveLeafForHandle(handle)?.ptyId + if (!ptyId) { + return null + } + const tracked = this.terminalCwdByPtyId.get(ptyId) + if (tracked) { + return tracked + } + try { + const cwd = await this.ptyController?.getCwd?.(ptyId) + return cwd && cwd.trim().length > 0 ? cwd : null + } catch { + return null + } + } + + resolveTerminalFileUriHostname(handle: string): string | null { + const ptyId = this.resolveLeafForHandle(handle)?.ptyId + return ptyId ? (this.terminalFileUriHostnameByPtyId.get(ptyId) ?? null) : null + } + + private recordRecentPtyOutputForPathProvenance(ptyId: string, data: string): void { + this.recentPtyOutputById.set( + ptyId, + appendRecentPtyOutput(this.recentPtyOutputById.get(ptyId), data) + ) + this.recentPtyPathCandidatesById.set( + ptyId, + appendRecentPtyPathCandidates(this.recentPtyPathCandidatesById.get(ptyId), data) + ) + } + + resolveTerminalContext( + handle: string + ): { worktreeId: string; connectionId: string | null } | null { + const ptyId = this.resolveLeafForHandle(handle)?.ptyId + const pty = ptyId ? this.ptysById.get(ptyId) : null + return pty ? { worktreeId: pty.worktreeId, connectionId: pty.connectionId } : null + } + + hasRecentTerminalOutputPath(handle: string, pathText: string, absolutePath: string): boolean { + const ptyId = this.resolveLeafForHandle(handle)?.ptyId + const recentOutput = ptyId ? this.recentPtyOutputById.get(ptyId) : null + if (recentOutput && recentTerminalOutputIncludesPath(recentOutput, pathText, absolutePath)) { + return true + } + const candidates = ptyId ? this.recentPtyPathCandidatesById.get(ptyId) : null + return candidates + ? recentTerminalPathCandidatesIncludePath(candidates, pathText, absolutePath) + : false + } + registerSubscriptionCleanup( subscriptionId: string, cleanup: () => void, @@ -6505,6 +6744,7 @@ export class OrcaRuntimeService { } onClientDisconnected(clientId: string): void { + this.revokeTerminalFileGrantsForClient(clientId) this.cancelMobileDictationForClient(clientId) // (1) Cancel pending restore-debounce timers owned by this client. @@ -6660,9 +6900,13 @@ export class OrcaRuntimeService { this.resizeListeners.delete(ptyId) this.lastRendererSizes.delete(ptyId) this.recentPtyOutputById.delete(ptyId) + this.recentPtyPathCandidatesById.delete(ptyId) this.ptyOutputSequenceById.delete(ptyId) this.agentStatusOscProcessorsByPtyId.delete(ptyId) this.oscTitleScanTailByPtyId.delete(ptyId) + this.osc7ScanTailByPtyId.delete(ptyId) + this.terminalCwdByPtyId.delete(ptyId) + this.terminalFileUriHostnameByPtyId.delete(ptyId) this.clearAgentRowSnapshotsForPty(ptyId) // Why: a Claude agent-team leader whose PTY exits naturally (agent finished, // process died, renderer reload) must release its team + nested panes map. @@ -6885,7 +7129,6 @@ export class OrcaRuntimeService { if (!resized) { return false } - this.resizeHeadlessTerminal(ptyId, cols, rows) this.onExternalPtyResize(ptyId, cols, rows) return true } @@ -7713,12 +7956,9 @@ export class OrcaRuntimeService { } } - // Why: called from the pty:resize IPC handler whenever the desktop renderer - // resizes a PTY (e.g. via safeFit after window resize, split, or desktop-mode - // restore). Stores the renderer-reported size so handleMobileSubscribe can use - // the actual pane geometry instead of a stale PTY size for previousCols. - // This is a passive geometry report — it does NOT call applyLayout; the - // PTY is already at the reported size. + // Why: called after a desktop renderer path has successfully resized the + // PTY (local IPC or remote desktop viewport). The runtime mirror must take + // the same accepted geometry so hidden-output restore parses at PTY width. onExternalPtyResize(ptyId: string, cols: number, rows: number): void { // The pty:resize IPC handler is supposed to gate via `isResizeSuppressed` // before calling here, but defend against callers that don't. @@ -7738,6 +7978,7 @@ export class OrcaRuntimeService { if (activeOverride && activeOverride.cols === cols && activeOverride.rows === rows) { return } + this.resizeHeadlessTerminal(ptyId, cols, rows) this.refreshRendererGeometry(ptyId, cols, rows) } @@ -8500,6 +8741,15 @@ export class OrcaRuntimeService { } } + private shouldDelayPtyBackedMobileSnapshotForForegroundAgent( + pty: RuntimePtyWorktreeRecord, + title: string + ): boolean { + return ( + !pty.launchAgent && pty.foregroundAgent === null && hasCompatibleAgentTitleIdentity(title) + ) + } + /** * Schedules an asynchronous query to check which agent process is currently * running in the foreground of a PTY. @@ -8508,19 +8758,75 @@ export class OrcaRuntimeService { void this.refreshPtyForegroundAgentFromController(ptyId) } + private getPendingForegroundAgentRefreshForTitle( + ptyId: string, + titleObservedAt: number + ): Promise | undefined { + if (!this.ptyForegroundAgentRefreshes.has(ptyId)) { + return undefined + } + return this.refreshPtyForegroundAgentFromController(ptyId, { + afterTitleObservation: titleObservedAt + }) + } + + private delayPtyBackedMobileSnapshotForForegroundAgent( + ptyId: string, + titleObservedAt: number, + foregroundRefresh: Promise + ): void { + this.ptyDelayedForegroundSnapshotTitleObservations.set(ptyId, titleObservedAt) + void foregroundRefresh.then((foregroundAgentChanged) => { + if (this.ptyDelayedForegroundSnapshotTitleObservations.get(ptyId) !== titleObservedAt) { + return + } + this.ptyDelayedForegroundSnapshotTitleObservations.delete(ptyId) + if (!foregroundAgentChanged) { + this.touchMobileSessionSnapshotsForPty(ptyId) + } + }) + } + /** * Deduplicates and manages in-flight foreground agent refresh queries * for a specific PTY. */ - private refreshPtyForegroundAgentFromController(ptyId: string): Promise { + private refreshPtyForegroundAgentFromController( + ptyId: string, + options: { afterTitleObservation?: number } = {} + ): Promise { + const startedAfterTitleObservation = options.afterTitleObservation ?? 0 const pendingRefresh = this.ptyForegroundAgentRefreshes.get(ptyId) if (pendingRefresh) { - return pendingRefresh + pendingRefresh.requestedAfterTitleObservation = Math.max( + pendingRefresh.requestedAfterTitleObservation, + startedAfterTitleObservation + ) + return pendingRefresh.promise } - const refresh = this.loadPtyForegroundAgentFromController(ptyId).finally(() => { - this.ptyForegroundAgentRefreshes.delete(ptyId) + const entry: PtyForegroundAgentRefresh = { + promise: Promise.resolve(false), + startedAfterTitleObservation, + requestedAfterTitleObservation: startedAfterTitleObservation + } + const refresh = (async (): Promise => { + while (true) { + entry.startedAfterTitleObservation = entry.requestedAfterTitleObservation + const foregroundAgentChanged = await this.loadPtyForegroundAgentFromController(ptyId) + if ( + foregroundAgentChanged || + entry.requestedAfterTitleObservation <= entry.startedAfterTitleObservation + ) { + return foregroundAgentChanged + } + } + })().finally(() => { + if (this.ptyForegroundAgentRefreshes.get(ptyId) === entry) { + this.ptyForegroundAgentRefreshes.delete(ptyId) + } }) - this.ptyForegroundAgentRefreshes.set(ptyId, refresh) + entry.promise = refresh + this.ptyForegroundAgentRefreshes.set(ptyId, entry) return refresh } @@ -8528,34 +8834,35 @@ export class OrcaRuntimeService { * Queries the PTY controller for the active foreground process, identifies if it * is a recognized agent, and updates the PTY's foreground agent state if changed. */ - private async loadPtyForegroundAgentFromController(ptyId: string): Promise { + private async loadPtyForegroundAgentFromController(ptyId: string): Promise { if (!this.ptyController) { - return + return false } const pty = this.ptysById.get(ptyId) if (!pty?.connected) { - return + return false } // Why: foregroundAgent is only consulted as the owner fallback when // launchAgent is unknown, so a known launchAgent makes the relay // getForegroundProcess round-trip pure waste (covers all launched agents). if (pty.launchAgent) { - return + return false } let foregroundProcess: string | null try { foregroundProcess = await this.ptyController.getForegroundProcess(ptyId) } catch { - return + return false } const foregroundAgent = foregroundProcess ? (recognizeAgentProcess(foregroundProcess)?.agent ?? null) : null if (pty.foregroundAgent === foregroundAgent) { - return + return false } pty.foregroundAgent = foregroundAgent this.touchMobileSessionSnapshotsForPty(ptyId) + return true } private getFreshExplicitAgentStatusForHandle(handle: string): { @@ -14713,13 +15020,9 @@ export class OrcaRuntimeService { ) if (!registeredWorktree) { let canCleanOrphanedDirectory = false - const knownOrcaLayouts = buildKnownOrcaWorkspaceLayouts(store.getSettings(), repo) if ( canCleanupUnregisteredOrcaWorktreeDirectory({ - meta: removedMeta, - worktreePath: removalTarget.path, - repo, - knownOrcaLayouts + meta: removedMeta }) ) { if (repo.connectionId) { @@ -14792,7 +15095,6 @@ export class OrcaRuntimeService { runtimeWorktreePath, repo, runtimeRepoPath: toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), - knownOrcaLayouts, registeredWorktrees, statPath: access.statPath, isGitRepository: (path) => isLocalRuntimeGitRepository(path, localWorktreeGitOptions) @@ -15137,6 +15439,7 @@ export class OrcaRuntimeService { opts: { command?: string claudeAgentTeamsSourceCommand?: string + cwd?: string env?: Record launchConfig?: WorktreeStartupLaunch['launchConfig'] launchToken?: string @@ -15180,6 +15483,7 @@ export class OrcaRuntimeService { throw new Error('runtime_unavailable') } const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector) + const cwd = resolveTerminalStartupCwd(workspace.path, opts.cwd) ?? workspace.path const preAllocatedHandle = this.createPreAllocatedTerminalHandle() // Why: mint tabId in main before spawn so paneKey is known at PTY env // build time. Hook-based agent status (Claude/Codex/Cursor/Gemini) keys @@ -15267,7 +15571,7 @@ export class OrcaRuntimeService { const result = await this.ptyController.spawn({ cols: 120, rows: 40, - cwd: workspace.path, + cwd, command: sequencedStartupCommand ? opts.command : (agentTeamsPlan?.command ?? opts.command), commandDelivery: 'provider', startupCommandDelivery: opts.startupCommandDelivery, @@ -15312,7 +15616,8 @@ export class OrcaRuntimeService { activate: presentation === 'focused', // Why: explicit background presentation may carry legacy activate // metadata from an already-owned renderer pane; don't select it on mobile. - selectIfNoActiveTab: presentation !== 'background' + selectIfNoActiveTab: presentation !== 'background', + ...(cwd !== workspace.path ? { startupCwd: cwd } : {}) }) } let surface: RuntimeTerminalCreate['surface'] = 'background' @@ -15326,6 +15631,7 @@ export class OrcaRuntimeService { await this.notifier.revealTerminalSession(workspace.id, { ptyId: result.id, title: opts.title ?? null, + ...(cwd !== workspace.path ? { cwd } : {}), ...(effectiveLaunchConfig ? { launchConfig: effectiveLaunchConfig } : {}), ...(launchToken ? { launchToken } : {}), ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), @@ -15358,9 +15664,11 @@ export class OrcaRuntimeService { const win = rendererWindow ?? this.getAuthoritativeWindow() // Why: mirrors browserTabCreate — when no worktree is specified, pass // undefined so the renderer uses its current active worktree. - const worktreeId = worktreeSelector - ? (await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector)).id - : undefined + const workspace = worktreeSelector + ? await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector) + : null + const worktreeId = workspace?.id + const cwd = workspace ? resolveTerminalStartupCwd(workspace.path, opts.cwd) : opts.cwd const requestId = randomUUID() // Why: terminal creation is a renderer-side Zustand store operation (like @@ -15392,6 +15700,7 @@ export class OrcaRuntimeService { requestId, worktreeId, command: opts.command, + cwd, ...(opts.env ? { env: opts.env } : {}), ...(opts.launchConfig ? { launchConfig: opts.launchConfig } : {}), ...(opts.launchToken ? { launchToken: opts.launchToken } : {}), @@ -15448,6 +15757,7 @@ export class OrcaRuntimeService { afterTabId?: string targetGroupId?: string command?: string + cwd?: string env?: Record startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] agent?: TuiAgent @@ -15488,6 +15798,7 @@ export class OrcaRuntimeService { afterTabId?: string targetGroupId?: string command?: string + cwd?: string env?: Record startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] agent?: TuiAgent @@ -15500,6 +15811,7 @@ export class OrcaRuntimeService { this.assertGraphReady() const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector) const worktreeId = workspace.id + const cwd = resolveTerminalStartupCwd(workspace.path, opts.cwd) this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) let afterDesktopTabId: string | undefined if (opts.afterTabId) { @@ -15518,13 +15830,15 @@ export class OrcaRuntimeService { worktreeId, opts.activate !== false, opts.afterTabId, - startupCommand.command, - startupCommand.env, - startupCommand.startupCommandDelivery, - undefined, - startupCommand.launchAgent, - opts.targetGroupId, - startupCommand.launchConfig + { + command: startupCommand.command, + cwd, + env: startupCommand.env, + startupCommandDelivery: startupCommand.startupCommandDelivery, + launchAgent: startupCommand.launchAgent, + targetGroupId: opts.targetGroupId, + launchConfig: startupCommand.launchConfig + } ) } const requestId = randomUUID() @@ -15556,6 +15870,7 @@ export class OrcaRuntimeService { afterTabId: afterDesktopTabId, targetGroupId: opts.targetGroupId, command: startupCommand.command, + cwd, ...(startupCommand.env ? { env: startupCommand.env } : {}), ...(startupCommand.launchConfig ? { launchConfig: startupCommand.launchConfig } : {}), ...(startupCommand.launchAgent ? { launchAgent: startupCommand.launchAgent } : {}), @@ -15593,13 +15908,16 @@ export class OrcaRuntimeService { worktreeId, opts.activate !== false, opts.afterTabId, - startupCommand.command, - startupCommand.env, - startupCommand.startupCommandDelivery, - { tabId: pendingSurface.tab.parentTabId, leafId: pendingSurface.tab.leafId }, - startupCommand.launchAgent, - opts.targetGroupId, - startupCommand.launchConfig + { + command: startupCommand.command, + cwd, + env: startupCommand.env, + startupCommandDelivery: startupCommand.startupCommandDelivery, + identity: { tabId: pendingSurface.tab.parentTabId, leafId: pendingSurface.tab.leafId }, + launchAgent: startupCommand.launchAgent, + targetGroupId: opts.targetGroupId, + launchConfig: startupCommand.launchConfig + } ) } catch (error) { // Why: the renderer created the tab but its terminal surface never @@ -15685,30 +16003,35 @@ export class OrcaRuntimeService { worktreeId: string, activate: boolean, afterTabId?: string, - command?: string, - env?: Record, - startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'], - identity?: { tabId: string; leafId: string; sessionId?: string }, - launchAgent?: TuiAgent, - targetGroupId?: string, - launchConfig?: SleepingAgentLaunchConfig + opts: { + command?: string + cwd?: string + env?: Record + startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] + identity?: { tabId: string; leafId: string; sessionId?: string } + launchAgent?: TuiAgent + targetGroupId?: string + launchConfig?: SleepingAgentLaunchConfig + } = {} ): Promise { const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${worktreeId}`) + const cwd = resolveTerminalStartupCwd(workspace.path, opts.cwd) // Why: SshPtyProvider treats sessionId as a relay reattach request. Only // synthesize local serve ids; SSH fresh terminals must call pty.spawn. const stableSessionId = - identity?.sessionId ?? (workspace.connectionId ? undefined : `serve-${randomUUID()}`) + opts.identity?.sessionId ?? (workspace.connectionId ? undefined : `serve-${randomUUID()}`) const terminal = await this.createTerminal(`id:${worktreeId}`, { focus: false, - command, - env, - ...(launchConfig ? { launchConfig } : {}), - ...(launchAgent ? { launchAgent } : {}), - startupCommandDelivery, - ...(identity + command: opts.command, + cwd, + env: opts.env, + ...(opts.launchConfig ? { launchConfig: opts.launchConfig } : {}), + ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), + startupCommandDelivery: opts.startupCommandDelivery, + ...(opts.identity ? { - tabId: identity.tabId, - leafId: identity.leafId, + tabId: opts.identity.tabId, + leafId: opts.identity.leafId, ...(stableSessionId ? { sessionId: stableSessionId } : {}) } : stableSessionId @@ -15745,7 +16068,8 @@ export class OrcaRuntimeService { leafId, ptyId: livePty.pty.ptyId, title: terminal.title ?? livePty.pty.title ?? 'Terminal', - ...(launchAgent ? { launchAgent } : {}), + ...(cwd ? { startupCwd: cwd } : {}), + ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), parentLayout, isActive: activate } @@ -15771,8 +16095,8 @@ export class OrcaRuntimeService { // Why: activating the new tab also focuses its group, so when "+" targeted // a specific split group, make that group active too. activeGroupId: - activate && targetGroupId - ? targetGroupId + activate && opts.targetGroupId + ? opts.targetGroupId : (existing?.activeGroupId ?? this.getHeadlessMobileSessionGroupId(worktreeId)), activeTabId: activate ? tab.id : (existing?.activeTabId ?? null), activeTabType: activate ? 'terminal' : (existing?.activeTabType ?? null), @@ -15781,7 +16105,7 @@ export class OrcaRuntimeService { tabs, activate ? tab : null, existing?.tabGroups, - targetGroupId ? { tabId: parentTabId, groupId: targetGroupId } : undefined + opts.targetGroupId ? { tabId: parentTabId, groupId: opts.targetGroupId } : undefined ), // Why: keep the group split geometry when a new tab is created, otherwise // opening a terminal while split loses the groups' arrangement. @@ -17594,6 +17918,7 @@ export class OrcaRuntimeService { const sessions = sessionsResult.value const livePtyIds = new Set(sessions.map((session) => session.id)) for (const session of sessions) { + this.adoptControllerTerminalHandle(session.id, session.terminalHandle) const worktreeId = inferWorktreeIdFromPtyId(session.id) ?? findResolvedWorktreeIdForPath(resolvedWorktrees, session.cwd) @@ -17652,9 +17977,13 @@ export class OrcaRuntimeService { serveSimStateWatcher.unbindPty(ptyId) this.ptysById.delete(ptyId) this.recentPtyOutputById.delete(ptyId) + this.recentPtyPathCandidatesById.delete(ptyId) this.ptyOutputSequenceById.delete(ptyId) this.agentStatusOscProcessorsByPtyId.delete(ptyId) this.oscTitleScanTailByPtyId.delete(ptyId) + this.osc7ScanTailByPtyId.delete(ptyId) + this.terminalCwdByPtyId.delete(ptyId) + this.terminalFileUriHostnameByPtyId.delete(ptyId) this.clearAgentRowSnapshotsForPty(ptyId) const handle = this.handleByPtyId.get(ptyId) if (handle) { @@ -18207,6 +18536,7 @@ export class OrcaRuntimeService { ...(launchAgent ? { launchAgent } : {}), ...(agentStatus ?? this.buildPtyMobileAgentStatus(livePty ?? pty, tab, terminalHandle)), ...(tab.parentLayout ? { parentLayout: tab.parentLayout } : {}), + ...(tab.startupCwd ? { startupCwd: tab.startupCwd } : {}), ...(tab.color != null ? { color: tab.color } : {}), ...(tab.isPinned ? { isPinned: true } : {}), ...(tab.viewMode ? { viewMode: tab.viewMode } : {}), @@ -22111,6 +22441,271 @@ export function appendRecentPtyOutput(previous: string | undefined, data: string return `${previous ?? ''}${data}`.slice(-RECENT_PTY_OUTPUT_LIMIT) } +export function appendRecentPtyPathCandidates( + previous: string[] | undefined, + data: string +): string[] { + const next = previous ? previous.slice() : [] + for (const candidate of extractTerminalOutputPathCandidates(data)) { + if (Buffer.byteLength(candidate, 'utf8') > RECENT_PTY_PATH_CANDIDATE_MAX_BYTES) { + continue + } + next.push(candidate) + } + return pruneRecentPtyPathCandidates(next) +} + +export function recentTerminalPathCandidatesIncludePath( + recentCandidates: readonly string[], + pathText: string, + absolutePath: string +): boolean { + const candidates = new Set( + [ + pathText, + absolutePath, + ...wslTerminalOutputAliases(pathText), + ...wslTerminalOutputAliases(absolutePath) + ] + .map((candidate) => candidate.trim()) + .filter((candidate) => candidate.length > 0) + ) + for (const recent of recentCandidates) { + if (candidates.has(recent)) { + return true + } + } + return false +} + +function pruneRecentPtyPathCandidates(candidates: string[]): string[] { + const countBounded = + candidates.length > RECENT_PTY_PATH_CANDIDATE_LIMIT + ? candidates.slice(-RECENT_PTY_PATH_CANDIDATE_LIMIT) + : candidates + let totalBytes = 0 + let startIndex = countBounded.length + for (let index = countBounded.length - 1; index >= 0; index -= 1) { + const nextTotal = totalBytes + Buffer.byteLength(countBounded[index]!, 'utf8') + if (nextTotal > RECENT_PTY_PATH_CANDIDATE_TOTAL_BYTES) { + break + } + totalBytes = nextTotal + startIndex = index + } + return startIndex === 0 ? countBounded : countBounded.slice(startIndex) +} + +export function recentTerminalOutputIncludesPath( + recentOutput: string, + pathText: string, + absolutePath: string +): boolean { + const candidates = new Set( + [pathText, absolutePath] + .map((candidate) => candidate.trim()) + .filter((candidate) => candidate.length > 0) + ) + if (candidates.size === 0) { + return false + } + for (const candidate of candidates) { + if (outputContainsPathCandidate(recentOutput, candidate)) { + return true + } + } + const decodedOutput = decodeTerminalOutputPercentEscapes(recentOutput) + if (decodedOutput !== recentOutput) { + for (const candidate of candidates) { + if (outputContainsPathCandidate(decodedOutput, candidate)) { + return true + } + } + } + return false +} + +function outputContainsPathCandidate(output: string, candidate: string): boolean { + let start = output.indexOf(candidate) + while (start !== -1) { + const end = start + candidate.length + if (isPathCandidateStartBoundary(output, start) && isPathCandidateEndBoundary(output, end)) { + return true + } + start = output.indexOf(candidate, start + 1) + } + return false +} + +function isPathCandidateStartBoundary(output: string, start: number): boolean { + if (start === 0) { + return true + } + if (output.slice(0, start).endsWith('file://')) { + return true + } + if ( + /^[A-Za-z]:[\\/]/.test(output.slice(start)) && + /file:\/\/(?:localhost|127\.0\.0\.1|\[?::1\]?)?\/$/i.test(output.slice(0, start)) + ) { + return true + } + if (/file:\/\/(?:localhost|127\.0\.0\.1|\[?::1\]?)$/i.test(output.slice(0, start))) { + return true + } + return !isPathCandidateContinuationChar(output[start - 1]!) +} + +function isPathCandidateEndBoundary(output: string, end: number): boolean { + const next = output[end] + if (!next) { + return true + } + if (next === ':' && /^\d+(?::\d+)?(?:\D|$)/.test(output.slice(end + 1))) { + return true + } + return !isPathCandidateContinuationChar(next) +} + +function isPathCandidateContinuationChar(char: string): boolean { + return /[A-Za-z0-9._~/%+@\\()[\]-]/.test(char) +} + +function decodeTerminalOutputPercentEscapes(value: string): string { + return value.replace(/(?:%[0-9a-f]{2})+/gi, (match) => { + try { + return decodeURIComponent(match) + } catch { + return match + } + }) +} + +// Why: extraction runs on the PTY hot path for every chunk, and the extension +// regex backtracks quadratically on pathological separator runs. No candidate +// can cross a newline (the regex classes exclude \r\n), so scan per line and +// skip lines already too long to yield a storable candidate — +// appendRecentPtyPathCandidates drops oversized candidates anyway, and the raw +// recent-output buffer still covers provenance inside oversized lines. +function extractTerminalOutputPathCandidates(data: string): string[] { + const candidates: string[] = [] + const add = (value: string): void => { + const candidate = trimTerminalOutputPathCandidate(value) + if (candidate.length > 0) { + candidates.push(candidate) + const drivePath = normalizeTerminalOutputFileUriDrivePath(candidate) + if (drivePath) { + candidates.push(drivePath) + } + } + } + for (const line of data.split(/[\r\n]+/)) { + if (line.length === 0 || line.length > RECENT_PTY_PATH_CANDIDATE_MAX_BYTES) { + continue + } + collectTerminalOutputLinePathCandidates(line, add) + } + return candidates +} + +function collectTerminalOutputLinePathCandidates(line: string, add: (value: string) => void): void { + for (const match of line.matchAll(/file:\/\/([^/\s]*)(\/[^\s\x1b"'<>)]*)/gi)) { + const authority = match[1] ?? '' + const uriPath = match[2] + if (uriPath) { + const decoded = decodeTerminalOutputPercentEscapes(uriPath) + add(isTerminalOutputLoopbackAuthority(authority) ? decoded : `//${authority}${decoded}`) + } + } + for (const match of line.matchAll( + /(?:\/(?:tmp|private\/tmp)\/|[A-Za-z]:[\\/])[^\r\n\x1b"'<>]+/g + )) { + if (isInsideNonLocalFileUri(line, match.index)) { + continue + } + add(match[0]) + } + for (const match of line.matchAll( + /\/[^\r\n\x1b"'<>]*\.[A-Za-z0-9_+-]+(?:[#:\s][^\r\n\x1b"'<>]*)?/g + )) { + if (isInsideNonLocalFileUri(line, match.index)) { + continue + } + add(match[0]) + } +} + +function normalizeTerminalOutputFileUriDrivePath(candidate: string): string | null { + return /^\/[A-Za-z]:[\\/]/.test(candidate) ? candidate.slice(1) : null +} + +function trimTerminalOutputPathCandidate(value: string): string { + let candidate = value.trim().replace(/[),;.]+$/g, '') + if (Buffer.byteLength(candidate, 'utf8') > RECENT_PTY_PATH_CANDIDATE_MAX_BYTES) { + return '' + } + let selected: string | null = null + for (const match of candidate.matchAll( + /.+?\.[A-Za-z0-9_+-]+(?:#L\d+(?:C\d+)?|(?::\d+)?(?::\d+)?)?(?=\s+|$)/gi + )) { + const end = match.index + match[0].length + const text = candidate.slice(0, end) + if (countTerminalOutputPathStarts(text) > 1) { + continue + } + // Same rule as the tap parsers: a line-end extension token only extends + // the candidate when the added segment is path-like, so trailing prose + // ending in a filename is not swallowed into the candidate. + if ( + end < candidate.length || + selected === null || + /[\\/]/.test(candidate.slice(selected.length, end)) + ) { + selected = text + } + } + return trimTerminalOutputPathLocator(selected ?? candidate) +} + +function isTerminalOutputLoopbackAuthority(authority: string): boolean { + const normalized = authority.toLowerCase() + return ( + normalized === '' || + normalized === 'localhost' || + normalized === '127.0.0.1' || + normalized === '::1' || + normalized === '[::1]' + ) +} + +function isInsideNonLocalFileUri(output: string, pathStart: number): boolean { + const prefix = output.slice(0, pathStart) + const match = /file:\/\/([^/\s]*)$/i.exec(prefix) + return !!match && !isTerminalOutputLoopbackAuthority(match[1] ?? '') +} + +function countTerminalOutputPathStarts(value: string): number { + let count = 0 + for (const match of value.matchAll(/(?:^|\s)(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/])/g)) { + void match + count += 1 + } + return count +} + +function trimTerminalOutputPathLocator(value: string): string { + return value.replace(/#L\d+(?:C\d+)?$/i, '').replace(/:\d+(?::\d+)?$/, '') +} + +function wslTerminalOutputAliases(value: string): string[] { + const match = /^\\\\wsl(?:\.localhost|\$)\\[^\\]+(\\.*)$/i.exec(value) + if (!match) { + return [] + } + const linuxPath = match[1]!.replace(/\\/g, '/') + return linuxPath.startsWith('/') ? [linuxPath] : [`/${linuxPath}`] +} + export function buildPreview(lines: string[], partialLine: string): string { const previewLines: string[] = [] const collectVisibleLine = (line: string): void => { diff --git a/src/main/runtime/rpc/methods/files.test.ts b/src/main/runtime/rpc/methods/files.test.ts index 3293d62fafb..7aea0b7c78f 100644 --- a/src/main/runtime/rpc/methods/files.test.ts +++ b/src/main/runtime/rpc/methods/files.test.ts @@ -300,14 +300,17 @@ describe('file RPC methods', () => { makeRequest('files.resolveTerminalPath', { worktree: 'id:wt-1', pathText: '/repo/src/index.ts', - cwd: '/repo' + cwd: '/repo', + terminal: 'term-1' }) ) expect(runtime.resolveTerminalPath).toHaveBeenCalledWith( 'id:wt-1', '/repo/src/index.ts', - '/repo' + '/repo', + undefined, + 'term-1' ) expect(response).toMatchObject({ ok: true, @@ -315,6 +318,62 @@ describe('file RPC methods', () => { }) }) + it('reads a terminal artifact through a grant-scoped method', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + readTerminalArtifactFile: vi.fn().mockResolvedValue({ + worktree: 'wt-1', + relativePath: '/tmp/result.json', + content: '{}', + truncated: false, + byteLength: 2 + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('files.readTerminalArtifact', { + worktree: 'id:wt-1', + absolutePath: '/tmp/result.json', + grantId: 'grant-1' + }) + ) + + expect(runtime.readTerminalArtifactFile).toHaveBeenCalledWith( + 'id:wt-1', + 'grant-1', + '/tmp/result.json', + undefined + ) + expect(response).toMatchObject({ ok: true, result: { content: '{}' } }) + }) + + it('writes a terminal artifact through a grant-scoped method', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + writeTerminalArtifactFile: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('files.writeTerminalArtifact', { + worktree: 'id:wt-1', + absolutePath: '/tmp/result.json', + grantId: 'grant-1', + content: '{}' + }) + ) + + expect(runtime.writeTerminalArtifactFile).toHaveBeenCalledWith( + 'id:wt-1', + 'grant-1', + '/tmp/result.json', + '{}', + undefined + ) + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + }) + it('reads a preview file for a selected worktree', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/files.ts b/src/main/runtime/rpc/methods/files.ts index a7ac09478ce..37224f18ed5 100644 --- a/src/main/runtime/rpc/methods/files.ts +++ b/src/main/runtime/rpc/methods/files.ts @@ -31,6 +31,11 @@ const ResolveTerminalPath = WorktreeSelector.extend({ .unknown() .transform((v) => (typeof v === 'string' ? v : '')) .pipe(z.string().min(1, 'Missing path text')), + terminal: z + .unknown() + .transform((v) => (typeof v === 'string' && v.length > 0 ? v : null)) + .nullable() + .optional(), cwd: z .unknown() .transform((v) => (typeof v === 'string' && v.length > 0 ? v : null)) @@ -38,6 +43,23 @@ const ResolveTerminalPath = WorktreeSelector.extend({ .optional() }) +const TerminalArtifactFile = WorktreeSelector.extend({ + grantId: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing terminal artifact grant')), + absolutePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing terminal artifact path')) +}) + +const TerminalArtifactFileWrite = TerminalArtifactFile.extend({ + content: z + .unknown() + .refine((v): v is string => typeof v === 'string', { message: 'Missing file content' }) +}) + const FileOpenDiff = FileOpen.extend({ staged: z.boolean().optional() }) @@ -175,8 +197,48 @@ export const FILE_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'files.resolveTerminalPath', params: ResolveTerminalPath, - handler: async (params, { runtime }) => - runtime.resolveTerminalPath(params.worktree, params.pathText, params.cwd ?? null) + handler: async (params, { runtime, clientId }) => + runtime.resolveTerminalPath( + params.worktree, + params.pathText, + params.cwd ?? null, + clientId, + params.terminal ?? null + ) + }), + defineMethod({ + name: 'files.readTerminalArtifact', + params: TerminalArtifactFile, + handler: async (params, { runtime, clientId }) => + runtime.readTerminalArtifactFile( + params.worktree, + params.grantId, + params.absolutePath, + clientId + ) + }), + defineMethod({ + name: 'files.readTerminalArtifactPreview', + params: TerminalArtifactFile, + handler: async (params, { runtime, clientId }) => + runtime.readTerminalArtifactPreview( + params.worktree, + params.grantId, + params.absolutePath, + clientId + ) + }), + defineMethod({ + name: 'files.writeTerminalArtifact', + params: TerminalArtifactFileWrite, + handler: async (params, { runtime, clientId }) => + runtime.writeTerminalArtifactFile( + params.worktree, + params.grantId, + params.absolutePath, + params.content, + clientId + ) }), defineMethod({ name: 'files.readPreview', diff --git a/src/main/runtime/rpc/methods/session-tabs-schemas.ts b/src/main/runtime/rpc/methods/session-tabs-schemas.ts index 2c7c3183946..0c21af7de72 100644 --- a/src/main/runtime/rpc/methods/session-tabs-schemas.ts +++ b/src/main/runtime/rpc/methods/session-tabs-schemas.ts @@ -113,6 +113,7 @@ export const CreateTerminalTab = WorktreeTabSelector.extend({ afterTabId: z.string().optional(), targetGroupId: z.string().optional(), command: z.string().optional(), + cwd: z.string().min(1).optional(), env: z.record(z.string(), z.string()).optional(), startupCommandDelivery: z.enum(['fast', 'shell-ready']).optional(), launchConfig: sleepingAgentLaunchConfigSchema, diff --git a/src/main/runtime/rpc/methods/session-tabs.test.ts b/src/main/runtime/rpc/methods/session-tabs.test.ts index e6c369fa5e6..625959f6d64 100644 --- a/src/main/runtime/rpc/methods/session-tabs.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs.test.ts @@ -140,6 +140,7 @@ describe('session tab RPC methods', () => { worktree: 'id:wt-1', targetGroupId: 'group-left', command: 'zsh', + cwd: '/repo/packages/app', env: { CODEX_PROFILE: 'captured' }, launchToken: 'launch-token-123', launchConfig: { @@ -156,6 +157,7 @@ describe('session tab RPC methods', () => { afterTabId: undefined, targetGroupId: 'group-left', command: 'zsh', + cwd: '/repo/packages/app', env: { CODEX_PROFILE: 'captured' }, startupCommandDelivery: undefined, agent: undefined, diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index ebdcc2a7f2c..f2bf1641bda 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -46,6 +46,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ afterTabId: params.afterTabId, targetGroupId: params.targetGroupId, command: params.command, + cwd: params.cwd, ...(params.env ? { env: params.env } : {}), startupCommandDelivery: params.startupCommandDelivery, agent: params.agent, diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 364940bd5cd..7f12d75b056 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -26,15 +26,11 @@ import { } from '../../../../shared/terminal-input' import { measureClipboardTextByteLength } from '../../../../shared/clipboard-text' import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { + MOBILE_SNAPSHOT_BYTE_BUDGET, + MOBILE_SUBSCRIBE_SCROLLBACK_ROWS +} from '../../scrollback-limits' -// Why: when a mobile client subscribes the server resizes the PTY to phone -// dims and serializes the buffer. Sending only the visible screen meant -// users coming back to the app or switching terminals could no longer scroll -// up to see prior agent output. Include enough scrollback to keep typical -// agent runs (Claude Code chats, command output) reachable. The mobile -// WebView's xterm has a 5000-row buffer so this fits comfortably. -const MOBILE_SUBSCRIBE_SCROLLBACK_ROWS = 1000 -const MOBILE_SNAPSHOT_BYTE_BUDGET = 512 * 1024 const REQUESTED_SNAPSHOT_BYTE_BUDGET = 2 * 1024 * 1024 const TERMINAL_STREAM_CHUNK_BYTES = 48 * 1024 const TERMINAL_OUTPUT_FLUSH_MS = 5 @@ -54,6 +50,7 @@ type SnapshotFrameOptions = { displayMode?: string reason?: string seq?: number + cwd?: string | null truncated?: boolean truncatedByByteBudget?: boolean source?: 'headless' | 'renderer' @@ -65,6 +62,7 @@ type SerializedSnapshot = { cols: number rows: number seq?: number + cwd?: string | null source?: 'headless' | 'renderer' oscLinks?: TerminalOscLinkRange[] scrollbackRows: number @@ -101,24 +99,25 @@ type TerminalMultiplexStream = { type TerminalOutputChunk = { data: string bytes: number - meta?: { seq?: number; rawLength?: number } + meta?: TerminalOutputMeta } +type TerminalOutputMeta = { seq?: number; rawLength?: number; cwd?: string } + type TerminalOutputFrameChunk = { bytes: Uint8Array seq?: number } -function createTerminalOutputBatcher( - onFlush: (data: string, meta?: { seq?: number; rawLength?: number }) => void -): { - push: (data: string, meta?: { seq?: number; rawLength?: number }) => void +function createTerminalOutputBatcher(onFlush: (data: string, meta?: TerminalOutputMeta) => void): { + push: (data: string, meta?: TerminalOutputMeta) => void flush: () => void dispose: () => void } { let chunks: string[] = [] let bytes = 0 let lastSeq: number | undefined + let pendingCwd: string | undefined let timer: ReturnType | null = null const clearTimer = (): void => { @@ -135,18 +134,29 @@ function createTerminalOutputBatcher( return } const data = chunks.length === 1 ? chunks[0]! : chunks.join('') - const meta = typeof lastSeq === 'number' ? { seq: lastSeq, rawLength: data.length } : undefined + const meta = + typeof lastSeq === 'number' || pendingCwd !== undefined + ? { + ...(typeof lastSeq === 'number' ? { seq: lastSeq, rawLength: data.length } : {}), + ...(pendingCwd !== undefined ? { cwd: pendingCwd } : {}) + } + : undefined chunks = [] bytes = 0 lastSeq = undefined + pendingCwd = undefined onFlush(data, meta) } return { - push(data: string, meta?: { seq?: number; rawLength?: number }): void { + push(data: string, meta?: TerminalOutputMeta): void { if (!data) { return } + if (meta?.cwd !== undefined) { + flush() + pendingCwd = meta.cwd + } chunks.push(data) const remainingBudget = Math.max(1, TERMINAL_OUTPUT_BATCH_MAX_BYTES - bytes) const measurement = measureTerminalStreamByteLength(data, { @@ -180,7 +190,7 @@ function createTerminalOutputBatcher( function* iterateTerminalOutputFrameChunks( data: string, - meta?: { seq?: number; rawLength?: number } + meta?: TerminalOutputMeta ): Generator { if (!terminalStreamByteLengthExceeds(data, TERMINAL_STREAM_CHUNK_BYTES)) { yield { bytes: encodeTerminalStreamText(data), seq: meta?.seq } @@ -307,7 +317,7 @@ function isTerminalSendGuardNotWritable(error: unknown): boolean { function appendPendingMultiplexOutput( stream: TerminalMultiplexStream, data: string, - meta?: { seq?: number; rawLength?: number } + meta?: TerminalOutputMeta ): void { const remainingBudget = Math.max( 1, @@ -447,6 +457,7 @@ function sendSnapshotFrames( displayMode: options.displayMode, reason: options.reason, seq: options.seq, + cwd: options.cwd, source: options.source, oscLinks: options.oscLinks, truncated: options.truncated === true, @@ -527,6 +538,7 @@ async function sendMobileResizeRestream( reason: event.reason, seq: event.seq ?? serialized.seq, source: serialized.source, + cwd: serialized.cwd, oscLinks: serialized.oscLinks, truncated: false, truncatedByByteBudget: serialized.truncatedByByteBudget, @@ -1374,6 +1386,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ requestId, displayMode, seq: serialized?.seq, + cwd: serialized?.cwd, source: serialized?.source, oscLinks: serialized?.oscLinks, truncated: false, @@ -1443,6 +1456,14 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ lastResizeCols: undefined, resizeGeneration: 0, outputBatcher: createTerminalOutputBatcher((data, meta) => { + if (meta?.cwd !== undefined) { + sendFrame( + request.streamId, + TerminalStreamOpcode.Metadata, + encodeTerminalStreamJson({ cwd: meta.cwd }), + meta.seq + ) + } for (const chunk of iterateTerminalOutputFrameChunks(data, meta)) { sendFrame(request.streamId, TerminalStreamOpcode.Output, chunk.bytes, chunk.seq) } @@ -1504,11 +1525,28 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ }) } - const read = await runtime.readTerminal(request.terminal) - const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) + let read = await runtime.readTerminal(request.terminal) + let serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) if (closed || streams.get(request.streamId) !== stream) { return } + let initialOutputOverflowed = false + if (stream.pendingOutputOverflowed) { + stream.pendingOutput.splice(0) + stream.pendingOutputBytes = 0 + stream.pendingOutputOverflowed = false + read = await runtime.readTerminal(request.terminal) + serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) + if (closed || streams.get(request.streamId) !== stream) { + return + } + if (stream.pendingOutputOverflowed) { + initialOutputOverflowed = true + stream.pendingOutput.splice(0) + stream.pendingOutputBytes = 0 + stream.pendingOutputOverflowed = false + } + } const size = runtime.getTerminalSize(ptyId) const displayMode = runtime.getMobileDisplayMode(ptyId) const layoutSeq = runtime.getLayout(ptyId)?.seq @@ -1537,7 +1575,9 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ rows: serialized?.rows ?? size?.rows, displayMode, seq: layoutSeq, - truncated: serialized ? read.truncated : isTerminalReadPayloadIncomplete(read) + truncated: + initialOutputOverflowed || + (serialized ? read.truncated : isTerminalReadPayloadIncomplete(read)) }) sendSnapshotFrames((opcode, payload) => sendFrame(request.streamId, opcode, payload), { kind: 'scrollback', @@ -1545,7 +1585,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ rows: serialized?.rows ?? size?.rows ?? 24, displayMode, seq: snapshotFrameSeq, - truncated: serialized ? read.truncated : isTerminalReadPayloadIncomplete(read), + cwd: serialized?.cwd, + truncated: + initialOutputOverflowed || + (serialized ? read.truncated : isTerminalReadPayloadIncomplete(read)), truncatedByByteBudget: serialized?.truncatedByByteBudget, source: serialized?.source, oscLinks: serialized?.oscLinks, @@ -1555,10 +1598,13 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ // rewrapped to these cols via the initial snapshot replay. stream.lastResizeCols = serialized?.cols ?? size?.cols stream.buffering = false - for (const chunk of stream.pendingOutput.splice(0)) { - const uncoveredData = getOutputAfterSnapshotSeq(chunk, snapshotOutputSeq) - if (uncoveredData) { - stream.outputBatcher.push(uncoveredData, chunk.meta) + const pendingOutput = stream.pendingOutput.splice(0) + if (!initialOutputOverflowed) { + for (const chunk of pendingOutput) { + const uncoveredData = getOutputAfterSnapshotSeq(chunk, snapshotOutputSeq) + if (uncoveredData) { + stream.outputBatcher.push(uncoveredData, chunk.meta) + } } } stream.pendingOutputBytes = 0 @@ -1712,6 +1758,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ truncated: isTerminalReadPayloadIncomplete(read), serialized: serialized?.data, oscLinks: serialized?.oscLinks, + cwd: serialized?.cwd, cols: serialized?.cols ?? size?.cols, rows: serialized?.rows ?? size?.rows, displayMode, @@ -1768,6 +1815,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ let resizeGeneration = 0 const pendingOutput: TerminalOutputChunk[] = [] let pendingOutputBytes = 0 + let pendingOutputOverflowed = false let unsubscribeData = (): void => {} let unsubscribeResize = (): void => {} let unsubscribeFit = (): void => {} @@ -1814,6 +1862,13 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ sendBinary(encodeTerminalStreamFrame({ opcode, streamId, seq: frameSeq, payload })) } outputBatcher = createTerminalOutputBatcher((data, meta) => { + if (meta?.cwd !== undefined) { + sendFrame( + TerminalStreamOpcode.Metadata, + encodeTerminalStreamJson({ cwd: meta.cwd }), + meta.seq + ) + } for (const chunk of iterateTerminalOutputFrameChunks(data, meta)) { sendFrame(TerminalStreamOpcode.Output, chunk.bytes, chunk.seq) } @@ -1886,16 +1941,34 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ pendingOutputBytes += measurement.byteLength const trimmed = trimPendingOutputToBudget(pendingOutput, pendingOutputBytes) pendingOutputBytes = trimmed.bytes + pendingOutputOverflowed ||= trimmed.overflowed return } outputBatcher?.push(data, meta) }) - const read = await runtime.readTerminal(params.terminal) - const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) + let read = await runtime.readTerminal(params.terminal) + let serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) if (closed) { return } + let initialOutputOverflowed = false + if (pendingOutputOverflowed) { + pendingOutput.splice(0) + pendingOutputBytes = 0 + pendingOutputOverflowed = false + read = await runtime.readTerminal(params.terminal) + serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) + if (closed) { + return + } + if (pendingOutputOverflowed) { + initialOutputOverflowed = true + pendingOutput.splice(0) + pendingOutputBytes = 0 + pendingOutputOverflowed = false + } + } const size = runtime.getTerminalSize(ptyId) const displayMode = runtime.getMobileDisplayMode(ptyId) // Why: emit the current layout seq with the initial scrollback so @@ -1909,7 +1982,9 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ type: 'subscribed', streamId, lines: read.tail, - truncated: isTerminalReadPayloadIncomplete(read), + truncated: + initialOutputOverflowed || + (serialized ? read.truncated : isTerminalReadPayloadIncomplete(read)), cols: serialized?.cols ?? size?.cols, rows: serialized?.rows ?? size?.rows, displayMode, @@ -1921,7 +1996,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ rows: serialized?.rows ?? size?.rows ?? 24, displayMode, seq: snapshotFrameSeq, - truncated: serialized ? read.truncated : isTerminalReadPayloadIncomplete(read), + cwd: serialized?.cwd, + truncated: + initialOutputOverflowed || + (serialized ? read.truncated : isTerminalReadPayloadIncomplete(read)), truncatedByByteBudget: serialized?.truncatedByByteBudget, oscLinks: serialized?.oscLinks, data: serialized?.data ?? '' @@ -1939,10 +2017,13 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ // rewrapped to these cols via the initial snapshot replay. lastResizeCols = serialized?.cols ?? size?.cols buffering = false - for (const item of pendingOutput.splice(0)) { - const uncoveredData = getOutputAfterSnapshotSeq(item, snapshotOutputSeq) - if (uncoveredData) { - outputBatcher.push(uncoveredData, item.meta) + const bufferedOutput = pendingOutput.splice(0) + if (!initialOutputOverflowed) { + for (const item of bufferedOutput) { + const uncoveredData = getOutputAfterSnapshotSeq(item, snapshotOutputSeq) + if (uncoveredData) { + outputBatcher.push(uncoveredData, item.meta) + } } } pendingOutputBytes = 0 diff --git a/src/main/runtime/rpc/terminal-multiplex.test.ts b/src/main/runtime/rpc/terminal-multiplex.test.ts index a19507a4a38..5ceee818dbb 100644 --- a/src/main/runtime/rpc/terminal-multiplex.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex.test.ts @@ -1383,14 +1383,14 @@ describe('terminal multiplex RPC', () => { >() const cleanups = new Map void>() const dataListenerRef: { current?: (data: string) => void } = {} - let resolveSnapshot: (value: { data: string; cols: number; rows: number }) => void = () => {} + const snapshotResolves: ((value: { data: string; cols: number; rows: number }) => void)[] = [] const runtime = stubRuntime({ resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), serializeTerminalBuffer: vi.fn( () => new Promise<{ data: string; cols: number; rows: number }>((resolve) => { - resolveSnapshot = resolve + snapshotResolves.push(resolve) }) ), getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), @@ -1451,7 +1451,9 @@ describe('terminal multiplex RPC', () => { dataListenerRef.current?.(`${String(index).padStart(3, '0')}${'x'.repeat(1021)}`) } await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalled()) - resolveSnapshot({ data: '', cols: 120, rows: 40 }) + snapshotResolves.shift()?.({ data: '', cols: 120, rows: 40 }) + await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalledTimes(2)) + snapshotResolves.shift()?.({ data: '399', cols: 120, rows: 40 }) await vi.waitFor(() => expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) ) @@ -1463,8 +1465,14 @@ describe('terminal multiplex RPC', () => { .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) .join('') expect(output.length).toBeLessThanOrEqual(256 * 1024) + expect(output).toBe('') + const snapshotPayload = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) + .join('') + expect(snapshotPayload).toBe('399') expect(output).not.toContain('000') - expect(output).toContain('399') cleanups.get('terminal-multiplex:conn-buffered')?.() await dispatchPromise @@ -1484,14 +1492,14 @@ describe('terminal multiplex RPC', () => { >() const cleanups = new Map void>() const dataListenerRef: { current?: (data: string) => void } = {} - let resolveSnapshot: (value: { data: string; cols: number; rows: number }) => void = () => {} + const snapshotResolves: ((value: { data: string; cols: number; rows: number }) => void)[] = [] const runtime = stubRuntime({ resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), serializeTerminalBuffer: vi.fn( () => new Promise<{ data: string; cols: number; rows: number }>((resolve) => { - resolveSnapshot = resolve + snapshotResolves.push(resolve) }) ), getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), @@ -1552,7 +1560,9 @@ describe('terminal multiplex RPC', () => { dataListenerRef.current?.(`${String(index).padStart(3, '0')}${'界'.repeat(341)}`) } await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalled()) - resolveSnapshot({ data: '', cols: 120, rows: 40 }) + snapshotResolves.shift()?.({ data: '', cols: 120, rows: 40 }) + await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalledTimes(2)) + snapshotResolves.shift()?.({ data: '399', cols: 120, rows: 40 }) await vi.waitFor(() => expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) ) @@ -1564,8 +1574,14 @@ describe('terminal multiplex RPC', () => { .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) .join('') expect(new TextEncoder().encode(output).byteLength).toBeLessThanOrEqual(256 * 1024) + expect(output).toBe('') + const snapshotPayload = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) + .join('') + expect(snapshotPayload).toBe('399') expect(output).not.toContain('000') - expect(output).toContain('399') cleanups.get('terminal-multiplex:conn-buffered-multibyte')?.() await dispatchPromise diff --git a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts index 76aa0b34bb0..dfd185b33b4 100644 --- a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts @@ -317,6 +317,13 @@ describe('terminal subscribe buffering', () => { await vi.waitFor(() => expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) ) + const subscribed = messages + .map((msg) => JSON.parse(msg).result) + .find((result) => result?.type === 'subscribed') + expect(subscribed).toMatchObject({ + type: 'subscribed', + truncated: false + }) const snapshotStart = binaryFrames .map((frame) => decodeTerminalStreamFrame(frame)) .find((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotStart) @@ -338,14 +345,14 @@ describe('terminal subscribe buffering', () => { const binaryFrames: Uint8Array[] = [] const cleanups = new Map void>() const dataListenerRef: { current?: (data: string) => void } = {} - let resolveSnapshot: (value: { data: string; cols: number; rows: number }) => void = () => {} + const snapshotResolves: ((value: { data: string; cols: number; rows: number }) => void)[] = [] const runtime = stubRuntime({ resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), serializeTerminalBuffer: vi.fn( () => new Promise<{ data: string; cols: number; rows: number }>((resolve) => { - resolveSnapshot = resolve + snapshotResolves.push(resolve) }) ), getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), @@ -392,7 +399,9 @@ describe('terminal subscribe buffering', () => { const shiftCallCount = shiftSpy.mock.calls.length shiftSpy.mockRestore() await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalled()) - resolveSnapshot({ data: '', cols: 120, rows: 40 }) + snapshotResolves.shift()?.({ data: '', cols: 120, rows: 40 }) + await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalledTimes(2)) + snapshotResolves.shift()?.({ data: '399', cols: 120, rows: 40 }) await vi.waitFor(() => expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) ) @@ -404,8 +413,14 @@ describe('terminal subscribe buffering', () => { .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) .join('') expect(output.length).toBeLessThanOrEqual(256 * 1024) + expect(output).toBe('') + const snapshotPayload = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) + .join('') + expect(snapshotPayload).toBe('399') expect(output).not.toContain('000') - expect(output).toContain('399') expect(shiftCallCount).toBe(0) runtime.cleanupSubscription('terminal-1:desktop-1') diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 04eef4e18d8..59efa6abfa2 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -154,7 +154,10 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'files.read', 'files.readChunk', 'files.readPreview', + 'files.readTerminalArtifact', + 'files.readTerminalArtifactPreview', 'files.resolveTerminalPath', + 'files.writeTerminalArtifact', 'git.abortMerge', 'git.abortRebase', 'git.bulkStage', diff --git a/src/main/runtime/scrollback-limits.ts b/src/main/runtime/scrollback-limits.ts index 95308afcff8..19e8fd147d9 100644 --- a/src/main/runtime/scrollback-limits.ts +++ b/src/main/runtime/scrollback-limits.ts @@ -1,7 +1,8 @@ // Why: mobile subscribers hydrate the runtime headless emulator from the // desktop renderer's xterm buffer. The renderer holds a 50k-row scrollback; // shipping all of it across IPC + replaying through HeadlessEmulator is -// expensive and unnecessary for a phone screen. Cap at 1000 rows so the seed -// covers a meaningful amount of agent context while keeping the round-trip -// bounded. See docs/mobile-prefer-renderer-scrollback.md. +// expensive and unnecessary for a phone screen. Cap at 1000 rows and 512 KiB so +// the seed covers a meaningful amount of agent context while keeping the +// round-trip bounded. See docs/mobile-prefer-renderer-scrollback.md. export const MOBILE_SUBSCRIBE_SCROLLBACK_ROWS = 1000 +export const MOBILE_SNAPSHOT_BYTE_BUDGET = 512 * 1024 diff --git a/src/main/ssh/ssh-connection-utils.ts b/src/main/ssh/ssh-connection-utils.ts index aea238ef234..cc8269c1b48 100644 --- a/src/main/ssh/ssh-connection-utils.ts +++ b/src/main/ssh/ssh-connection-utils.ts @@ -100,6 +100,13 @@ export function wrapRemoteCommandForPosixShell(command: string): string { export type SshExecOptions = { wrapCommand?: boolean + signal?: AbortSignal +} + +export function createSshOperationAbortError(): Error & { name: string } { + const error = new Error('SSH operation was cancelled') as Error & { name: string } + error.name = 'AbortError' + return error } function cmdEscape(s: string): string { diff --git a/src/main/ssh/ssh-connection.test.ts b/src/main/ssh/ssh-connection.test.ts index ade239d56f6..f40d27077a8 100644 --- a/src/main/ssh/ssh-connection.test.ts +++ b/src/main/ssh/ssh-connection.test.ts @@ -302,6 +302,14 @@ describe('SshConnection', () => { expect(clientInstances[0].setNoDelay).toHaveBeenCalledWith(true) }) + it('allows concurrent exec commands for ssh2 transport', async () => { + const conn = new SshConnection(createTarget(), createCallbacks()) + await conn.connect() + + expect(conn.usesSystemSshTransport()).toBe(false) + expect(conn.canRunConcurrentExecCommands()).toBe(true) + }) + it('removes startup listeners after ssh2 connect succeeds', async () => { const conn = new SshConnection(createTarget(), createCallbacks()) @@ -762,8 +770,36 @@ describe('SshConnection', () => { ) }) + it('allows concurrent exec commands for system SSH with an Orca ControlMaster socket', async () => { + getOrcaControlSocketPathMock.mockReturnValue('/tmp/orca-ssh-501/live-socket') + vi.mocked(resolveWithSshG).mockResolvedValueOnce(createResolvedConfig()) + const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks()) + + await conn.connect() + + expect(conn.usesSystemSshTransport()).toBe(true) + expect(conn.canRunConcurrentExecCommands()).toBe(true) + }) + + it('keeps concurrent exec commands disabled for system SSH without a reusable socket', async () => { + getOrcaControlSocketPathMock.mockReturnValue(null) + vi.mocked(resolveWithSshG).mockResolvedValueOnce(createResolvedConfig()) + const conn = new SshConnection( + createTarget({ configHost: 'fdpass-host', systemSshConnectionReuse: false }), + createCallbacks() + ) + + await conn.connect() + + expect(conn.usesSystemSshTransport()).toBe(true) + expect(conn.canRunConcurrentExecCommands()).toBe(false) + }) + it('retries a failed system SSH probe without ControlMaster and disables mux for the session', async () => { - getOrcaControlSocketPathMock.mockReturnValue('/tmp/orca-ssh-501/stale-socket') + getOrcaControlSocketPathMock.mockImplementation( + (_target: SshTarget, options?: { disableControlMaster?: boolean }) => + options?.disableControlMaster ? null : '/tmp/orca-ssh-501/stale-socket' + ) spawnSystemSshCommandMock .mockImplementationOnce(() => createFailingSystemCommandChannel(255, 'mux client failed')) .mockImplementation(() => createSystemCommandChannel()) @@ -812,6 +848,7 @@ describe('SshConnection', () => { resolvedConfig: expect.objectContaining({ proxyUseFdpass: true }) }) ) + expect(conn.canRunConcurrentExecCommands()).toBe(false) }) it('uses system SSH transport for ProxyCommand targets before ssh2 auth', async () => { @@ -952,7 +989,10 @@ describe('SshConnection', () => { }) it('retries direct system SSH connections without ControlMaster after mux startup failure', async () => { - getOrcaControlSocketPathMock.mockReturnValue('/tmp/orca-ssh-501/stale-socket') + getOrcaControlSocketPathMock.mockImplementation( + (_target: SshTarget, options?: { disableControlMaster?: boolean }) => + options?.disableControlMaster ? null : '/tmp/orca-ssh-501/stale-socket' + ) spawnSystemSshMock .mockReturnValueOnce(createFailingSystemSshProcess(255)) .mockImplementation(() => createSystemSshProcess()) @@ -977,6 +1017,7 @@ describe('SshConnection', () => { resolvedConfig: expect.objectContaining({ proxyUseFdpass: true }) } ) + expect(conn.canRunConcurrentExecCommands()).toBe(false) }) it('kills delayed direct system SSH startup on disconnect and ignores late stdout', async () => { diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index bdc7543c5fc..528f03c4c58 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -30,6 +30,7 @@ import { resolveEffectiveProxy, spawnProxyCommand, wrapRemoteCommandForPosixShell, + createSshOperationAbortError, type SshExecOptions, type SshConnectionCallbacks } from './ssh-connection-utils' @@ -85,6 +86,16 @@ export class SshConnection { usesSystemSshTransport(): boolean { return this.useSystemSshTransport } + canRunConcurrentExecCommands(): boolean { + if (!this.useSystemSshTransport) { + return true + } + return ( + getOrcaControlSocketPath(this.target, { + ...this.getSystemSshBuildArgsOptions() + }) !== null + ) + } getTarget(): SshTarget { return { ...this.target } } @@ -107,6 +118,9 @@ export class SshConnection { } async exec(cmd: string, options?: SshExecOptions): Promise { + if (options?.signal?.aborted) { + throw createSshOperationAbortError() + } if (this.useSystemSshTransport) { if (this.disposed || this.state.status !== 'connected') { throw new Error('Not connected') @@ -121,7 +135,8 @@ export class SshConnection { return this.waitForSshCallback( 'SSH exec channel timed out', (callback) => client.exec(remoteCommand, callback), - (channel) => channel.close() + (channel) => channel.close(), + options?.signal ) } @@ -143,12 +158,26 @@ export class SshConnection { private waitForSshCallback( timeoutMessage: string, register: (callback: (error: Error | undefined, value: T) => void) => void, - cleanupLateValue?: (value: T) => void + cleanupLateValue?: (value: T) => void, + signal?: AbortSignal ): Promise { return new Promise((resolve, reject) => { let settled = false + const cleanup = (): void => { + clearTimeout(timer) + signal?.removeEventListener('abort', onAbort) + } + const onAbort = (): void => { + if (settled) { + return + } + settled = true + cleanup() + reject(createSshOperationAbortError()) + } const timer = setTimeout(() => { settled = true + signal?.removeEventListener('abort', onAbort) reject(new Error(timeoutMessage)) }, CONNECT_TIMEOUT_MS) const finish = (error: Error | undefined, value?: T): void => { @@ -166,13 +195,18 @@ export class SshConnection { return } settled = true - clearTimeout(timer) + cleanup() if (error) { reject(error) return } resolve(value as T) } + if (signal?.aborted) { + onAbort() + return + } + signal?.addEventListener('abort', onAbort, { once: true }) try { // Why: higher-level channel timers start only after ssh2 invokes its @@ -686,6 +720,9 @@ export class SshConnection { } private spawnTrackedSystemSshCommand(command: string, options?: SshExecOptions): ClientChannel { + if (options?.signal?.aborted) { + throw createSshOperationAbortError() + } const buildArgsOptions = this.getSystemSshBuildArgsOptions() const commandOptions = options === undefined && Object.keys(buildArgsOptions).length === 0 @@ -696,9 +733,14 @@ export class SshConnection { ? spawnSystemSshCommand(this.target, command) : spawnSystemSshCommand(this.target, command, commandOptions) this.systemCommandChannels.add(channel) + const onAbort = (): void => { + channel.close() + } const cleanup = (): void => { + options?.signal?.removeEventListener('abort', onAbort) this.systemCommandChannels.delete(channel) } + options?.signal?.addEventListener('abort', onAbort, { once: true }) channel.once('close', cleanup) channel.once('error', cleanup) return channel diff --git a/src/main/ssh/ssh-relay-cross-version-isolation.test.ts b/src/main/ssh/ssh-relay-cross-version-isolation.test.ts index e1f93d8ec6d..c426e616fed 100644 --- a/src/main/ssh/ssh-relay-cross-version-isolation.test.ts +++ b/src/main/ssh/ssh-relay-cross-version-isolation.test.ts @@ -50,6 +50,7 @@ import type { SshConnection } from './ssh-connection' function makeMockConnection(): SshConnection { return { + canRunConcurrentExecCommands: vi.fn().mockReturnValue(false), exec: vi.fn().mockResolvedValue({ on: vi.fn(), stderr: { on: vi.fn() }, diff --git a/src/main/ssh/ssh-relay-deploy-helpers.test.ts b/src/main/ssh/ssh-relay-deploy-helpers.test.ts index 4b4a3783ca8..8bd40901dae 100644 --- a/src/main/ssh/ssh-relay-deploy-helpers.test.ts +++ b/src/main/ssh/ssh-relay-deploy-helpers.test.ts @@ -217,6 +217,55 @@ describe('execCommand', () => { } }) + it('closes and rejects with AbortError when a command is aborted', async () => { + const channel = createMockChannel() + const controller = new AbortController() + const conn = { + exec: vi.fn().mockResolvedValue(channel) + } + const commandPromise = execCommand(conn as never, 'sleep 60', { + signal: controller.signal + }) + + await Promise.resolve() + controller.abort() + + await expect(commandPromise).rejects.toMatchObject({ name: 'AbortError' }) + expect(channel.close).toHaveBeenCalledOnce() + expect(channel.listenerCount('error')).toBe(0) + expect(channel.listenerCount('data')).toBe(0) + expect(channel.listenerCount('close')).toBe(0) + expect(channel.stderr.listenerCount('error')).toBe(0) + expect(channel.stderr.listenerCount('data')).toBe(0) + }) + + it('handles aborts that happen while the SSH exec channel is still opening', async () => { + const channel = createMockChannel() + const controller = new AbortController() + let resolveExec: (channel: ClientChannel) => void = () => {} + const conn = { + exec: vi.fn().mockReturnValue( + new Promise((resolve) => { + resolveExec = resolve + }) + ) + } + + const commandPromise = execCommand(conn as never, 'sleep 60', { + signal: controller.signal + }) + controller.abort() + resolveExec(channel) + + await expect(commandPromise).rejects.toMatchObject({ name: 'AbortError' }) + expect(channel.close).toHaveBeenCalledOnce() + expect(channel.listenerCount('error')).toBe(0) + expect(channel.listenerCount('data')).toBe(0) + expect(channel.listenerCount('close')).toBe(0) + expect(channel.stderr.listenerCount('error')).toBe(0) + expect(channel.stderr.listenerCount('data')).toBe(0) + }) + it('uses custom command timeouts without forwarding them to SSH exec', async () => { vi.useFakeTimers() try { diff --git a/src/main/ssh/ssh-relay-deploy-helpers.ts b/src/main/ssh/ssh-relay-deploy-helpers.ts index ecc7f27677c..1473e3233cd 100644 --- a/src/main/ssh/ssh-relay-deploy-helpers.ts +++ b/src/main/ssh/ssh-relay-deploy-helpers.ts @@ -1,6 +1,6 @@ import type { ClientChannel } from 'ssh2' import type { SshConnection } from './ssh-connection' -import type { SshExecOptions } from './ssh-connection-utils' +import { createSshOperationAbortError, type SshExecOptions } from './ssh-connection-utils' import { RELAY_SENTINEL, RELAY_SENTINEL_TIMEOUT_MS } from './relay-protocol' import type { MultiplexerTransport } from './ssh-channel-multiplexer' import { buildRelayVersionMismatchError } from './ssh-relay-handshake-mismatch' @@ -247,6 +247,10 @@ export async function execCommand( options?: ExecCommandOptions ): Promise { const { timeoutMs = EXEC_TIMEOUT_MS, ...execOptions } = options ?? {} + const signal = options?.signal + if (signal?.aborted) { + throw createSshOperationAbortError() + } const channel = await conn.exec(command, execOptions) return new Promise((resolve, reject) => { let stdout = '' @@ -255,6 +259,7 @@ export async function execCommand( const cleanup = (): void => { clearTimeout(timeout) + signal?.removeEventListener('abort', onAbort) channel.off('error', fail) channel.stderr.off('error', fail) channel.off('data', onStdoutData) @@ -272,6 +277,10 @@ export async function execCommand( const fail = (err: Error): void => { settle(reject, err) } + const onAbort = (): void => { + channel.close() + settle(reject, createSshOperationAbortError()) + } const onStdoutData = (data: Buffer): void => { stdout += data.toString('utf-8') } @@ -293,10 +302,14 @@ export async function execCommand( // Why: remote reboot tears down exec channels with stream errors. Without // scoped listeners, Node treats those as uncaught exceptions. + signal?.addEventListener('abort', onAbort, { once: true }) channel.on('error', fail) channel.stderr.on('error', fail) channel.on('data', onStdoutData) channel.stderr.on('data', onStderrData) channel.on('close', onClose) + if (signal?.aborted) { + onAbort() + } }) } diff --git a/src/main/ssh/ssh-relay-deploy.test.ts b/src/main/ssh/ssh-relay-deploy.test.ts index eddb0e9f0ba..ec825cdc4b6 100644 --- a/src/main/ssh/ssh-relay-deploy.test.ts +++ b/src/main/ssh/ssh-relay-deploy.test.ts @@ -69,7 +69,9 @@ vi.mock('./ssh-connection-utils', () => ({ import { deployAndLaunchRelay } from './ssh-relay-deploy' import { execCommand, waitForSentinel } from './ssh-relay-deploy-helpers' import { resolveRemoteNodePath } from './ssh-remote-node-resolution' +import { isRelayAlreadyInstalled } from './ssh-relay-versioned-install' import type { SshConnection } from './ssh-connection' +import type * as SshRemoteNodeResolution from './ssh-remote-node-resolution' import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS, MAX_SSH_RELAY_GRACE_PERIOD_SECONDS @@ -90,6 +92,7 @@ function extractWindowsMarkerPath(script: string): string { function makeMockConnection(): SshConnection { return { + canRunConcurrentExecCommands: vi.fn().mockReturnValue(true), exec: vi.fn().mockResolvedValue({ on: vi.fn(), stderr: { on: vi.fn() }, @@ -161,6 +164,259 @@ describe('deployAndLaunchRelay', () => { expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1) }) + it('resolves node concurrently with remote home, not after the install-state chain', async () => { + const conn = makeMockConnection() + const mockExecCommand = vi.mocked(execCommand) + mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + + let markNodeResolutionStarted: () => void = () => {} + const nodeResolutionStarted = new Promise((resolve) => { + markNodeResolutionStarted = resolve + }) + vi.mocked(resolveRemoteNodePath).mockImplementationOnce(() => { + markNodeResolutionStarted() + return Promise.resolve('/usr/bin/node') + }) + + // Hold the first install-state step open. The optimization starts the node + // branch before the remote-home -> install-check chain finishes. + let releaseRemoteHome: (home: string) => void = () => {} + mockExecCommand.mockReturnValueOnce( + new Promise((resolve) => { + releaseRemoteHome = resolve + }) + ) + + const deployPromise = deployAndLaunchRelay(conn) + let assertionError: unknown + let deployError: unknown + try { + await nodeResolutionStarted + + expect(isRelayAlreadyInstalled).not.toHaveBeenCalled() + expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1) + } catch (err) { + assertionError = err + } finally { + // Drain the rest of the happy path so a failed assertion does not leave + // the deploy promise pending until its 300s timeout. + mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe + mockExecCommand.mockResolvedValueOnce('DEAD') // socket probe + mockExecCommand.mockResolvedValueOnce('READY') // socket poll + releaseRemoteHome('/home/user') + deployError = await deployPromise.then( + () => undefined, + (err: unknown) => err + ) + } + if (assertionError) { + throw assertionError + } + if (deployError) { + throw deployError + } + }) + + it('keeps bootstrap sequential when the connection cannot run concurrent exec commands', async () => { + const conn = makeMockConnection() + vi.mocked(conn.canRunConcurrentExecCommands).mockReturnValue(false) + const mockExecCommand = vi.mocked(execCommand) + mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + let releaseRemoteHome: (home: string) => void = () => {} + let remoteHomeProbeStarted: () => void = () => {} + const remoteHomeProbeStartedPromise = new Promise((resolve) => { + remoteHomeProbeStarted = resolve + }) + mockExecCommand.mockReturnValueOnce( + new Promise((resolve) => { + remoteHomeProbeStarted() + releaseRemoteHome = resolve + }) + ) + + const deployPromise = deployAndLaunchRelay(conn) + await remoteHomeProbeStartedPromise + expect(resolveRemoteNodePath).not.toHaveBeenCalled() + + mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe + mockExecCommand.mockResolvedValueOnce('DEAD') // socket probe + mockExecCommand.mockResolvedValueOnce('READY') // socket poll + releaseRemoteHome('/home/user') + await deployPromise + expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1) + }) + + it('falls back to sequential bootstrap when concurrent SSH sessions are refused', async () => { + const conn = makeMockConnection() + const mockExecCommand = vi.mocked(execCommand) + const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { + reason: 4 + }) + const { resolveRemoteNodePath: resolveRemoteNodePathActual } = await vi.importActual< + typeof SshRemoteNodeResolution + >('./ssh-remote-node-resolution') + let fallbackInstallStateCompleted = false + vi.mocked(resolveRemoteNodePath) + .mockImplementationOnce(resolveRemoteNodePathActual) + .mockImplementationOnce(() => { + if (!fallbackInstallStateCompleted) { + throw new Error('Sequential fallback resolved node before install state finished') + } + return Promise.resolve('/usr/bin/node') + }) + vi.mocked(isRelayAlreadyInstalled) + .mockImplementationOnce(async (_conn, _dir, _host, options) => { + expect(options?.rethrowSessionLimitErrors).toBe(true) + return true + }) + .mockImplementationOnce(async (_conn, _dir, _host, options) => { + expect(options?.rethrowSessionLimitErrors).toBeUndefined() + fallbackInstallStateCompleted = true + return true + }) + mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + mockExecCommand.mockResolvedValueOnce('/home/user') // concurrent install-state $HOME + mockExecCommand.mockRejectedValueOnce(sessionLimitError) // concurrent node path probe + mockExecCommand.mockResolvedValueOnce('/home/user') // sequential fallback $HOME + mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe + mockExecCommand.mockResolvedValueOnce('DEAD') // socket probe + mockExecCommand.mockResolvedValueOnce('READY') // socket poll + + await deployAndLaunchRelay(conn) + + expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(2) + expect(resolveRemoteNodePath).toHaveBeenCalledTimes(2) + }) + + it('falls back to sequential bootstrap when the install-state probe hits a session limit', async () => { + const conn = makeMockConnection() + const mockExecCommand = vi.mocked(execCommand) + const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { + reason: 4 + }) + vi.mocked(isRelayAlreadyInstalled) + .mockImplementationOnce(async (_conn, _dir, _host, options) => { + if (!options?.rethrowSessionLimitErrors) { + return true + } + throw sessionLimitError + }) + .mockResolvedValueOnce(true) + mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + mockExecCommand.mockResolvedValueOnce('/home/user') // concurrent install-state $HOME + mockExecCommand.mockResolvedValueOnce('/home/user') // sequential fallback $HOME + mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe + mockExecCommand.mockResolvedValueOnce('DEAD') // socket probe + mockExecCommand.mockResolvedValueOnce('READY') // socket poll + + await deployAndLaunchRelay(conn) + + expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(2) + expect(vi.mocked(isRelayAlreadyInstalled).mock.calls[0]?.[3]).toMatchObject({ + rethrowSessionLimitErrors: true + }) + expect(vi.mocked(isRelayAlreadyInstalled).mock.calls[1]?.[3]).toBeUndefined() + expect(resolveRemoteNodePath).toHaveBeenCalledTimes(2) + }) + + it('does not retry bootstrap for non-session failures', async () => { + const conn = makeMockConnection() + const mockExecCommand = vi.mocked(execCommand) + const nodeError = new Error('Node.js not found on remote host') + vi.mocked(resolveRemoteNodePath).mockRejectedValueOnce(nodeError) + mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + mockExecCommand.mockResolvedValueOnce('/home/user') // concurrent install-state $HOME + + await expect(deployAndLaunchRelay(conn)).rejects.toBe(nodeError) + expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(1) + expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1) + }) + + it('aborts a pending sibling probe and preserves a non-session install-state failure', async () => { + const conn = makeMockConnection() + const mockExecCommand = vi.mocked(execCommand) + let nodeProbeAborted = false + vi.mocked(resolveRemoteNodePath).mockImplementationOnce((_conn, _host, options) => { + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener('abort', () => { + nodeProbeAborted = true + const abortError = new Error('aborted') + abortError.name = 'AbortError' + reject(abortError) + }) + }) + }) + mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + mockExecCommand.mockResolvedValueOnce('relative-home') // invalid install-state $HOME + + const timedDeploy = Promise.race([ + deployAndLaunchRelay(conn), + new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error('deploy did not fail promptly')), 100) + }) + ]) + + await expect(timedDeploy).rejects.toThrow(/Remote home is not a valid path/) + expect(nodeProbeAborted).toBe(true) + expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1) + }) + + it('does not retry when a session-limit failure races with a real install-state failure', async () => { + const conn = makeMockConnection() + const mockExecCommand = vi.mocked(execCommand) + const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { + reason: 4 + }) + const installError = new Error('permission denied while checking relay install') + vi.mocked(resolveRemoteNodePath).mockRejectedValueOnce(sessionLimitError) + vi.mocked(isRelayAlreadyInstalled).mockRejectedValueOnce(installError) + mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + mockExecCommand.mockResolvedValueOnce('/home/user') // concurrent install-state $HOME + + await expect(deployAndLaunchRelay(conn)).rejects.toBe(installError) + expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(1) + expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1) + }) + + it('does not retry until the surviving first-attempt probe settles', async () => { + const conn = makeMockConnection() + const mockExecCommand = vi.mocked(execCommand) + const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { + reason: 4 + }) + mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + let releaseRemoteHome: (home: string) => void = () => {} + let remoteHomeSettled = false + mockExecCommand.mockReturnValueOnce( + new Promise((resolve) => { + releaseRemoteHome = (home: string) => { + remoteHomeSettled = true + resolve(home) + } + }) + ) + vi.mocked(resolveRemoteNodePath).mockImplementationOnce(() => Promise.reject(sessionLimitError)) + vi.mocked(resolveRemoteNodePath).mockImplementationOnce(() => { + if (!remoteHomeSettled) { + throw new Error('Sequential fallback started before first install-state probe settled') + } + return Promise.resolve('/usr/bin/node') + }) + + const deployPromise = deployAndLaunchRelay(conn) + await vi.waitFor(() => expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1)) + await new Promise((resolve) => setImmediate(resolve)) + expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1) + + mockExecCommand.mockResolvedValueOnce('/home/user') // sequential fallback $HOME + mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe + mockExecCommand.mockResolvedValueOnce('DEAD') // socket probe + mockExecCommand.mockResolvedValueOnce('READY') // socket poll + releaseRemoteHome('/home/user') + await deployPromise + expect(resolveRemoteNodePath).toHaveBeenCalledTimes(2) + }) + it('defaults fresh relays to keep-alive-until-reset', async () => { const conn = makeMockConnection() const mockExecCommand = vi.mocked(execCommand) diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index bee9f63de8b..78e7dec4443 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -42,6 +42,7 @@ import { import { detectRemoteHostPlatform } from './ssh-remote-platform-detection' import { powerShellCommand, powerShellLiteral, powerShellNativeArg } from './ssh-remote-powershell' import { relaySocketNameForInstanceId } from './ssh-relay-instance-id' +import { isSshSessionLimitError } from './ssh-session-limit-error' import { isWindowsRelayPipePath, relayEndpointForHost, @@ -80,11 +81,12 @@ function execHostCommand( conn: SshConnection, hostPlatform: RemoteHostPlatform, command: string, - options?: { timeoutMs?: number } + options?: { timeoutMs?: number; signal?: AbortSignal } ): Promise { return execCommand(conn, command, { wrapCommand: !isWindowsRemoteHost(hostPlatform), - timeoutMs: options?.timeoutMs + timeoutMs: options?.timeoutMs, + signal: options?.signal }) } @@ -122,6 +124,119 @@ export async function deployAndLaunchRelay( } } +/** + * Resolve the remote home, derive the versioned relay directory, and check + * whether the relay is already installed there. + * + * Why: extracted so the deploy can run this chain concurrently with node-path + * resolution (the two are independent). Home and install-check stay sequential + * here because the install-check needs the resolved directory. + */ +async function resolveRemoteInstallState( + conn: SshConnection, + hostPlatform: RemoteHostPlatform, + fullVersion: string, + options?: { rethrowSessionLimitErrors?: boolean; signal?: AbortSignal } +): Promise<{ remoteHome: string; remoteRelayDir: string; alreadyInstalled: boolean }> { + // Why: SFTP does not expand `~`, so we must resolve the remote home + // explicitly with the host's native shell and normalize it before use. + const remoteHome = normalizeRemoteHome( + await execHostCommand(conn, hostPlatform, readRemoteHomeCommand(hostPlatform), { + signal: options?.signal + }), + hostPlatform + ) + // Why: we only interpolate $HOME into single-quoted shell strings later, so + // this validation only needs to reject obviously unsafe control characters. + // Allow spaces and non-ASCII so valid home directories are not rejected. + if (!validateRemoteHome(remoteHome, hostPlatform)) { + throw new Error(`Remote home is not a valid path: ${remoteHome.slice(0, 100)}`) + } + const remoteRelayDir = computeRemoteRelayDir(remoteHome, fullVersion, hostPlatform.pathFlavor) + const probeOptions = + options?.rethrowSessionLimitErrors || options?.signal + ? { + rethrowSessionLimitErrors: options.rethrowSessionLimitErrors, + signal: options.signal + } + : undefined + const alreadyInstalled = await isRelayAlreadyInstalled( + conn, + remoteRelayDir, + hostPlatform, + probeOptions + ) + return { remoteHome, remoteRelayDir, alreadyInstalled } +} + +type RelayBootstrapState = { + remoteHome: string + remoteRelayDir: string + alreadyInstalled: boolean + nodePath: string +} + +async function resolveRelayBootstrapStateSequentially( + conn: SshConnection, + hostPlatform: RemoteHostPlatform, + fullVersion: string +): Promise { + const installState = await resolveRemoteInstallState(conn, hostPlatform, fullVersion) + const nodePath = await resolveRemoteNodePath(conn, hostPlatform) + return { ...installState, nodePath } +} + +async function resolveRelayBootstrapState( + conn: SshConnection, + hostPlatform: RemoteHostPlatform, + fullVersion: string +): Promise { + if (!conn.canRunConcurrentExecCommands()) { + return resolveRelayBootstrapStateSequentially(conn, hostPlatform, fullVersion) + } + const abortController = new AbortController() + const installStatePromise = resolveRemoteInstallState(conn, hostPlatform, fullVersion, { + rethrowSessionLimitErrors: true, + signal: abortController.signal + }) + const nodePathPromise = resolveRemoteNodePath(conn, hostPlatform, { + rethrowSessionLimitErrors: true, + signal: abortController.signal + }) + try { + const [installState, nodePath] = await Promise.all([installStatePromise, nodePathPromise]) + return { ...installState, nodePath } + } catch (err) { + abortController.abort() + const settled = await Promise.allSettled([installStatePromise, nodePathPromise]) + if (!isSshSessionLimitError(err)) { + throw err + } + const nonSessionFailure = settled.find( + (result) => + result.status === 'rejected' && + !isSshSessionLimitError(result.reason) && + !isAbortError(result.reason) + ) + if (nonSessionFailure?.status === 'rejected') { + throw nonSessionFailure.reason + } + console.warn( + '[ssh-relay] Concurrent bootstrap probes hit the remote SSH session limit; retrying sequentially.' + ) + return resolveRelayBootstrapStateSequentially(conn, hostPlatform, fullVersion) + } +} + +function isAbortError(err: unknown): boolean { + return err instanceof Error && err.name === 'AbortError' +} + +/** + * Detect the remote platform, resolve install state and node path, install the + * relay if it is not already present, then launch it and return the transport. + * Inner implementation wrapped by `deployAndLaunchRelay` with an overall timeout. + */ async function deployAndLaunchRelayInner( conn: SshConnection, onProgress?: (status: string) => void, @@ -152,25 +267,17 @@ async function deployAndLaunchRelayInner( // docs/ssh-relay-versioned-install-dirs.md "Data Flow: Upstream Error". const fullVersion = readLocalFullVersion(localRelayDir) - // Why: SFTP does not expand `~`, so we must resolve the remote home - // explicitly with the host's native shell and normalize it before use. - const remoteHome = normalizeRemoteHome( - await execHostCommand(conn, hostPlatform, readRemoteHomeCommand(hostPlatform)), - hostPlatform - ) - // Why: we only interpolate $HOME into single-quoted shell strings later, so - // this validation only needs to reject obviously unsafe control characters. - // Allow spaces and non-ASCII so valid home directories are not rejected. - if (!validateRemoteHome(remoteHome, hostPlatform)) { - throw new Error(`Remote home is not a valid path: ${remoteHome.slice(0, 100)}`) - } - const remoteRelayDir = computeRemoteRelayDir(remoteHome, fullVersion, hostPlatform.pathFlavor) - console.log(`[ssh-relay] Remote dir: ${remoteRelayDir}`) - onProgress?.('Checking existing relay...') - const alreadyInstalled = await isRelayAlreadyInstalled(conn, remoteRelayDir, hostPlatform) + // Why: the remote-home -> install-check chain and node resolution are + // independent (both only need hostPlatform, not each other's results), yet + // each is a separate SSH exec round trip. Run them concurrently so the deploy + // pays one round trip instead of two for this phase. Most failures stay + // fail-fast; remotes that reject overlapping session channels retry the old + // sequential order so restrictive SSH servers keep working. + const { remoteHome, remoteRelayDir, alreadyInstalled, nodePath } = + await resolveRelayBootstrapState(conn, hostPlatform, fullVersion) + console.log(`[ssh-relay] Remote dir: ${remoteRelayDir}`) console.log(`[ssh-relay] Already installed at ${fullVersion}: ${alreadyInstalled}`) - const nodePath = await resolveRemoteNodePath(conn, hostPlatform) if (alreadyInstalled) { await repairInstalledNativeDeps(conn, remoteRelayDir, platform, hostPlatform, nodePath) diff --git a/src/main/ssh/ssh-relay-native-deps-install.test.ts b/src/main/ssh/ssh-relay-native-deps-install.test.ts index b8bfcfeb7dc..0cedf1aff05 100644 --- a/src/main/ssh/ssh-relay-native-deps-install.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-install.test.ts @@ -109,6 +109,7 @@ function makeMockConnection(capture: SftpWriteCapture): SshConnection { end: vi.fn() }) return { + canRunConcurrentExecCommands: vi.fn().mockReturnValue(false), exec: vi.fn().mockResolvedValue({ on: vi.fn(), stderr: { on: vi.fn() }, diff --git a/src/main/ssh/ssh-relay-versioned-install.test.ts b/src/main/ssh/ssh-relay-versioned-install.test.ts index 29ea274c309..8cc46e8b151 100644 --- a/src/main/ssh/ssh-relay-versioned-install.test.ts +++ b/src/main/ssh/ssh-relay-versioned-install.test.ts @@ -88,6 +88,26 @@ describe('isRelayAlreadyInstalled', () => { expect(await isRelayAlreadyInstalled(conn, '/r')).toBe(false) }) + it('keeps default probe failures as not installed for SSH session-limit-shaped errors', async () => { + const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { + reason: 4 + }) + mockExec.mockRejectedValueOnce(sessionLimitError) + + await expect(isRelayAlreadyInstalled(conn, '/r')).resolves.toBe(false) + }) + + it('rethrows SSH session-limit errors in strict mode', async () => { + const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { + reason: 4 + }) + mockExec.mockRejectedValueOnce(sessionLimitError) + + await expect( + isRelayAlreadyInstalled(conn, '/r', undefined, { rethrowSessionLimitErrors: true }) + ).rejects.toBe(sessionLimitError) + }) + it('checks for relay.js AND .install-complete in addition to the dir', async () => { mockExec.mockResolvedValueOnce('OK') await isRelayAlreadyInstalled(conn, '/r') diff --git a/src/main/ssh/ssh-relay-versioned-install.ts b/src/main/ssh/ssh-relay-versioned-install.ts index ab3c88fb6ee..4074e31f673 100644 --- a/src/main/ssh/ssh-relay-versioned-install.ts +++ b/src/main/ssh/ssh-relay-versioned-install.ts @@ -36,6 +36,7 @@ import { type RemotePathFlavor } from './ssh-remote-platform' import { windowsRelayPipePathsForSocketName } from './ssh-relay-endpoints' +import { isSshSessionLimitError } from './ssh-session-limit-error' // Why: the GC pass and the version-dir parser must agree on what counts as a // relay install dir. Single source of truth for both. The pattern matches the @@ -64,12 +65,21 @@ const INSTALL_LOCK_TIMEOUT_MS = 120_000 const INSTALL_LOCK_STALE_MS = 120_000 const DEFAULT_REMOTE_HOST = getRemoteHostPlatform('linux-x64') +type RelayInstalledProbeOptions = { + rethrowSessionLimitErrors?: boolean + signal?: AbortSignal +} + function execHostCommand( conn: SshConnection, host: RemoteHostPlatform, - command: string + command: string, + options?: { signal?: AbortSignal } ): Promise { - return execCommand(conn, command, { wrapCommand: host.commandDialect !== 'powershell' }) + return execCommand(conn, command, { + wrapCommand: host.commandDialect !== 'powershell', + signal: options?.signal + }) } /** @@ -124,16 +134,21 @@ export function computeRemoteRelayDir( export async function isRelayAlreadyInstalled( conn: SshConnection, remoteRelayDir: string, - host: RemoteHostPlatform = DEFAULT_REMOTE_HOST + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, + options?: RelayInstalledProbeOptions ): Promise { try { const probe = await execHostCommand( conn, host, - probeRelayInstalledCommand(host, remoteRelayDir) + probeRelayInstalledCommand(host, remoteRelayDir), + { signal: options?.signal } ) return probe.trim() === 'OK' - } catch { + } catch (err) { + if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { + throw err + } return false } } diff --git a/src/main/ssh/ssh-remote-node-resolution.test.ts b/src/main/ssh/ssh-remote-node-resolution.test.ts index b007c8fd17c..23ea9097540 100644 --- a/src/main/ssh/ssh-remote-node-resolution.test.ts +++ b/src/main/ssh/ssh-remote-node-resolution.test.ts @@ -4,6 +4,7 @@ import os from 'node:os' import path from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { SshConnection } from './ssh-connection' +import { getRemoteHostPlatform } from './ssh-remote-platform' const execCommandMock = vi.hoisted(() => vi.fn()) @@ -270,6 +271,66 @@ describe('resolveRemoteNodePath', () => { await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/Node\.js not found/) }) + it('keeps the default path-probe fallback for SSH session-limit-shaped errors', async () => { + const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { + reason: 4 + }) + execCommandMock + .mockRejectedValueOnce(sessionLimitError) + .mockResolvedValueOnce('/bin/bash') + .mockResolvedValueOnce('\n') + + await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/Node\.js not found/) + }) + + it('rethrows SSH session-limit errors from the path probe in strict mode', async () => { + const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { + reason: 4 + }) + execCommandMock.mockRejectedValueOnce(sessionLimitError) + + await expect( + resolveRemoteNodePath(conn, undefined, { rethrowSessionLimitErrors: true }) + ).rejects.toBe(sessionLimitError) + }) + + it('rethrows SSH session-limit errors from candidate version checks in strict mode', async () => { + const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { + reason: 4 + }) + execCommandMock + .mockResolvedValueOnce('/usr/local/bin/node\n') + .mockRejectedValueOnce(sessionLimitError) + + await expect( + resolveRemoteNodePath(conn, undefined, { rethrowSessionLimitErrors: true }) + ).rejects.toBe(sessionLimitError) + }) + + it('rethrows SSH session-limit errors from login-shell resolution in strict mode', async () => { + const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { + reason: 4 + }) + execCommandMock.mockResolvedValueOnce('\n').mockRejectedValueOnce(sessionLimitError) + + await expect( + resolveRemoteNodePath(conn, undefined, { rethrowSessionLimitErrors: true }) + ).rejects.toBe(sessionLimitError) + }) + + it('rethrows SSH session-limit errors from Windows node resolution in strict mode', async () => { + const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { + reason: 4 + }) + execCommandMock.mockRejectedValueOnce(sessionLimitError) + + await expect( + resolveRemoteNodePath(conn, getRemoteHostPlatform('win32-x64'), { + rethrowSessionLimitErrors: true + }) + ).rejects.toBe(sessionLimitError) + }) + it('throws when every candidate across both strategies is below the minimum', async () => { execCommandMock .mockResolvedValueOnce('/old/node\n') // path probe diff --git a/src/main/ssh/ssh-remote-node-resolution.ts b/src/main/ssh/ssh-remote-node-resolution.ts index a5ec9b9fd05..4117ddf97ef 100644 --- a/src/main/ssh/ssh-remote-node-resolution.ts +++ b/src/main/ssh/ssh-remote-node-resolution.ts @@ -4,6 +4,7 @@ import type { RemoteHostPlatform } from './ssh-remote-platform' import { isWindowsRemoteHost, normalizeWindowsRemotePath } from './ssh-remote-platform' import { powerShellCommand } from './ssh-remote-powershell' import { execCommand } from './ssh-relay-deploy-helpers' +import { isSshSessionLimitError } from './ssh-session-limit-error' // Why: the relay requires Node.js 18+. Version managers like nvm keep every // installed version on disk, so a naive "highest version" glob can hand back @@ -15,12 +16,18 @@ const MIN_NODE_MAJOR = 18 // hang a login shell, so keep this short. const LOGIN_SHELL_PROBE_TIMEOUT_MS = 8_000 +type RemoteNodeResolutionOptions = { + rethrowSessionLimitErrors?: boolean + signal?: AbortSignal +} + export async function resolveRemoteNodePath( conn: SshConnection, - host?: RemoteHostPlatform + host?: RemoteHostPlatform, + options?: RemoteNodeResolutionOptions ): Promise { if (host && isWindowsRemoteHost(host)) { - return resolveRemoteWindowsNodePath(conn) + return resolveRemoteWindowsNodePath(conn, options) } // Strategy 1: probe well-known install directories for every common Node @@ -28,14 +35,14 @@ export async function resolveRemoteNodePath( // This doesn't depend on shell startup-file semantics — bash -lc skips // .bashrc and zsh -lc skips .zshrc, but those are exactly the files where // nvm/mise/asdf hooks live. Probing directories directly is deterministic. - const probedPath = await tryResolveViaKnownPaths(conn) + const probedPath = await tryResolveViaKnownPaths(conn, options) if (probedPath) { return probedPath } // Strategy 2 (fallback): ask the user's login shell. Catches custom PATH // setups in ~/.profile / ~/.bash_profile that the probes don't cover. - const loginShellPath = await tryResolveViaLoginShell(conn) + const loginShellPath = await tryResolveViaLoginShell(conn, options) if (loginShellPath) { return loginShellPath } @@ -47,7 +54,10 @@ export async function resolveRemoteNodePath( // plus system package-manager locations. Every probe runs unconditionally so // a missing directory prints nothing rather than short-circuiting later // probes. Returns the first candidate that meets the minimum version. -async function tryResolveViaKnownPaths(conn: SshConnection): Promise { +async function tryResolveViaKnownPaths( + conn: SshConnection, + options?: RemoteNodeResolutionOptions +): Promise { const script = ` command -v node 2>/dev/null nvm_dirs=\${NVM_DIR:-"$HOME/.nvm"} @@ -94,7 +104,7 @@ true ` try { - const result = await execCommand(conn, script) + const result = await execCommand(conn, script, { signal: options?.signal }) const seen = new Set() for (const line of result.split('\n')) { const candidate = line.trim() @@ -102,12 +112,15 @@ true continue } seen.add(candidate) - if (await nodeMeetsVersionRequirement(conn, candidate)) { + if (await nodeMeetsVersionRequirement(conn, candidate, options)) { console.log(`[ssh-relay] Found node via path probe: ${candidate}`) return candidate } } - } catch { + } catch (err) { + if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { + throw err + } // Fall through to login shell. } return null @@ -116,14 +129,18 @@ true // Run `command -v node` under the user's login shell, then verify the result // meets the minimum version. Returns null on any failure (shell missing, no // node found, version too old, timeout) so callers fall through to the error. -async function tryResolveViaLoginShell(conn: SshConnection): Promise { +async function tryResolveViaLoginShell( + conn: SshConnection, + options?: RemoteNodeResolutionOptions +): Promise { try { // Why: $SHELL is the user's configured login shell (set by chsh / passwd). // Using it — rather than hardcoding bash — means zsh/fish users whose // custom PATH hooks live in profile files get coverage too. We fall back // to sh if $SHELL is unset (rare, e.g. restricted accounts). const shellResult = await execCommand(conn, 'echo "${SHELL:-/bin/sh}"', { - timeoutMs: LOGIN_SHELL_PROBE_TIMEOUT_MS + timeoutMs: LOGIN_SHELL_PROBE_TIMEOUT_MS, + signal: options?.signal }) const shell = shellResult.trim().split('\n')[0] if (!shell) { @@ -132,18 +149,22 @@ async function tryResolveViaLoginShell(conn: SshConnection): Promise { try { const versionOutput = await execCommand(conn, `${shellEscape(nodePath)} --version`, { - wrapCommand: false + wrapCommand: false, + signal: options?.signal }) const match = versionOutput.trim().match(/^v?(\d+)/) if (!match) { @@ -174,13 +197,19 @@ async function nodeMeetsVersionRequirement( } const major = Number.parseInt(match[1]!, 10) return major >= MIN_NODE_MAJOR - } catch { + } catch (err) { + if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { + throw err + } // Binary missing or fails to run — not usable. return false } } -async function resolveRemoteWindowsNodePath(conn: SshConnection): Promise { +async function resolveRemoteWindowsNodePath( + conn: SshConnection, + options?: RemoteNodeResolutionOptions +): Promise { const script = [ '$paths = @()', '$cmd = Get-Command node.exe -ErrorAction SilentlyContinue', @@ -199,14 +228,20 @@ async function resolveRemoteWindowsNodePath(conn: SshConnection): Promise { + it('matches ssh2 open failures with the resource-shortage reason code', () => { + expect( + isSshSessionLimitError( + Object.assign(new Error('(SSH) Channel open failure: open failed'), { reason: 4 }) + ) + ).toBe(true) + }) + + it('matches OpenSSH mux and MaxSessions failures', () => { + expect( + isSshSessionLimitError( + new Error( + 'mux_client_request_session: session request failed: Session open refused by peer' + ) + ) + ).toBe(true) + expect( + isSshSessionLimitError(new Error('open failed: MaxSessions limit reached on remote host')) + ).toBe(true) + }) + + it('does not match generic channel-open failures without a session-limit reason', () => { + expect( + isSshSessionLimitError( + Object.assign(new Error('(SSH) Channel open failure: open failed'), { reason: 1 }) + ) + ).toBe(false) + expect( + isSshSessionLimitError( + Object.assign(new Error('(SSH) Channel open failure: open failed'), { reason: 2 }) + ) + ).toBe(false) + expect( + isSshSessionLimitError( + Object.assign(new Error('(SSH) Channel open failure: open failed'), { reason: 3 }) + ) + ).toBe(false) + }) + + it('does not match unrelated command failures', () => { + expect( + isSshSessionLimitError(new Error('Command "node" failed (exit 1): Node.js not found')) + ).toBe(false) + expect(isSshSessionLimitError(new Error('channel open failure while parsing output'))).toBe( + false + ) + expect( + isSshSessionLimitError( + new Error('open failed: administratively prohibited: forwarding disabled') + ) + ).toBe(false) + }) +}) diff --git a/src/main/ssh/ssh-session-limit-error.ts b/src/main/ssh/ssh-session-limit-error.ts new file mode 100644 index 00000000000..aed848525b8 --- /dev/null +++ b/src/main/ssh/ssh-session-limit-error.ts @@ -0,0 +1,19 @@ +export function isSshSessionLimitError(err: unknown): boolean { + if (!(err instanceof Error)) { + return false + } + const reason = (err as { reason?: unknown }).reason + const message = err.message.toLowerCase() + if ( + reason === 4 && + (message.includes('channel open failure') || message.includes('open failed')) + ) { + return true + } + return ( + message.includes('no free channels available') || + message.includes('maxsessions') || + message.includes('session open refused') || + (message.includes('mux_client_request_session') && message.includes('session request failed')) + ) +} diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index 323342c1805..35bc56c02a8 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -614,12 +614,13 @@ describe('attachMainWindowServices', () => { const notifier = runtime.setNotifier.mock.calls[0][0] as { revealTerminalSession: ( worktreeId: string, - opts: { ptyId: string; title?: string; activate?: boolean } + opts: { ptyId: string; title?: string; cwd?: string; activate?: boolean } ) => Promise<{ tabId: string; title?: string }> } const revealPromise = notifier.revealTerminalSession('wt-1', { ptyId: 'pty-1', - title: 'SSH tmux' + title: 'SSH tmux', + cwd: '/repo/packages/web' }) const sentPayload = sendMock.mock.calls.find( ([channel]) => channel === 'ui:createTerminal' @@ -627,6 +628,7 @@ describe('attachMainWindowServices', () => { const handler = onMock.mock.calls.find( ([channel]) => channel === 'terminal:tabCreateReply' )?.[1] + expect(sentPayload.cwd).toBe('/repo/packages/web') handler?.( { sender: { send: vi.fn() } }, diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 42924392358..904d99b8e2e 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -252,6 +252,7 @@ function registerRuntimeWindowLifecycle( send('ui:createTerminal', { worktreeId, command: opts.command, + ...(opts.cwd ? { cwd: opts.cwd } : {}), ...(opts.env ? { env: opts.env } : {}), title: opts.title, ...(opts.presentation ? { presentation: opts.presentation } : {}) @@ -286,6 +287,7 @@ function registerRuntimeWindowLifecycle( worktreeId, ptyId: opts.ptyId, title: opts.title ?? undefined, + ...(opts.cwd ? { cwd: opts.cwd } : {}), ...(opts.launchConfig ? { launchConfig: opts.launchConfig } : {}), ...(opts.launchToken ? { launchToken: opts.launchToken } : {}), ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), diff --git a/src/main/worktree-removal-authority.test.ts b/src/main/worktree-removal-authority.test.ts index acb8d2f3f3c..cbc9e2ed24c 100644 --- a/src/main/worktree-removal-authority.test.ts +++ b/src/main/worktree-removal-authority.test.ts @@ -4,6 +4,7 @@ import { isWorktreePathMissing, stripOrcaProvenanceMetaUpdates } from './worktree-removal-safety' +import type { WorktreeMeta } from '../shared/types' describe('isWorktreePathMissing', () => { it('recognizes missing-path errors from local and remote stat providers', async () => { @@ -33,10 +34,7 @@ describe('canCleanupUnregisteredOrcaWorktreeDirectory', () => { it('does not treat orcaCreatedAt alone as cleanup authority', () => { expect( canCleanupUnregisteredOrcaWorktreeDirectory({ - meta: { orcaCreatedAt: Date.now() }, - worktreePath: '/outside/orphan', - repo: { path: '/repo' }, - knownOrcaLayouts: [] + meta: { orcaCreatedAt: Date.now() } }) ).toBe(false) expect( @@ -44,10 +42,7 @@ describe('canCleanupUnregisteredOrcaWorktreeDirectory', () => { meta: { orcaCreatedAt: Date.now(), orcaCreationSource: 'runtime' - }, - worktreePath: '/outside/orphan', - repo: { path: '/repo' }, - knownOrcaLayouts: [] + } }) ).toBe(true) }) @@ -55,32 +50,40 @@ describe('canCleanupUnregisteredOrcaWorktreeDirectory', () => { it('accepts legacy Orca-created metadata before explicit provenance existed', () => { expect( canCleanupUnregisteredOrcaWorktreeDirectory({ - meta: { createdAt: Date.now() }, - worktreePath: '/outside/orphan', - repo: { path: '/repo' }, - knownOrcaLayouts: [] + meta: { createdAt: Date.now() } }) ).toBe(true) }) - it('accepts legacy repo-nested Orca workspace paths without metadata provenance', () => { + it('does not treat creation layout metadata alone as cleanup authority', () => { + const layoutOnlyMeta: WorktreeMeta = { + displayName: '', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + workspaceStatus: 'todo', + orcaCreationWorkspaceLayout: { path: '/orca/workspaces', nestWorkspaces: true } + } + expect( canCleanupUnregisteredOrcaWorktreeDirectory({ - meta: undefined, - worktreePath: '/orca/workspaces/app/legacy-orphan', - repo: { path: '/repos/app' }, - knownOrcaLayouts: [{ path: '/orca/workspaces', nestWorkspaces: true }] + meta: layoutOnlyMeta }) - ).toBe(true) + ).toBe(false) }) - it('does not trust flat workspace-root paths without legacy metadata', () => { + it('does not trust paths without provenance or legacy metadata', () => { expect( canCleanupUnregisteredOrcaWorktreeDirectory({ - meta: undefined, - worktreePath: '/orca/workspaces/legacy-orphan', - repo: { path: '/repos/app' }, - knownOrcaLayouts: [{ path: '/orca/workspaces', nestWorkspaces: false }] + meta: undefined }) ).toBe(false) }) diff --git a/src/main/worktree-removal-safety.test.ts b/src/main/worktree-removal-safety.test.ts index d008b952089..5a64b8c90e5 100644 --- a/src/main/worktree-removal-safety.test.ts +++ b/src/main/worktree-removal-safety.test.ts @@ -325,7 +325,6 @@ describe('canCleanupUnregisteredOrcaLeftoverDirectory', () => { runtimeWorktreePath: '/workspaces/orca-owned', repo, runtimeRepoPath: repo.path, - knownOrcaLayouts: [], registeredWorktrees: [makeGitWorktree(repo.path, true)] } @@ -376,7 +375,6 @@ describe('canCleanupUnregisteredOrcaLeftoverDirectory', () => { canCleanupUnregisteredOrcaLeftoverDirectory({ ...baseArgs, meta: undefined, - knownOrcaLayouts: [{ path: '/workspaces', nestWorkspaces: false }], statPath: makeStatPath([], ['/workspaces/orca-owned']), isGitRepository }) diff --git a/src/main/worktree-removal-safety.ts b/src/main/worktree-removal-safety.ts index a8236ea4908..a589bce9e10 100644 --- a/src/main/worktree-removal-safety.ts +++ b/src/main/worktree-removal-safety.ts @@ -2,8 +2,7 @@ import { lstat, readFile } from 'node:fs/promises' import { homedir } from 'node:os' import { posix, win32 } from 'node:path' import { isWindowsAbsolutePathLike } from '../shared/cross-platform-path' -import type { GitWorktreeInfo, OrcaWorkspaceLayout, Repo, WorktreeMeta } from '../shared/types' -import { matchesStrongOrcaCreatePath } from '../shared/worktree-ownership' +import type { GitWorktreeInfo, Repo, WorktreeMeta } from '../shared/types' import { areWorktreePathsEqual } from './ipc/worktree-logic' import { gitFileProvesOrphanedWorktreeDirectory, @@ -172,9 +171,6 @@ export async function canSafelyRemoveOrphanedWorktreeDirectory( export function canCleanupUnregisteredOrcaWorktreeDirectory(args: { meta: UnregisteredOrcaCleanupMeta | null | undefined - worktreePath: string - repo: Pick - knownOrcaLayouts: readonly OrcaWorkspaceLayout[] }): boolean { if (hasCurrentOrcaCreationProvenance(args.meta)) { return true @@ -184,9 +180,9 @@ export function canCleanupUnregisteredOrcaWorktreeDirectory(args: { return true } - // Why: profiles created before explicit provenance can still contain Orca - // workspaces at the repo-specific workspaceDir// path shape. - return matchesStrongOrcaCreatePath(args.worktreePath, args.knownOrcaLayouts, args.repo) + // Why: path shape alone is not authority; users can create plain Git + // worktrees inside Orca's workspace directory too. + return false } export async function canCleanupUnregisteredOrcaLeftoverDirectory(args: { @@ -195,7 +191,6 @@ export async function canCleanupUnregisteredOrcaLeftoverDirectory(args: { runtimeWorktreePath: string repo: Pick runtimeRepoPath: string - knownOrcaLayouts: readonly OrcaWorkspaceLayout[] registeredWorktrees: readonly GitWorktreeInfo[] statPath: StatPath isGitRepository: (runtimeWorktreePath: string) => Promise diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 3f31cc560e2..35bf793840a 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2587,6 +2587,7 @@ export type PreloadApi = { requestId?: string worktreeId: string command?: string + cwd?: string env?: Record launchConfig?: SleepingAgentLaunchConfig launchToken?: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 9a162aa5473..333e298eabe 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3210,6 +3210,7 @@ const api = { requestId?: string worktreeId: string command?: string + cwd?: string env?: Record launchConfig?: SleepingAgentLaunchConfig launchToken?: string @@ -3231,6 +3232,7 @@ const api = { requestId?: string worktreeId: string command?: string + cwd?: string env?: Record launchConfig?: SleepingAgentLaunchConfig launchToken?: string diff --git a/src/relay/fs-handler-terminal-artifact.ts b/src/relay/fs-handler-terminal-artifact.ts new file mode 100644 index 00000000000..02af753f134 --- /dev/null +++ b/src/relay/fs-handler-terminal-artifact.ts @@ -0,0 +1,251 @@ +import { constants } from 'node:fs' +import type { Stats } from 'node:fs' +import { chmod, open, realpath, rename, rm, writeFile } from 'node:fs/promises' +import type { FileHandle } from 'node:fs/promises' +import { basename, dirname, extname, join } from 'node:path' +import { randomUUID } from 'node:crypto' +import { + IMAGE_MIME_TYPES, + isBinaryBuffer, + MAX_PREVIEWABLE_BINARY_SIZE, + MAX_TEXT_FILE_SIZE +} from './fs-handler-utils' + +type TerminalArtifactStat = { + size: number + type: 'file' | 'directory' | 'symlink' + mtime: number + mtimeMs: number + mode?: number + dev?: number + ino?: number + nlink?: number +} + +type VerifiedTerminalArtifactOptions = { + expectedRealPath: string + expectedStatIdentity?: string | null + maxBytes?: number +} + +const OPEN_NOFOLLOW = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0 + +export async function readVerifiedTerminalArtifact(params: Record) { + const filePath = stringParam(params.filePath) + const options = verifiedTerminalArtifactOptions(params) + const handle = await openVerifiedTerminalArtifact(filePath, options, constants.O_RDONLY) + try { + await verifiedHandleStat(handle, options) + const mimeType = terminalArtifactImageMimeType(filePath) + const sizeLimit = Math.min( + options.maxBytes ?? (mimeType ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE), + mimeType ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE + ) + const buffer = await readBoundedFileFromHandle(handle, sizeLimit) + if (mimeType) { + return { content: buffer.toString('base64'), isBinary: true, isImage: true, mimeType } + } + if (isBinaryBuffer(buffer)) { + return { content: '', isBinary: true } + } + return { content: buffer.toString('utf-8'), isBinary: false } + } finally { + await handle.close() + } +} + +export async function writeVerifiedTerminalArtifact( + params: Record +): Promise<{ ok: true; stat: TerminalArtifactStat }> { + const filePath = stringParam(params.filePath) + const content = stringParam(params.content) + const options = verifiedTerminalArtifactOptions(params) + // Why: maxBytes is client-supplied; clamp before it sizes buffer allocations. + const writeLimit = Math.min(options.maxBytes ?? MAX_TEXT_FILE_SIZE, MAX_TEXT_FILE_SIZE) + if (Buffer.byteLength(content, 'utf8') > writeLimit) { + throw new Error('file_too_large') + } + const handle = await openVerifiedTerminalArtifact(filePath, options, constants.O_RDONLY) + let originalMode: number | null = null + try { + originalMode = (await verifiedHandleStat(handle, options)).mode ?? null + const existing = await readBoundedFileFromHandle(handle, writeLimit) + if (isBinaryBuffer(existing)) { + throw new Error('binary_file') + } + } finally { + await handle.close() + } + const tempPath = join(dirname(filePath), `.${basename(filePath)}.${randomUUID()}.tmp`) + try { + await writeFile(tempPath, content, { encoding: 'utf8', flag: 'wx' }) + if (typeof originalMode === 'number') { + await chmod(tempPath, originalMode & 0o7777) + } + const freshHandle = await openVerifiedTerminalArtifact(filePath, options, constants.O_RDONLY) + try { + await verifiedHandleStat(freshHandle, options) + } finally { + await freshHandle.close() + } + await rename(tempPath, filePath) + return { ok: true, stat: fileStatFromHandleStats(await openStatClose(filePath)) } + } finally { + await rm(tempPath, { force: true }).catch(() => {}) + } +} + +async function openStatClose(filePath: string): Promise { + const handle = await open(filePath, constants.O_RDONLY) + try { + return await handle.stat() + } finally { + await handle.close() + } +} + +function terminalArtifactImageMimeType(filePath: string): string | undefined { + const mimeType = IMAGE_MIME_TYPES[extname(filePath).toLowerCase()] + // Why: mobile renders SVG terminal artifacts as source text; returning image + // data from the relay would make SSH disagree with local artifact previews. + return mimeType === 'image/svg+xml' ? undefined : mimeType +} + +async function readBoundedFileFromHandle(handle: FileHandle, maxBytes: number): Promise { + const safeLimit = Math.max(0, Math.floor(maxBytes)) + const buffer = Buffer.alloc(safeLimit + 1) + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0) + if (bytesRead > safeLimit) { + throw new Error('file_too_large') + } + return buffer.subarray(0, bytesRead) +} + +async function openVerifiedTerminalArtifact( + filePath: string, + options: VerifiedTerminalArtifactOptions, + flags: number +): Promise { + await assertRealPathStillGranted(filePath, options.expectedRealPath) + try { + return await open(filePath, flags | OPEN_NOFOLLOW) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ELOOP') { + throw new Error('terminal_file_grant_stale') + } + throw error + } +} + +async function verifiedHandleStat( + handle: FileHandle, + options: VerifiedTerminalArtifactOptions +): Promise { + const stats = fileStatFromHandleStats(await handle.stat()) + if (stats.type !== 'file') { + throw new Error( + stats.type === 'directory' ? 'Cannot write to a directory' : 'terminal_file_grant_stale' + ) + } + assertTerminalArtifactNotHardLinked(stats) + assertTerminalArtifactStatIdentity(options.expectedStatIdentity ?? null, stats) + return stats +} + +async function assertRealPathStillGranted( + filePath: string, + expectedRealPath: string +): Promise { + // Why: terminal artifacts are user-writable temp paths; the relay must check + // canonicality in the same remote operation that opens the file handle. + if ((await realpath(filePath)) !== expectedRealPath) { + throw new Error('terminal_file_grant_stale') + } +} + +function fileStatFromHandleStats(stats: Stats): TerminalArtifactStat { + let type: TerminalArtifactStat['type'] = 'file' + if (stats.isDirectory()) { + type = 'directory' + } else if (stats.isSymbolicLink()) { + type = 'symlink' + } + return { + size: stats.size, + type, + mtime: stats.mtimeMs, + mtimeMs: stats.mtimeMs, + mode: stats.mode, + dev: stats.dev, + ino: stats.ino, + nlink: stats.nlink + } +} + +function terminalArtifactStatIdentity(stats: { + size?: number + dev?: number + ino?: number + nlink?: number + mtime?: number | Date + mtimeMs?: number +}): string | null { + const dev = typeof stats.dev === 'number' ? stats.dev : null + const ino = typeof stats.ino === 'number' ? stats.ino : null + const nlink = typeof stats.nlink === 'number' ? stats.nlink : null + const size = typeof stats.size === 'number' ? stats.size : null + const mtimeMs = + typeof stats.mtimeMs === 'number' + ? stats.mtimeMs + : typeof stats.mtime === 'number' + ? stats.mtime + : null + if (dev !== null && ino !== null && size !== null && mtimeMs !== null) { + return `${dev}:${ino}:${nlink ?? 'unknown'}:${size}:${mtimeMs}` + } + if (size !== null && mtimeMs !== null) { + return `${size}:${mtimeMs}` + } + return null +} + +function assertTerminalArtifactNotHardLinked(stats: TerminalArtifactStat): void { + if (typeof stats.nlink === 'number' && stats.nlink > 1) { + throw new Error('terminal_file_grant_stale') + } +} + +function assertTerminalArtifactStatIdentity( + expectedStatIdentity: string | null, + stats: TerminalArtifactStat +): void { + const nextIdentity = terminalArtifactStatIdentity(stats) + if ( + expectedStatIdentity !== null && + nextIdentity !== null && + expectedStatIdentity !== nextIdentity + ) { + throw new Error('terminal_file_grant_stale') + } +} + +function verifiedTerminalArtifactOptions( + params: Record +): VerifiedTerminalArtifactOptions { + return { + expectedRealPath: stringParam(params.expectedRealPath), + expectedStatIdentity: + typeof params.expectedStatIdentity === 'string' ? params.expectedStatIdentity : null, + maxBytes: + typeof params.maxBytes === 'number' && Number.isFinite(params.maxBytes) + ? Math.max(0, Math.floor(params.maxBytes)) + : undefined + } +} + +function stringParam(value: unknown): string { + if (typeof value !== 'string') { + throw new Error('invalid_terminal_artifact_request') + } + return value +} diff --git a/src/relay/fs-handler.test.ts b/src/relay/fs-handler.test.ts index 45cfae9af62..b90a786f7fd 100644 --- a/src/relay/fs-handler.test.ts +++ b/src/relay/fs-handler.test.ts @@ -2,6 +2,7 @@ Space scans, file watcher lifecycle edges, and cross-platform path behavior together. */ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { FsHandler } from './fs-handler' +import { MAX_TEXT_FILE_SIZE } from './fs-handler-utils' import { RelayContext } from './context' import type { RelayDispatcher } from './dispatcher' import * as fs from 'node:fs/promises' @@ -101,6 +102,16 @@ function createMockDispatcher() { } } +function statIdentity(stats: { + dev?: number + ino?: number + nlink?: number + size?: number + mtimeMs?: number +}) { + return `${stats.dev}:${stats.ino}:${stats.nlink ?? 'unknown'}:${stats.size}:${stats.mtimeMs}` +} + describe('FsHandler', () => { let dispatcher: ReturnType let handler: FsHandler @@ -254,6 +265,157 @@ describe('FsHandler', () => { expect(content).toBe('new content') }) + it('readTerminalArtifact reads through a verified artifact handle', async () => { + const filePath = path.join(tmpDir, 'artifact-read.json') + writeFileSync(filePath, '{"ok":true}') + const stats = await fs.stat(filePath) + + const result = (await dispatcher.callRequest('fs.readTerminalArtifact', { + filePath, + expectedRealPath: await fs.realpath(filePath), + expectedStatIdentity: statIdentity(stats), + maxBytes: 512 * 1024 + })) as { content: string; isBinary: boolean } + + expect(result).toEqual({ content: '{"ok":true}', isBinary: false }) + }) + + it('readTerminalArtifact treats SVG artifacts as editable text', async () => { + const filePath = path.join(tmpDir, 'artifact.svg') + writeFileSync(filePath, 'ok') + const stats = await fs.stat(filePath) + + const result = (await dispatcher.callRequest('fs.readTerminalArtifact', { + filePath, + expectedRealPath: await fs.realpath(filePath), + expectedStatIdentity: statIdentity(stats), + maxBytes: 512 * 1024 + })) as { content: string; isBinary: boolean; isImage?: boolean } + + expect(result).toEqual({ content: 'ok', isBinary: false }) + }) + + it('readTerminalArtifact rejects content beyond the requested byte limit', async () => { + const filePath = path.join(tmpDir, 'artifact-read-too-large.txt') + writeFileSync(filePath, 'abcdef') + + await expect( + dispatcher.callRequest('fs.readTerminalArtifact', { + filePath, + expectedRealPath: await fs.realpath(filePath), + maxBytes: 5 + }) + ).rejects.toThrow('file_too_large') + }) + + it('writeTerminalArtifact writes through a verified artifact handle', async () => { + const filePath = path.join(tmpDir, 'artifact-write.json') + writeFileSync(filePath, '{"ok":true}') + const stats = await fs.stat(filePath) + + const result = (await dispatcher.callRequest('fs.writeTerminalArtifact', { + filePath, + content: '{"ok":false}', + expectedRealPath: await fs.realpath(filePath), + expectedStatIdentity: statIdentity(stats), + maxBytes: 512 * 1024 + })) as { stat: { type: string; size: number } } + + await expect(fs.readFile(filePath, 'utf-8')).resolves.toBe('{"ok":false}') + expect(result.stat).toMatchObject({ type: 'file', size: 12 }) + }) + + it.skipIf(process.platform === 'win32')( + 'writeTerminalArtifact preserves executable mode across the atomic rename', + async () => { + const filePath = path.join(tmpDir, 'artifact-executable.sh') + writeFileSync(filePath, '#!/bin/sh\necho ok\n') + await fs.chmod(filePath, 0o755) + const stats = await fs.stat(filePath) + + await dispatcher.callRequest('fs.writeTerminalArtifact', { + filePath, + content: '#!/bin/sh\necho changed\n', + expectedRealPath: await fs.realpath(filePath), + expectedStatIdentity: statIdentity(stats), + maxBytes: 512 * 1024 + }) + + expect((await fs.stat(filePath)).mode & 0o777).toBe(0o755) + } + ) + + it('writeTerminalArtifact rejects oversized existing content before writing', async () => { + const filePath = path.join(tmpDir, 'artifact-write-too-large.txt') + writeFileSync(filePath, 'abcdef') + + await expect( + dispatcher.callRequest('fs.writeTerminalArtifact', { + filePath, + content: 'ok', + expectedRealPath: await fs.realpath(filePath), + maxBytes: 5 + }) + ).rejects.toThrow('file_too_large') + await expect(fs.readFile(filePath, 'utf-8')).resolves.toBe('abcdef') + }) + + it('writeTerminalArtifact clamps client-supplied maxBytes to the text-file cap', async () => { + const filePath = path.join(tmpDir, 'artifact-write-clamp.txt') + writeFileSync(filePath, 'abcdef') + + await expect( + dispatcher.callRequest('fs.writeTerminalArtifact', { + filePath, + content: 'a'.repeat(MAX_TEXT_FILE_SIZE + 1), + expectedRealPath: await fs.realpath(filePath), + maxBytes: Number.MAX_SAFE_INTEGER + }) + ).rejects.toThrow('file_too_large') + await expect(fs.readFile(filePath, 'utf-8')).resolves.toBe('abcdef') + }) + + it('writeTerminalArtifact rejects a retargeted symlink before writing outside temp', async () => { + const filePath = path.join(tmpDir, 'artifact-link.json') + const outsidePath = path.join(tmpDir, 'outside.json') + writeFileSync(filePath, '{"ok":true}') + writeFileSync(outsidePath, '{"secret":true}') + const stats = await fs.stat(filePath) + const expectedRealPath = await fs.realpath(filePath) + await fs.rm(filePath) + symlinkSync(outsidePath, filePath) + + await expect( + dispatcher.callRequest('fs.writeTerminalArtifact', { + filePath, + content: '{"ok":false}', + expectedRealPath, + expectedStatIdentity: statIdentity(stats), + maxBytes: 512 * 1024 + }) + ).rejects.toThrow('terminal_file_grant_stale') + await expect(fs.readFile(outsidePath, 'utf-8')).resolves.toBe('{"secret":true}') + }) + + it('writeTerminalArtifact rejects hard-linked files before writing', async () => { + const outsidePath = path.join(tmpDir, 'outside-hardlink.json') + const filePath = path.join(tmpDir, 'artifact-hardlink.json') + writeFileSync(outsidePath, '{"secret":true}') + await fs.link(outsidePath, filePath) + const stats = await fs.stat(filePath) + + await expect( + dispatcher.callRequest('fs.writeTerminalArtifact', { + filePath, + content: '{"ok":false}', + expectedRealPath: await fs.realpath(filePath), + expectedStatIdentity: statIdentity(stats), + maxBytes: 512 * 1024 + }) + ).rejects.toThrow('terminal_file_grant_stale') + await expect(fs.readFile(outsidePath, 'utf-8')).resolves.toBe('{"secret":true}') + }) + it('stat returns file metadata', async () => { const filePath = path.join(tmpDir, 'stat-test.txt') writeFileSync(filePath, 'test') diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index e49a62c1d3d..a1b4ee08660 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -21,6 +21,10 @@ import { isQuickOpenReaddirBudgetError } from '../shared/quick-open-readdir-walk import { buildExcludePathPrefixes } from '../shared/quick-open-filter' import { buildInstallRgMessage } from './fs-handler-install-rg' import { readRelayFileContent, readRelayFileStreamMetadata } from './fs-handler-file-read' +import { + readVerifiedTerminalArtifact, + writeVerifiedTerminalArtifact +} from './fs-handler-terminal-artifact' import { RelayStreamRegistry } from './fs-stream-registry' import { scanWorkspaceSpaceDirectory } from './workspace-space-scan' import { buildRelayCommandEnv } from './relay-command-env' @@ -59,7 +63,15 @@ function fileStatFromLstat(stats: Awaited>) { } else if (stats.isSymbolicLink()) { type = 'symlink' } - return { size: stats.size, type, mtime: stats.mtimeMs } + return { + size: stats.size, + type, + mtime: stats.mtimeMs, + mtimeMs: stats.mtimeMs, + dev: stats.dev, + ino: stats.ino, + nlink: stats.nlink + } } export class FsHandler { @@ -77,8 +89,10 @@ export class FsHandler { this.dispatcher.onRequest('fs.readDir', (p) => this.readDir(p)) this.dispatcher.onRequest('fs.readFile', (p) => this.readFile(p)) this.dispatcher.onRequest('fs.readFileStream', (p, c) => this.readFileStream(p, c)) + this.dispatcher.onRequest('fs.readTerminalArtifact', (p) => this.readTerminalArtifact(p)) this.dispatcher.onRequest('fs.tempDir', () => this.tempDir()) this.dispatcher.onRequest('fs.writeFile', (p) => this.writeFile(p)) + this.dispatcher.onRequest('fs.writeTerminalArtifact', (p) => this.writeTerminalArtifact(p)) this.dispatcher.onRequest('fs.stat', (p) => this.stat(p)) this.dispatcher.onRequest('fs.lstat', (p) => this.lstat(p)) this.dispatcher.onRequest('fs.deletePath', (p) => this.deletePath(p)) @@ -120,6 +134,13 @@ export class FsHandler { return readRelayFileContent(filePath) } + private async readTerminalArtifact(params: Record) { + return readVerifiedTerminalArtifact({ + ...params, + filePath: expandTilde(params.filePath as string) + }) + } + private async readFileStream(params: Record, context?: RequestContext) { const filePath = expandTilde(params.filePath as string) const ctx = context ?? { clientId: 0, isStale: () => false } @@ -153,6 +174,13 @@ export class FsHandler { await writeFile(filePath, content, 'utf-8') } + private async writeTerminalArtifact(params: Record) { + return writeVerifiedTerminalArtifact({ + ...params, + filePath: expandTilde(params.filePath as string) + }) + } + private async stat(params: Record) { const filePath = expandTilde(params.filePath as string) const stats = await lstat(filePath) @@ -164,7 +192,11 @@ export class FsHandler { return { size: targetStats.size, type: targetStats.isDirectory() ? 'directory' : 'file', - mtime: targetStats.mtimeMs + mtime: targetStats.mtimeMs, + mtimeMs: targetStats.mtimeMs, + dev: targetStats.dev, + ino: targetStats.ino, + nlink: targetStats.nlink } } catch { return { size: stats.size, type: 'symlink', mtime: stats.mtimeMs } diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 5f4fff86610..126f761333b 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -62,6 +62,7 @@ type ManagedPty = { paneKey?: string tabId?: string worktreeId?: string + terminalHandle?: string startupCommand?: ManagedStartupCommand } @@ -156,6 +157,13 @@ function resolvePtyShellOverride(shellOverride: string): string { return resolveWindowsGitBashShellPath(shellOverride) ?? shellOverride } +type PtyProcessSummary = { + id: string + cwd: string + title: string + terminalHandle?: string +} + type SerializedPtyEntry = { id: string pid: number @@ -165,6 +173,7 @@ type SerializedPtyEntry = { paneKey?: string tabId?: string worktreeId?: string + terminalHandle?: string } export type PtyExitListener = (event: { id: string; paneKey?: string }) => void @@ -539,6 +548,10 @@ export class PtyHandler { // for overlay resolution; runtime-owned PTYs opt into relay delivery // because no renderer TerminalPane exists to type the command. const paneKey = typeof env?.ORCA_PANE_KEY === 'string' ? env.ORCA_PANE_KEY : undefined + // Why: kept so a restarted runtime can re-adopt this live PTY under its + // originally-exported handle (reported via listProcesses, survives revive). + const terminalHandle = + typeof env?.ORCA_TERMINAL_HANDLE === 'string' ? env.ORCA_TERMINAL_HANDLE : undefined const command = typeof params.command === 'string' ? params.command : undefined const terminalWindowsWslDistro = typeof params.terminalWindowsWslDistro === 'string' ? params.terminalWindowsWslDistro : null @@ -589,6 +602,7 @@ export class PtyHandler { paneKey, tabId, worktreeId, + ...(terminalHandle ? { terminalHandle } : {}), ...(shouldProviderDeliverCommand ? { startupCommand: { @@ -819,12 +833,17 @@ export class PtyHandler { return await getForegroundProcessName(managed.pty.pid, managed.pty.process || null) } - private async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { - const results: { id: string; cwd: string; title: string }[] = [] + private async listProcesses(): Promise { + const results: PtyProcessSummary[] = [] for (const [id, managed] of this.ptys) { const title = (await getForegroundProcessName(managed.pty.pid, managed.pty.process || null)) || 'shell' - results.push({ id, cwd: managed.initialCwd, title }) + results.push({ + id, + cwd: managed.initialCwd, + title, + ...(managed.terminalHandle ? { terminalHandle: managed.terminalHandle } : {}) + }) } return results } @@ -846,7 +865,8 @@ export class PtyHandler { cwd: managed.initialCwd, paneKey: managed.paneKey, tabId: managed.tabId, - worktreeId: managed.worktreeId + worktreeId: managed.worktreeId, + ...(managed.terminalHandle ? { terminalHandle: managed.terminalHandle } : {}) }) } return JSON.stringify(entries) @@ -884,6 +904,9 @@ export class PtyHandler { if (entry.worktreeId) { revivedEnv.ORCA_WORKTREE_ID = entry.worktreeId } + if (entry.terminalHandle) { + revivedEnv.ORCA_TERMINAL_HANDLE = entry.terminalHandle + } const shell = resolveDefaultShell() // Why: `command` is intentionally absent from this revive path because // SerializedPtyEntry (see line 99) does not persist it — ManagedPty @@ -914,7 +937,8 @@ export class PtyHandler { buffered: '', paneKey: entry.paneKey, tabId: entry.tabId, - worktreeId: entry.worktreeId + worktreeId: entry.worktreeId, + ...(entry.terminalHandle ? { terminalHandle: entry.terminalHandle } : {}) }) // Why: nextId starts at 1 and is only incremented by spawn(). Revived diff --git a/src/renderer/src/assets/terminal.css b/src/renderer/src/assets/terminal.css index 97d3cbb4744..d9763b3e341 100644 --- a/src/renderer/src/assets/terminal.css +++ b/src/renderer/src/assets/terminal.css @@ -219,6 +219,12 @@ pointer-events: none; } +.pane-drop-overlay[data-pane-drop-overlay-kind='insertion'] { + background: var(--chart-2); + border: 0; + border-radius: 0; +} + .tab-drop-overlay__label { font-size: 11px; color: var(--primary-foreground); diff --git a/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx b/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx index b63dc248665..5e45c6e71da 100644 --- a/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx +++ b/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx @@ -11,6 +11,11 @@ import { LinearIssueMarkdownToolbar } from '@/components/LinearIssueMarkdownTool import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { + getRichMarkdownSpellcheckAttribute, + useRichMarkdownSpellcheckAttribute +} from '@/components/editor/rich-markdown-spellcheck' type LinearIssueMarkdownDescriptionEditorProps = { value: string @@ -46,6 +51,9 @@ export function LinearIssueMarkdownDescriptionEditor({ const language = i18n.resolvedLanguage ?? i18n.language const lastEditorMarkdownRef = useRef(value) const editorRef = useRef(null) + const richMarkdownSpellcheckEnabled = useAppStore( + (s) => s.settings?.richMarkdownSpellcheckEnabled ?? true + ) const linearIssueMarkdownExtensions = useMemo(() => { // Why: Tiptap freezes extension options when the editor is created; the // language value is the recreation key for translated extension options. @@ -63,7 +71,7 @@ export function LinearIssueMarkdownDescriptionEditor({ editorProps: { attributes: { class: 'rich-markdown-editor', - spellcheck: 'true', + spellcheck: getRichMarkdownSpellcheckAttribute(richMarkdownSpellcheckEnabled), 'aria-label': 'Issue description' }, handleKeyDown: (_view, event) => { @@ -93,6 +101,7 @@ export function LinearIssueMarkdownDescriptionEditor({ }, [language] ) + useRichMarkdownSpellcheckAttribute(editor, richMarkdownSpellcheckEnabled) useEffect(() => { editorRef.current = editor diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 6da91641ab6..8a17d67af80 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -1886,7 +1886,7 @@ function Terminal(): React.JSX.Element | null { key={`${tab.id}-${tab.generation ?? 0}`} tabId={tab.id} worktreeId={workspace.id} - cwd={workspace.path} + cwd={tab.startupCwd ?? workspace.path} isActive={isActiveTerminalTab || activityTerminalPortal?.active === true} // Why: the activity page hosts this existing pane via // portal while the workspace surface remains hidden. diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.tsx b/src/renderer/src/components/activity/ActivityPrototypePage.tsx index 38cd942c765..d296f5da471 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.tsx +++ b/src/renderer/src/components/activity/ActivityPrototypePage.tsx @@ -873,6 +873,61 @@ function EventTime({ timestamp }: { timestamp: number }): React.JSX.Element { ) } +export function ActivityThreadOptionsMenu({ + compactMode, + hasUnreadThreads, + onCompactModeChange, + onMarkAllThreadsRead +}: { + compactMode: boolean + hasUnreadThreads: boolean + onCompactModeChange: (compactMode: boolean) => void + onMarkAllThreadsRead: () => void +}): React.JSX.Element { + return ( + + + + {/* Why: keep Tooltip and Dropdown from composing refs onto the same + button; the crash report's stack loops through Radix setRef. */} + + + + + + + + {translate('auto.components.activity.ActivityPrototypePage.a472a14700', 'More options')} + + + + onCompactModeChange(checked === true)} + onSelect={(event) => event.preventDefault()} + > + {translate('auto.components.activity.ActivityPrototypePage.f70e4bec47', 'Compact mode')} + + + + {translate('auto.components.activity.ActivityPrototypePage.023ff75afe', 'Mark all read')} + + + + ) +} + function EventRepoBadge({ repo }: { repo: Repo | null }): React.JSX.Element | null { if (!repo) { return null @@ -1823,54 +1878,12 @@ export default function ActivityPrototypePage(): React.JSX.Element { the toolbar focused on the high-frequency Filter + unread toggle while still giving the action a stable home next to the list it acts on (rather than the titlebar). */} - - - - - - - - - {translate( - 'auto.components.activity.ActivityPrototypePage.a472a14700', - 'More options' - )} - - - - setCompactMode(checked === true)} - onSelect={(event) => event.preventDefault()} - > - {translate( - 'auto.components.activity.ActivityPrototypePage.f70e4bec47', - 'Compact mode' - )} - - - markAllThreadsRead()} - disabled={!hasUnreadThreads} - > - {translate( - 'auto.components.activity.ActivityPrototypePage.023ff75afe', - 'Mark all read' - )} - - - +
diff --git a/src/renderer/src/components/activity/ActivityThreadOptionsMenu.test.tsx b/src/renderer/src/components/activity/ActivityThreadOptionsMenu.test.tsx new file mode 100644 index 00000000000..b5d786f3568 --- /dev/null +++ b/src/renderer/src/components/activity/ActivityThreadOptionsMenu.test.tsx @@ -0,0 +1,65 @@ +// @vitest-environment happy-dom + +import { act, type ReactElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { TooltipProvider } from '@/components/ui/tooltip' +import { ActivityThreadOptionsMenu } from './ActivityPrototypePage' + +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +function Harness({ + compactMode = false, + hasUnreadThreads = true +}: { + compactMode?: boolean + hasUnreadThreads?: boolean +}): ReactElement { + return ( + + + + ) +} + +describe('ActivityThreadOptionsMenu', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + document.body.replaceChildren() + }) + + it('opens without recursively updating composed Radix trigger refs', async () => { + await act(async () => { + root.render() + }) + + const trigger = container.querySelector( + 'button[aria-label="Thread list options"]' + ) + + expect(trigger).not.toBeNull() + expect(trigger?.parentElement?.tagName).toBe('SPAN') + + await act(async () => { + trigger?.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' })) + }) + + expect(document.body.textContent).toContain('Compact mode') + }) +}) diff --git a/src/renderer/src/components/editor/RichMarkdownEditor.tsx b/src/renderer/src/components/editor/RichMarkdownEditor.tsx index c4b90043207..13461edd9ca 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditor.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditor.tsx @@ -21,6 +21,7 @@ import { isRichMarkdownContextCommandTarget, runRichMarkdownContextCommand } from './rich-markdown-context-command-routing' +import { useRichMarkdownSpellcheckAttribute } from './rich-markdown-spellcheck' type RichMarkdownEditorProps = { fileId: string @@ -72,6 +73,7 @@ export default function RichMarkdownEditor({ }: RichMarkdownEditorProps): React.JSX.Element { const rootRef = useRef(null) const settings = useAppStore((s) => s.settings) + const richMarkdownSpellcheckEnabled = settings?.richMarkdownSpellcheckEnabled ?? true const editorFontZoomLevel = useAppStore((s) => s.editorFontZoomLevel) const activateMarkdownLink = useAppStore((s) => s.activateMarkdownLink) const addDiffComment = useAppStore((s) => s.addDiffComment) @@ -209,6 +211,7 @@ export default function RichMarkdownEditor({ worktreeRoot, runtimeEnvironmentId, isMac, + richMarkdownSpellcheckEnabled, settings, activateMarkdownLink, rootRef, @@ -246,6 +249,7 @@ export default function RichMarkdownEditor({ setSlashMenu: menu.setSlashMenu, setDocLinkMenu: menu.setDocLinkMenu }) + useRichMarkdownSpellcheckAttribute(editor, richMarkdownSpellcheckEnabled) // Why: use useLayoutEffect (synchronous cleanup) so the pending serialization // flush runs before useEditor's cleanup destroys the editor instance on tab diff --git a/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts b/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts new file mode 100644 index 00000000000..20d969ca8f0 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts @@ -0,0 +1,90 @@ +import type { Dispatch, MutableRefObject, SetStateAction } from 'react' +import type { Editor } from '@tiptap/react' +import type { DocLinkMenuState } from './rich-markdown-commands' +import { describe, expect, it, vi } from 'vitest' +import type { LinkBubbleState } from './RichMarkdownLinkBubble' +import { + createRichMarkdownEditorConfig, + type EditorConfigParams +} from './rich-markdown-editor-config' +import type { SlashMenuState } from './rich-markdown-slash-commands' + +function ref(current: T): MutableRefObject { + return { current } +} + +function stateSetter(): Dispatch> { + return vi.fn() as Dispatch> +} + +function getSpellcheckAttribute(config: ReturnType): string { + const attributes = config.editorProps?.attributes + return typeof attributes === 'function' + ? attributes({} as never).spellcheck + : (attributes?.spellcheck ?? '') +} + +function createConfigParams(overrides: Partial = {}): EditorConfigParams { + return { + content: '', + filePath: '/repo/README.md', + worktreeId: 'worktree-1', + worktreeRoot: '/repo', + runtimeEnvironmentId: null, + isMac: false, + richMarkdownSpellcheckEnabled: true, + settings: { activeRuntimeEnvironmentId: null }, + activateMarkdownLink: vi.fn(), + rootRef: ref(null), + editorRef: ref(null), + lastCommittedMarkdownRef: ref(''), + onContentChangeRef: ref(vi.fn()), + onDirtyStateHintRef: ref(vi.fn()), + onSaveRef: ref(vi.fn()), + onOpenDocLinkRef: ref(undefined), + isEditingLinkRef: ref(false), + slashMenuRef: ref(null), + filteredSlashCommandsRef: ref([]), + selectedCommandIndexRef: ref(0), + docLinkMenuRef: ref(null), + filteredDocLinkRowsRef: ref([]), + selectedDocLinkIndexRef: ref(0), + handleLocalImagePickRef: ref(vi.fn()), + handleEmojiPickRef: ref(vi.fn()), + typedEmptyOrderedListMarkerRef: ref(false), + cancelAutoFocusRef: ref(null), + serializeTimerRef: ref(null), + isInitializingRef: ref(false), + isApplyingProgrammaticUpdateRef: ref(false), + markdownCommentsRef: ref([]), + markdownSourceLineOffsetRef: ref(0), + flushPendingSerialization: vi.fn(), + openSearchRef: ref(vi.fn()), + syncAnnotationTarget: vi.fn(), + clearAnnotationTarget: vi.fn(), + scrollRichMarkdownReviewNoteCardIntoView: vi.fn(), + setIsEditingLink: stateSetter(), + setLinkBubble: stateSetter(), + setSelectedCommandIndex: stateSetter(), + setSelectedDocLinkIndex: stateSetter(), + setSlashMenu: stateSetter(), + setDocLinkMenu: stateSetter(), + ...overrides + } +} + +describe('createRichMarkdownEditorConfig', () => { + it('disables browser spellcheck when the rich Markdown setting is off', () => { + const config = createRichMarkdownEditorConfig( + createConfigParams({ richMarkdownSpellcheckEnabled: false }) + ) + + expect(getSpellcheckAttribute(config)).toBe('false') + }) + + it('keeps browser spellcheck enabled by default', () => { + const config = createRichMarkdownEditorConfig(createConfigParams()) + + expect(getSpellcheckAttribute(config)).toBe('true') + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-editor-config.ts b/src/renderer/src/components/editor/rich-markdown-editor-config.ts index e6fedd8f897..de2e520d631 100644 --- a/src/renderer/src/components/editor/rich-markdown-editor-config.ts +++ b/src/renderer/src/components/editor/rich-markdown-editor-config.ts @@ -26,6 +26,7 @@ import { createRichMarkdownImageResolverContext, setRichMarkdownImageResolverContext } from './rich-markdown-image-context' +import { getRichMarkdownSpellcheckAttribute } from './rich-markdown-spellcheck' import type { MutableRefObject, Dispatch, SetStateAction } from 'react' import type { DiffComment } from '../../../../shared/types' @@ -36,6 +37,7 @@ export type EditorConfigParams = { worktreeRoot: string | null runtimeEnvironmentId?: string | null isMac: boolean + richMarkdownSpellcheckEnabled: boolean settings: RichMarkdownRuntimeSettings activateMarkdownLink: ActivateMarkdownLink rootRef: MutableRefObject @@ -82,6 +84,7 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE worktreeRoot, runtimeEnvironmentId, isMac, + richMarkdownSpellcheckEnabled, settings, activateMarkdownLink, rootRef, @@ -127,7 +130,7 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE editorProps: { attributes: { class: 'rich-markdown-editor', - spellcheck: 'true' + spellcheck: getRichMarkdownSpellcheckAttribute(richMarkdownSpellcheckEnabled) }, handleDOMEvents: { cut: handleRichMarkdownCut diff --git a/src/renderer/src/components/editor/rich-markdown-spellcheck.ts b/src/renderer/src/components/editor/rich-markdown-spellcheck.ts new file mode 100644 index 00000000000..0b82bdf60ee --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-spellcheck.ts @@ -0,0 +1,12 @@ +import { useEffect } from 'react' +import type { Editor } from '@tiptap/react' + +export function getRichMarkdownSpellcheckAttribute(enabled: boolean): 'true' | 'false' { + return enabled ? 'true' : 'false' +} + +export function useRichMarkdownSpellcheckAttribute(editor: Editor | null, enabled: boolean): void { + useEffect(() => { + editor?.view.dom.setAttribute('spellcheck', getRichMarkdownSpellcheckAttribute(enabled)) + }, [editor, enabled]) +} diff --git a/src/renderer/src/components/github/GitHubMarkdownComposer.tsx b/src/renderer/src/components/github/GitHubMarkdownComposer.tsx index ba840a82421..9a4019a3f8b 100644 --- a/src/renderer/src/components/github/GitHubMarkdownComposer.tsx +++ b/src/renderer/src/components/github/GitHubMarkdownComposer.tsx @@ -25,6 +25,11 @@ import { hasBoundedGitHubMarkdownImageUrlText } from '@/components/github/github import { useImageInput } from '@/components/github/use-image-input' import type { GitHubOwnerRepo } from '../../../../shared/types' import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { + getRichMarkdownSpellcheckAttribute, + useRichMarkdownSpellcheckAttribute +} from '@/components/editor/rich-markdown-spellcheck' type GitHubMarkdownComposerProps = { value: string @@ -59,6 +64,9 @@ export function GitHubMarkdownComposer({ const onSubmitShortcutRef = useRef(onSubmitShortcut) const disabledRef = useRef(disabled) const isEditingLinkRef = useRef(false) + const richMarkdownSpellcheckEnabled = useAppStore( + (s) => s.settings?.richMarkdownSpellcheckEnabled ?? true + ) const [activeTab, setActiveTab] = useState('write') const [linkBubble, setLinkBubble] = useState(null) const [isEditingLink, setIsEditingLink] = useState(false) @@ -114,7 +122,7 @@ export function GitHubMarkdownComposer({ editorProps: { attributes: { class: cn('rich-markdown-editor github-markdown-composer-editor', minHeightClassName), - spellcheck: 'true' + spellcheck: getRichMarkdownSpellcheckAttribute(richMarkdownSpellcheckEnabled) }, handleKeyDown: (_view, event) => { if (isScreenSubmitShortcut(event)) { @@ -179,6 +187,7 @@ export function GitHubMarkdownComposer({ setLinkBubble(null) } }) + useRichMarkdownSpellcheckAttribute(editor, richMarkdownSpellcheckEnabled) useEffect(() => { if (!editor) { diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx index 1722a4ed815..0b0dee7079a 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx @@ -18,6 +18,7 @@ import { shouldShowCollapseFolderAction, shouldShowFindInFolderAction, shouldShowCopyFileAction, + shouldShowOpenInTerminalAction, shouldShowRemoteDownloadAction } from './FileExplorerRow' import { FileExplorerVirtualRows } from './FileExplorerVirtualRows' @@ -575,6 +576,11 @@ describe('FileExplorerRow collapse folder action', () => { ).toBe(false) }) + it('only shows open in terminal for directories', () => { + expect(shouldShowOpenInTerminalAction(directoryNode)).toBe(true) + expect(shouldShowOpenInTerminalAction(fileNode)).toBe(false) + }) + it('shows remote download only for desktop SSH or Remote Host file-like rows', () => { const runtimeContext = { settings: { activeRuntimeEnvironmentId: 'runtime-1' }, @@ -776,6 +782,7 @@ describe('FileExplorerRow collapse folder action', () => { onDuplicate: vi.fn(), onAddFolderAsProject: vi.fn(), canAddFolderAsProject: () => false, + onOpenInTerminal: vi.fn(), onRequestDelete: vi.fn(), onCollapseFolderSubtree, onFindInFolder: vi.fn(), @@ -827,6 +834,7 @@ describe('FileExplorerRow collapse folder action', () => { onDuplicate: vi.fn(), onAddFolderAsProject: vi.fn(), canAddFolderAsProject: () => false, + onOpenInTerminal: vi.fn(), onRequestDelete: vi.fn(), onCollapseFolderSubtree: vi.fn(), onFindInFolder, @@ -878,6 +886,7 @@ describe('FileExplorerRow collapse folder action', () => { onDuplicate: vi.fn(), onAddFolderAsProject: vi.fn(), canAddFolderAsProject: () => false, + onOpenInTerminal: vi.fn(), onRequestDelete: vi.fn(), onCollapseFolderSubtree: vi.fn(), onFindInFolder: vi.fn(), @@ -896,4 +905,56 @@ describe('FileExplorerRow collapse folder action', () => { expect(row.props.connectionId).toBe('ssh-1') }) + + it('passes the row node to the open in terminal handler', () => { + const onOpenInTerminal = vi.fn() + const element = FileExplorerVirtualRows({ + virtualizer: { + getTotalSize: () => 26, + getVirtualItems: () => [{ index: 0, key: 'src', start: 0 }], + measureElement: vi.fn() + } as never, + inlineInputIndex: -1, + rowProjection: createFileExplorerRowProjection([directoryNode]), + inlineInput: null, + handleInlineSubmit: vi.fn(), + dismissInlineInput: vi.fn(), + folderStatusByRelativePath: new Map(), + statusByRelativePath: new Map(), + ignoredByRelativePath: new Set(), + expanded: new Set([directoryNode.path]), + dirCache: {}, + selectedPaths: new Set(), + activeFileId: null, + flashingPath: null, + deleteShortcutLabel: 'Del', + onClick: vi.fn(), + onDoubleClick: vi.fn(), + onContextMenuSelect: vi.fn(), + onCopyPaths: vi.fn(), + onStartNew: vi.fn(), + onStartRename: vi.fn(), + onDuplicate: vi.fn(), + onAddFolderAsProject: vi.fn(), + canAddFolderAsProject: () => false, + onOpenInTerminal, + onRequestDelete: vi.fn(), + onCollapseFolderSubtree: vi.fn(), + onFindInFolder: vi.fn(), + onMoveDrop: vi.fn(), + onDragTargetChange: vi.fn(), + onDragSourceChange: vi.fn(), + onDragExpandDir: vi.fn(), + onNativeDragTargetChange: vi.fn(), + onNativeDragExpandDir: vi.fn(), + dropTargetDir: null, + dragSourcePath: null, + nativeDropTargetDir: null + }) + + const row = findFileExplorerRow(element) + ;(row.props.onOpenInTerminal as () => void)() + + expect(onOpenInTerminal).toHaveBeenCalledWith(directoryNode) + }) }) diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.tsx index ff236353958..83defe0e715 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorer.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorer.tsx @@ -54,6 +54,7 @@ import { translate } from '@/i18n/i18n' import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '@/components/tab-bar/SortableTab' import type { RightSidebarExplorerView } from '../../../../shared/types' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { createNewTerminalTab } from '@/components/terminal/terminal-tab-actions' function FileExplorerFiles(): React.JSX.Element { const explorerView = useAppStore((s) => s.rightSidebarExplorerView) @@ -577,6 +578,15 @@ function FileExplorerFiles(): React.JSX.Element { }, [activeRepo, openModal] ) + const handleOpenInTerminal = useCallback( + (node: TreeNode) => { + if (!activeWorktreeId || !node.isDirectory) { + return + } + createNewTerminalTab(activeWorktreeId, undefined, { startupCwd: node.path }) + }, + [activeWorktreeId] + ) if (!worktreePath) { return ( @@ -743,6 +753,7 @@ function FileExplorerFiles(): React.JSX.Element { onDuplicate={handleDuplicate} onAddFolderAsProject={handleAddFolderAsProject} canAddFolderAsProject={(node) => canShowAddAsProjectAction(node, activeRepo)} + onOpenInTerminal={handleOpenInTerminal} onRequestDelete={handleContextMenuDelete} onCollapseFolderSubtree={handleCollapseFolderSubtree} onFindInFolder={handleFindInFolder} diff --git a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx index d24d3d65637..765c1071b54 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx @@ -20,6 +20,7 @@ import { Loader2, Pencil, Search, + SquareTerminal, Trash2 } from 'lucide-react' import { toast } from 'sonner' @@ -285,6 +286,7 @@ type FileExplorerRowProps = { onDuplicate: (node: TreeNode) => void onAddFolderAsProject: () => void canAddAsProject: boolean + onOpenInTerminal: () => void onRequestDelete: () => void onCollapseFolderSubtree: () => void onFindInFolder: () => void @@ -304,6 +306,10 @@ export function shouldShowFindInFolderAction(node: TreeNode): boolean { return node.isDirectory } +export function shouldShowOpenInTerminalAction(node: TreeNode): boolean { + return node.isDirectory +} + export function shouldShowRemoteDownloadAction( node: TreeNode, connectionId?: string | null, @@ -422,6 +428,7 @@ export function FileExplorerRow({ onDuplicate, onAddFolderAsProject, canAddAsProject, + onOpenInTerminal, onRequestDelete, onCollapseFolderSubtree, onFindInFolder, @@ -696,6 +703,15 @@ export function FileExplorerRow({ )} )} + {shouldShowOpenInTerminalAction(node) && ( + + + {translate( + 'auto.components.right.sidebar.FileExplorerRow.e887fa4b2e', + 'Open in Terminal' + )} + + )} {!node.isDirectory && activeWorktreeId && ( diff --git a/src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.tsx b/src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.tsx index a400fef2c78..fe4e71dd608 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.tsx @@ -37,6 +37,7 @@ type FileExplorerVirtualRowsProps = { onDuplicate: (node: TreeNode) => void onAddFolderAsProject: (node: TreeNode) => void canAddFolderAsProject: (node: TreeNode) => boolean + onOpenInTerminal: (node: TreeNode) => void onRequestDelete: (node: TreeNode) => void onCollapseFolderSubtree: (node: TreeNode) => void onFindInFolder: (node: TreeNode) => void @@ -80,6 +81,7 @@ export function FileExplorerVirtualRows(props: FileExplorerVirtualRowsProps): Re onDuplicate, onAddFolderAsProject, canAddFolderAsProject, + onOpenInTerminal, onRequestDelete, onCollapseFolderSubtree, onFindInFolder, @@ -185,6 +187,7 @@ export function FileExplorerVirtualRows(props: FileExplorerVirtualRowsProps): Re onDuplicate={onDuplicate} onAddFolderAsProject={() => onAddFolderAsProject(n)} canAddAsProject={canAddFolderAsProject(n)} + onOpenInTerminal={() => onOpenInTerminal(n)} onRequestDelete={() => onRequestDelete(n)} onCollapseFolderSubtree={() => onCollapseFolderSubtree(n)} onFindInFolder={() => onFindInFolder(n)} diff --git a/src/renderer/src/components/right-sidebar/FileExplorerVirtualRowsAddProject.test.tsx b/src/renderer/src/components/right-sidebar/FileExplorerVirtualRowsAddProject.test.tsx index 7910c5f8fcf..16236cbd71f 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerVirtualRowsAddProject.test.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerVirtualRowsAddProject.test.tsx @@ -77,6 +77,7 @@ describe('FileExplorerVirtualRows add-as-project action', () => { onDuplicate: vi.fn(), onAddFolderAsProject, canAddFolderAsProject: (node) => node.path === directoryNode.path, + onOpenInTerminal: vi.fn(), onRequestDelete: vi.fn(), onCollapseFolderSubtree: vi.fn(), onFindInFolder: vi.fn(), diff --git a/src/renderer/src/components/right-sidebar/file-explorer-drag-scroll-marker.test.tsx b/src/renderer/src/components/right-sidebar/file-explorer-drag-scroll-marker.test.tsx index ded3aa0cb69..a9d1d9a46f5 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-drag-scroll-marker.test.tsx +++ b/src/renderer/src/components/right-sidebar/file-explorer-drag-scroll-marker.test.tsx @@ -78,6 +78,7 @@ function virtualRowsElement(nodes: TreeNode[]): React.JSX.Element { onDuplicate: vi.fn(), onAddFolderAsProject: vi.fn(), canAddFolderAsProject: () => false, + onOpenInTerminal: vi.fn(), onRequestDelete: vi.fn(), onCollapseFolderSubtree: vi.fn(), onFindInFolder: vi.fn(), diff --git a/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx b/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx index 2d8ca10c2f7..a4a33f1102a 100644 --- a/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx +++ b/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx @@ -16,6 +16,7 @@ import { SettingsSwitchRow } from './SettingsFormControls' import { translate } from '@/i18n/i18n' +import { RichMarkdownSpellcheckSetting } from './RichMarkdownSpellcheckSetting' export type AutoSaveDelayDraftState = { sourceDelayMs: number @@ -378,6 +379,8 @@ export function GeneralEditorSettingsSection({ /> + + { expect(matchesSettingsSearch('wsl', entries)).toBe(true) }) + it('includes rich Markdown spellcheck keywords', () => { + const entries = getGeneralPaneSearchEntries() + + expect(matchesSettingsSearch('spellcheck', entries)).toBe(true) + expect(matchesSettingsSearch('red underline', entries)).toBe(true) + }) + it('omits the default project runtime setting when Windows runtimes are unsupported', () => { const entries = getGeneralPaneSearchEntries({ includeProjectRuntime: false }) diff --git a/src/renderer/src/components/settings/RichMarkdownSpellcheckSetting.tsx b/src/renderer/src/components/settings/RichMarkdownSpellcheckSetting.tsx new file mode 100644 index 00000000000..c59a6302386 --- /dev/null +++ b/src/renderer/src/components/settings/RichMarkdownSpellcheckSetting.tsx @@ -0,0 +1,44 @@ +import type React from 'react' +import type { GlobalSettings } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' +import { SearchableSetting } from './SearchableSetting' +import { SettingsSwitchRow } from './SettingsFormControls' + +type RichMarkdownSpellcheckSettingProps = { + settings: Pick + updateSettings: (updates: Partial) => void +} + +export function RichMarkdownSpellcheckSetting({ + settings, + updateSettings +}: RichMarkdownSpellcheckSettingProps): React.JSX.Element { + const enabled = settings.richMarkdownSpellcheckEnabled ?? true + + return ( + + updateSettings({ richMarkdownSpellcheckEnabled: !enabled })} + /> + + ) +} diff --git a/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts b/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts index 5e8b643e111..0114ea3ebf4 100644 --- a/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts +++ b/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts @@ -8,12 +8,15 @@ vi.mock('react', async () => { const actual = await vi.importActual('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() return { ...actual, - useState: (initial: T) => [ - themeTarget ?? initial, - (value: T) => { - themeTarget = value as 'dark' | 'light' - } - ] + useState: (initial: T | (() => T)) => { + const initialValue = typeof initial === 'function' ? (initial as () => T)() : initial + return [ + themeTarget ?? initialValue, + (value: T) => { + themeTarget = value as 'dark' | 'light' + } + ] + } } }) @@ -23,7 +26,10 @@ vi.mock('./TerminalSettingsPreview', () => ({ } })) -import { TerminalThemeCatalogSection } from './TerminalThemeSections' +import { + resetTerminalThemeTargetMemoryForTests, + TerminalThemeCatalogSection +} from './TerminalThemeSections' type ReactElementLike = { type: unknown @@ -50,6 +56,7 @@ const warpThemesMock: UseWarpThemeImportReturn = { function makeSettings(overrides: Partial = {}): GlobalSettings { return { + theme: 'system', terminalUseSeparateLightTheme: false, terminalThemeDark: 'Ghostty Default Style Dark', terminalThemeLight: 'Builtin Tango Light', @@ -64,12 +71,13 @@ function renderCatalog( settings = makeSettings(), updateSettings = vi.fn(), target?: 'dark' | 'light', - preferredTarget?: 'dark' | 'light' + preferredTarget?: 'dark' | 'light', + systemPrefersDark = true ): React.JSX.Element { themeTarget = target return TerminalThemeCatalogSection({ settings, - systemPrefersDark: true, + systemPrefersDark, themeSearch: '', setThemeSearch: () => {}, updateSettings, @@ -167,6 +175,7 @@ function findButtonTexts(node: unknown): string[] { describe('TerminalThemeCatalogSection', () => { beforeEach(() => { themeTarget = 'dark' + resetTerminalThemeTargetMemoryForTests() vi.clearAllMocks() }) @@ -180,6 +189,72 @@ describe('TerminalThemeCatalogSection', () => { ) }) + it('opens on the active light theme when the system appearance is light', () => { + const updateSettings = vi.fn() + const element = renderCatalog( + makeSettings({ terminalUseSeparateLightTheme: true }), + updateSettings, + undefined, + undefined, + false + ) + const picker = findElementByTypeName(element, 'ThemePicker') + const preview = findElementByTypeName(element, 'TerminalSettingsPreview') + const selectTheme = picker?.props?.onSelectTheme as (theme: string) => void + + expect(picker?.props?.selectedTheme).toBe('Builtin Tango Light') + expect(preview?.props?.modeOverride).toBe('light') + + selectTheme('GitHub Light') + + expect(updateSettings).toHaveBeenCalledWith({ terminalThemeLight: 'GitHub Light' }) + }) + + it('reopens the manually edited light target while the active appearance is dark', () => { + const firstOpen = renderCatalog( + makeSettings({ terminalUseSeparateLightTheme: true }), + vi.fn(), + undefined, + undefined, + true + ) + const targetControl = findElementByTypeName(firstOpen, 'SettingsSegmentedControl') + const selectTarget = targetControl?.props?.onChange as (target: 'dark' | 'light') => void + + selectTarget('light') + + const reopened = renderCatalog( + makeSettings({ terminalUseSeparateLightTheme: true }), + vi.fn(), + undefined, + undefined, + true + ) + const picker = findElementByTypeName(reopened, 'ThemePicker') + const preview = findElementByTypeName(reopened, 'TerminalSettingsPreview') + + expect(picker?.props?.selectedTheme).toBe('Builtin Tango Light') + expect(preview?.props?.modeOverride).toBe('light') + }) + + it('opens a customized light-only theme after reload even when the active appearance is dark', () => { + const element = renderCatalog( + makeSettings({ + terminalUseSeparateLightTheme: true, + terminalThemeLight: 'GitHub Light' + }), + vi.fn(), + undefined, + undefined, + true + ) + const picker = findElementByTypeName(element, 'ThemePicker') + const preview = findElementByTypeName(element, 'TerminalSettingsPreview') + + expect(picker?.props?.selectedTheme).toBe('GitHub Light') + expect(preview?.props?.modeOverride).toBe('light') + }) + it('keeps the light target enabled while separate light theme is disabled', () => { const element = renderCatalog(makeSettings({ terminalUseSeparateLightTheme: false }), vi.fn()) const targetControl = findElementByTypeName(element, 'SettingsSegmentedControl') diff --git a/src/renderer/src/components/settings/TerminalThemeSections.tsx b/src/renderer/src/components/settings/TerminalThemeSections.tsx index 9479b5f1738..1fa765bb271 100644 --- a/src/renderer/src/components/settings/TerminalThemeSections.tsx +++ b/src/renderer/src/components/settings/TerminalThemeSections.tsx @@ -12,12 +12,56 @@ import { TerminalSettingsPreview } from './TerminalSettingsPreview' import { WarpThemeImportButton } from './WarpThemeImportButton' import { YamlThemeImportButton } from './YamlThemeImportButton' import type { UseWarpThemeImportReturn } from './useWarpThemeImport' -import { getAvailableTerminalThemeOptions } from '@/lib/terminal-theme' +import { + DEFAULT_TERMINAL_THEME_DARK, + DEFAULT_TERMINAL_THEME_LIGHT, + getAvailableTerminalThemeOptions, + resolveEffectiveTerminalAppearance +} from '@/lib/terminal-theme' import { translate } from '@/i18n/i18n' import { cn } from '@/lib/utils' type TerminalThemeTarget = 'dark' | 'light' +let lastEditedTerminalThemeTarget: TerminalThemeTarget | null = null + +function rememberTerminalThemeTarget(target: TerminalThemeTarget): void { + lastEditedTerminalThemeTarget = target +} + +export function resetTerminalThemeTargetMemoryForTests(): void { + lastEditedTerminalThemeTarget = null +} + +function isCustomizedTheme(themeName: string, defaultThemeName: string): boolean { + const trimmed = themeName.trim() + return trimmed.length > 0 && trimmed !== defaultThemeName +} + +function getInitialTerminalThemeTarget( + settings: GlobalSettings, + systemPrefersDark: boolean, + preferredTarget?: TerminalThemeTarget +): TerminalThemeTarget { + if (preferredTarget) { + return preferredTarget + } + if (lastEditedTerminalThemeTarget) { + return lastEditedTerminalThemeTarget + } + const customizedDark = isCustomizedTheme(settings.terminalThemeDark, DEFAULT_TERMINAL_THEME_DARK) + const customizedLight = isCustomizedTheme( + settings.terminalThemeLight, + DEFAULT_TERMINAL_THEME_LIGHT + ) + if (customizedDark !== customizedLight) { + return customizedDark ? 'dark' : 'light' + } + // Why: the picker edits mode-specific slots. Defaulting to the active + // terminal mode makes the selected theme apply immediately in system-light. + return resolveEffectiveTerminalAppearance(settings, systemPrefersDark).mode +} + type TerminalThemeCatalogSectionProps = { settings: GlobalSettings systemPrefersDark: boolean @@ -45,7 +89,13 @@ export function TerminalThemeCatalogSection({ preferredTarget, advancedContent }: TerminalThemeCatalogSectionProps): React.JSX.Element { - const [target, setTarget] = useState(preferredTarget ?? 'dark') + const [target, setTargetState] = useState(() => + getInitialTerminalThemeTarget(settings, systemPrefersDark, preferredTarget) + ) + const setTarget = (nextTarget: TerminalThemeTarget): void => { + rememberTerminalThemeTarget(nextTarget) + setTargetState(nextTarget) + } const themeOptions = getAvailableTerminalThemeOptions(settings) const isLightTarget = target === 'light' const matchDarkMode = !settings.terminalUseSeparateLightTheme @@ -200,11 +250,12 @@ export function TerminalThemeCatalogSection({ themeOptions={themeOptions} query={themeSearch} onQueryChange={setThemeSearch} - onSelectTheme={(theme) => + onSelectTheme={(theme) => { + rememberTerminalThemeTarget(target) updateSettings( isLightTarget ? { terminalThemeLight: theme } : { terminalThemeDark: theme } ) - } + }} importedHighlightSignal={importedHighlightSignal} /> diff --git a/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx b/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx index 70fa7e3eb7f..8633b735a96 100644 --- a/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx +++ b/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx @@ -14,6 +14,7 @@ import { Label } from '../ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' import { SearchableSetting } from './SearchableSetting' import { useSidebarHostScopeOptions } from '../sidebar/use-sidebar-host-scope-options' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { buildHostScopeChoices, CLIENT_DEFAULT_SCOPE, @@ -197,7 +198,9 @@ export function WorkspaceDirectorySetting({ }} onBlur={handleBlur} onKeyDown={(e) => { - if (isComposingKeyboardEvent(e)) { + // Why: an Enter that only confirms a CJK IME candidate must not + // commit the rename; wait for a non-composition Enter. + if (isImeCompositionKeyDown(e)) { return } if (e.key === 'Enter') { @@ -270,8 +273,3 @@ export function WorkspaceDirectorySetting({ ) } - -function isComposingKeyboardEvent(event: React.KeyboardEvent): boolean { - const nativeEvent = event.nativeEvent - return nativeEvent.isComposing || nativeEvent.keyCode === 229 -} diff --git a/src/renderer/src/components/settings/general-editor-search.ts b/src/renderer/src/components/settings/general-editor-search.ts index 0a13d7c5dd8..8f93eea9544 100644 --- a/src/renderer/src/components/settings/general-editor-search.ts +++ b/src/renderer/src/components/settings/general-editor-search.ts @@ -79,6 +79,29 @@ export const getGeneralEditorSearchEntries = createLocalizedCatalog(() => [ ...translateSearchKeyword('auto.components.settings.general.search.a0014961ae', 'scroll') ] }, + { + title: translate( + 'auto.components.settings.general.search.d2d2d929c0', + 'Rich Markdown Spellcheck' + ), + description: translate( + 'auto.components.settings.general.search.4497e2e2bb', + 'Show browser spelling underlines and suggestions while editing rich Markdown.' + ), + keywords: [ + ...translateSearchKeyword('auto.components.settings.general.search.f96cdaf37d', 'spellcheck'), + ...translateSearchKeyword( + 'auto.components.settings.general.search.a51d23f4a1', + 'spell check' + ), + ...translateSearchKeyword('auto.components.settings.general.search.641358460a', 'spelling'), + ...translateSearchKeyword('auto.components.settings.general.search.d05f629d2c', 'markdown'), + ...translateSearchKeyword( + 'auto.components.settings.general.search.d755962089', + 'red underline' + ) + ] + }, { title: translate('auto.components.settings.general.search.128bc09325', 'Markdown Review Notes'), description: translate( diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 75cab5bcc2c..2e405dcfb2d 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -135,6 +135,8 @@ import { } from '@/lib/scroll-to-current-workspace-status' import { isRepoHeaderActionTarget, useRepoHeaderDrag } from './project-header-drag' import { getSidebarOrderedRepoHeaderIdsByBucket } from './project-header-drop' +import { useProjectGroupHeaderDrag } from './project-group-header-drag' +import { getSidebarOrderedProjectGroupHeaderIdsByBucket } from './project-group-header-drop' import { buildManualOrderUpdatesForGroupDrop, buildManualOrderUpdatesForVisibleGroups, @@ -180,6 +182,10 @@ import { type WorktreeSidebarTrackedStatusDropTarget, type WorktreeSidebarDropPreview } from './worktree-sidebar-drop-preview' +import { + getReorderedWorktreeIdsToUnnest, + getWorktreeLineageDropTargetId +} from './worktree-lineage-drag-drop' import { resolveProjectGroupHeaderColor } from './project-header-color' import { REPO_HEADER_ACTION_BUTTON_CLASS, @@ -984,11 +990,11 @@ function getPointerDropStatusTarget(args: { if (pinTarget && args.container.contains(pinTarget)) { return { status: null, isPinDrop: true, lineageParentId: null } } - const rowTarget = target.closest('[data-worktree-drag-id]') - let lineageParentId: string | null = null - if (rowTarget && args.container.contains(rowTarget)) { - lineageParentId = rowTarget.dataset.worktreeDragId ?? null - } + const lineageParentId = getWorktreeLineageDropTargetId({ + container: args.container, + target, + pointerY: args.y + }) const statusTarget = target.closest('[data-workspace-status-drop-target]') return { status: @@ -1314,6 +1320,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const [highlightedRevealRowKey, setHighlightedRevealRowKey] = useState(null) const setRenamingWorktreeId = useAppStore((s) => s.setRenamingWorktreeId) const assignWorktreeParent = useAppStore((s) => s.assignWorktreeParent) + const updateWorktreeLineage = useAppStore((s) => s.updateWorktreeLineage) const worktreeDragSessionRef = useRef(null) const worktreePointerDragRef = useRef(null) const worktreePointerAutoscrollFrameIdRef = useRef(null) @@ -1372,7 +1379,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const suppressWorktreeClickUntilRef = useRef(0) const hasProjectGroups = projectGroups.length > 0 const canReorderRepoHeaders = groupBy === 'repo' && projectOrderBy === 'manual' + const canReorderProjectGroupHeaders = groupBy === 'repo' && hasProjectGroups const moveProjectToGroup = useAppStore((s) => s.moveProjectToGroup) + const updateProjectGroup = useAppStore((s) => s.updateProjectGroup) const lastVisibleRefreshKeyRef = useRef('') const reportVisibleGitHubPRRefreshCandidates = useAppStore( (s) => s.reportVisibleGitHubPRRefreshCandidates @@ -1394,6 +1403,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ), [projectGroups] ) + const projectGroupByIdForHeaderDrag = useMemo( + () => new Map(projectGroups.map((group) => [group.id, group])), + [projectGroups] + ) useEffect( () => @@ -1494,6 +1507,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp groupKey: string rects: readonly WorktreeSidebarDragRect[] draggedIds: readonly string[] + draggingWorktreeId?: string | null }): WorktreeSidebarDropPreview | null => { const container = scrollRef.current if (!container) { @@ -1510,7 +1524,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp scrollTop: container.scrollTop, rects: args.rects, groupIds: group.worktreeIds, - draggedIds: args.draggedIds + draggedIds: args.draggedIds, + draggingWorktreeId: args.draggingWorktreeId }) }, [worktreeDragUnitGroups] @@ -1525,7 +1540,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp pointerY, groupKey: session.sourceGroupKey, rects: session.rects, - draggedIds: session.reorderUnitDraggedIds + draggedIds: session.reorderUnitDraggedIds, + draggingWorktreeId: session.draggingWorktreeId }) }, [computeWorktreeDropForGroup] @@ -1545,7 +1561,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp pointerY: args.pointerY, groupKey, rects: getWorktreeSidebarDragRectsForGroup(container, groupKey), - draggedIds: args.draggedIds + draggedIds: args.draggedIds, + draggingWorktreeId: worktreeDragSessionRef.current?.draggingWorktreeId ?? null }) }, [computeWorktreeDropForGroup] @@ -1558,6 +1575,14 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ), [rows] ) + const sidebarProjectGroupHeaderIdsByBucket = useMemo( + () => + getSidebarOrderedProjectGroupHeaderIdsByBucket( + rows.filter((row): row is Row => row.type !== 'host-header'), + projectGroupByIdForHeaderDrag + ), + [projectGroupByIdForHeaderDrag, rows] + ) const repoHeaderIndexByRepoId = useMemo(() => { const map = new Map() for (const repoIds of sidebarRepoHeaderIdsByBucket.values()) { @@ -1576,12 +1601,43 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp } return map }, [sidebarRepoHeaderIdsByBucket]) + const projectGroupHeaderIndexByGroupId = useMemo(() => { + const map = new Map() + for (const groupIds of sidebarProjectGroupHeaderIdsByBucket.values()) { + groupIds.forEach((groupId, index) => { + map.set(groupId, index) + }) + } + return map + }, [sidebarProjectGroupHeaderIdsByBucket]) + const projectGroupHeaderBucketByGroupId = useMemo(() => { + const map = new Map() + for (const [bucketKey, groupIds] of sidebarProjectGroupHeaderIdsByBucket) { + for (const groupId of groupIds) { + map.set(groupId, bucketKey) + } + } + return map + }, [sidebarProjectGroupHeaderIdsByBucket]) const commitProjectGroupOrder = useCallback( (repoId: string, projectGroupId: string | null, order: number) => { void moveProjectToGroup(repoId, projectGroupId, order) }, [moveProjectToGroup] ) + const commitProjectGroupHeaderOrder = useCallback( + (groupId: string, tabOrder: number) => { + if (!Number.isFinite(tabOrder)) { + return + } + const suppressUntil = + window.performance.now() + USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS + suppressMeasurementAdjustmentUntilRef.current = suppressUntil + directScrollInputUntilRef.current = suppressUntil + void updateProjectGroup(groupId, { tabOrder }) + }, + [updateProjectGroup] + ) // Drag is only meaningful when repo headers are using manual order. The // controller is still constructed for hook order stability when inert. const repoDrag = useRepoHeaderDrag({ @@ -1593,6 +1649,12 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp onCommitProjectGroupOrder: commitProjectGroupOrder, getScrollContainer: () => scrollRef.current }) + const projectGroupDrag = useProjectGroupHeaderDrag({ + sidebarProjectGroupHeaderIdsByBucket, + projectGroupById: projectGroupByIdForHeaderDrag, + onCommitProjectGroupTabOrder: commitProjectGroupHeaderOrder, + getScrollContainer: () => scrollRef.current + }) const [primaryActiveWorktreeRow, setPrimaryActiveWorktreeRow] = useState<{ worktreeId: string rowKey: string @@ -2587,6 +2649,37 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp [assignWorktreeParent, getEligibleLineageDropTarget] ) + const clearReorderedWorktreeParents = useCallback( + (args: { draggedIds: readonly string[]; sourceGroupKey: string }) => { + const sourceGroup = worktreeDragGroups.find((group) => group.key === args.sourceGroupKey) + if (!sourceGroup) { + return + } + const ids = getReorderedWorktreeIdsToUnnest({ + draggedIds: args.draggedIds, + sourceGroupIds: sourceGroup.worktreeIds, + lineageById: worktreeLineageById + }) + if (ids.length === 0) { + return + } + // Why: dropping a nested card onto a reorder line is the escape hatch + // from accidental nesting, so clear only the directly dragged children. + void Promise.all(ids.map((id) => updateWorktreeLineage(id, { noParent: true }))).catch( + (err) => { + console.error('Failed to unnest workspace:', err) + toast.error( + translate( + 'auto.components.sidebar.WorktreeList.failedUnnestWorkspace', + 'Failed to unnest workspace' + ) + ) + } + ) + }, + [updateWorktreeLineage, worktreeDragGroups, worktreeLineageById] + ) + const flushWorktreePointerDrag = useCallback(() => { const drag = worktreePointerDragRef.current if (!drag) { @@ -3101,6 +3194,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp dropIndex: drop.dropIndex }) }) + clearReorderedWorktreeParents({ + draggedIds: drag.draggedIds, + sourceGroupKey: drag.sourceGroupKey + }) } else if (scrollRef.current) { const currentTarget = preferredStatusTarget const currentPreview = currentTarget.status @@ -3157,6 +3254,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp }, [ beginWorktreePointerDrag, clearWorktreeDrag, + clearReorderedWorktreeParents, commitWorktreeLineageParentDrop, computeWorktreeDrop, computeWorktreeStatusDrop, @@ -3504,10 +3602,15 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp dropIndex: drop.dropIndex }) }) + clearReorderedWorktreeParents({ + draggedIds: session.draggedIds, + sourceGroupKey: session.sourceGroupKey + }) clearWorktreeDrag() }, [ clearWorktreeDrag, + clearReorderedWorktreeParents, commitWorktreeLineageParentDrop, computeWorktreeDrop, computeWorktreeStatusDrop, @@ -3757,6 +3860,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp dropIndex: drop.dropIndex }) }) + clearReorderedWorktreeParents({ + draggedIds: session.draggedIds, + sourceGroupKey: session.sourceGroupKey + }) clearWorktreeDrag() } @@ -3764,6 +3871,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp return () => document.removeEventListener('drop', handleDocumentDrop, true) }, [ clearWorktreeDrag, + clearReorderedWorktreeParents, commitWorktreeLineageParentDrop, computeWorktreeDrop, computeWorktreeStatusDrop, @@ -3854,6 +3962,15 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp style={{ top: `${repoDrag.state.dropIndicatorY}px` }} /> ) : null} + {canReorderProjectGroupHeaders && + projectGroupDrag.state.draggingGroupId !== null && + projectGroupDrag.state.dropIndicatorY !== null ? ( +
+ ) : null} {hostDrag.state.draggingHostId !== null && hostDrag.state.dropIndicatorY !== null ? (
1 ) + const isDraggableProjectGroupHeader = Boolean( + canReorderProjectGroupHeaders && + projectGroupIdForHeader && + projectGroupHeaderBucketKey && + (sidebarProjectGroupHeaderIdsByBucket.get(projectGroupHeaderBucketKey)?.length ?? + 0) > 1 + ) const isDraggingThis = canReorderRepoHeaders && repoDrag.state.draggingRepoId !== null && repoDrag.state.draggingRepoId === projectIdForHeader + const isDraggingThisProjectGroup = + canReorderProjectGroupHeaders && + projectGroupDrag.state.draggingGroupId !== null && + projectGroupDrag.state.draggingGroupId === projectGroupIdForHeader const headerWorkspaceStatus = groupBy === 'workspace-status' ? getWorkspaceStatusFromGroupKey(row.key, workspaceStatuses) @@ -4045,6 +4185,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp data-repo-header-id={projectIdForHeader} data-repo-header-index={repoHeaderIndex} data-repo-header-bucket={repoHeaderBucketKey} + data-project-group-header-id={projectGroupIdForHeader} + data-project-group-header-index={projectGroupHeaderIndex} + data-project-group-header-bucket={projectGroupHeaderBucketKey} data-workspace-status-drop-target={headerWorkspaceStatus ? '' : undefined} data-workspace-status={headerWorkspaceStatus ?? undefined} data-workspace-pin-drop-target={isPinnedHeader ? '' : undefined} @@ -4053,7 +4196,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp 'cursor-pointer', highlightedRevealRowKey === row.key && 'rounded-md bg-worktree-sidebar-accent ring-1 ring-worktree-sidebar-ring/50', - isDraggingThis && + (isDraggingThis || isDraggingThisProjectGroup) && 'bg-accent/80 ring-1 ring-ring/40 shadow-md rounded-md scale-[1.01]', headerWorkspaceStatus && dragOverStatus === headerWorkspaceStatus && @@ -4102,15 +4245,25 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp {row.icon ? (
repoDrag.onHandlePointerDown(event, projectIdForHeader) - : undefined + : isDraggableProjectGroupHeader && projectGroupIdForHeader + ? (event) => + projectGroupDrag.onHandlePointerDown( + event, + projectGroupIdForHeader + ) + : undefined } > {row.repo ? ( @@ -4615,11 +4768,12 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp onClickCapture={handleWorktreeRowClickCapture} onDoubleClick={nested ? stopNestedWorktreeCardBubble : undefined} onDragStart={nested ? stopNestedWorktreeCardBubble : undefined} - onPointerDown={(event) => - nested - ? undefined - : handleWorktreeRowPointerDown(event, itemRow.worktree.id, itemRow.rowKey) - } + onPointerDown={(event) => { + if (nested) { + event.stopPropagation() + } + handleWorktreeRowPointerDown(event, itemRow.worktree.id, itemRow.rowKey) + }} style={{ paddingLeft: surfaceInset > 0 ? `${surfaceInset}px` : undefined }} diff --git a/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.begin-editing.test.tsx b/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.begin-editing.test.tsx index 7b017362b1d..8958dd58543 100644 --- a/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.begin-editing.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.begin-editing.test.tsx @@ -68,6 +68,7 @@ async function renderTitleRename(props: { beginEditing: boolean disabled?: boolean onBeginEditingConsumed: () => void + onRename?: (displayName: string) => Promise | void }): Promise { reactHookRuntime.index = 0 const module = await import('./WorktreeTitleInlineRename') @@ -120,6 +121,27 @@ function findElementsByType(node: unknown, typeName: string): ReactElementLike[] return results } +function pressInputKey( + input: ReactElementLike, + key: string, + options?: { isComposing?: boolean; keyCode?: number } +): { + preventDefault: ReturnType + stopPropagation: ReturnType +} { + const event = { + key, + nativeEvent: { + isComposing: options?.isComposing ?? false, + keyCode: options?.keyCode ?? 13 + }, + preventDefault: vi.fn(), + stopPropagation: vi.fn() + } + ;(input.props.onKeyDown as (nextEvent: typeof event) => void)(event) + return event +} + describe('WorktreeTitleInlineRename beginEditing', () => { beforeEach(() => { reactHookRuntime.states = [] @@ -152,4 +174,32 @@ describe('WorktreeTitleInlineRename beginEditing', () => { expect(onBeginEditingConsumed).toHaveBeenCalledTimes(1) expect(findElementsByType(rerender, 'input')).toHaveLength(0) }) + + it('ignores IME composition Enter before committing the edited title', async () => { + const onBeginEditingConsumed = vi.fn() + const onRename = vi.fn() + + await renderTitleRename({ beginEditing: true, onBeginEditingConsumed, onRename }) + let rerender = expandNode( + await renderTitleRename({ beginEditing: false, onBeginEditingConsumed, onRename }) + ) + let input = findElementsByType(rerender, 'input')[0] + ;(input.props.onChange as (event: { target: { value: string } }) => void)({ + target: { value: '日本語 workspace' } + }) + rerender = expandNode( + await renderTitleRename({ beginEditing: false, onBeginEditingConsumed, onRename }) + ) + input = findElementsByType(rerender, 'input')[0] + + const composingEvent = pressInputKey(input, 'Enter', { isComposing: true }) + + expect(composingEvent.preventDefault).not.toHaveBeenCalled() + expect(onRename).not.toHaveBeenCalled() + + pressInputKey(input, 'Enter') + await Promise.resolve() + + expect(onRename).toHaveBeenCalledWith('日本語 workspace') + }) }) diff --git a/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx b/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx index 914c3eb2f9a..d7a98fe2981 100644 --- a/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx +++ b/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx @@ -4,6 +4,7 @@ import { toast } from 'sonner' import { Input } from '@/components/ui/input' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { translate } from '@/i18n/i18n' export type WorktreeTitleRenameCommit = { kind: 'cancel' } | { kind: 'save'; displayName: string } @@ -211,6 +212,11 @@ export function WorktreeTitleInlineRename({ const handleKeyDown = useCallback( (event: React.KeyboardEvent) => { event.stopPropagation() + // Why: an Enter that only confirms a CJK IME candidate must not commit the + // rename; wait for a non-composition Enter. + if (isImeCompositionKeyDown(event)) { + return + } if (event.key === 'Enter') { event.preventDefault() void commitRename() diff --git a/src/renderer/src/components/sidebar/project-group-header-dom.test.ts b/src/renderer/src/components/sidebar/project-group-header-dom.test.ts new file mode 100644 index 00000000000..266c85c4893 --- /dev/null +++ b/src/renderer/src/components/sidebar/project-group-header-dom.test.ts @@ -0,0 +1,25 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +function readWorktreeListSource(): string { + return readFileSync(fileURLToPath(new URL('./WorktreeList.tsx', import.meta.url)), 'utf8') +} + +describe('Project Group header drag DOM source', () => { + it('renders concrete Project Group header drag attributes separately from repo headers', () => { + const source = readWorktreeListSource() + + expect(source).toContain('data-project-group-header-id={projectGroupIdForHeader}') + expect(source).toContain('data-project-group-header-index={projectGroupHeaderIndex}') + expect(source).toContain('data-project-group-header-bucket={projectGroupHeaderBucketKey}') + expect(source).toContain('data-project-group-header-drag-handle=') + }) + + it('commits Project Group manual sorting through updateProjectGroup tabOrder', () => { + const source = readWorktreeListSource() + + expect(source).toContain('const updateProjectGroup = useAppStore((s) => s.updateProjectGroup)') + expect(source).toContain('void updateProjectGroup(groupId, { tabOrder })') + }) +}) diff --git a/src/renderer/src/components/sidebar/project-group-header-drag-commit.test.ts b/src/renderer/src/components/sidebar/project-group-header-drag-commit.test.ts new file mode 100644 index 00000000000..ef873d9bf02 --- /dev/null +++ b/src/renderer/src/components/sidebar/project-group-header-drag-commit.test.ts @@ -0,0 +1,111 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from 'vitest' + +import { commitProjectGroupHeaderDragDrop } from './project-group-header-drag-commit' +import type { ProjectGroupHeaderDragSession } from './project-group-header-drag-contract' +import type { ProjectGroup } from '../../../../shared/types' + +function group(id: string, overrides: Partial = {}): ProjectGroup { + return { + id, + name: id, + parentPath: null, + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +function makeSession( + groupId: string, + sidebarProjectGroupHeaderIds: readonly string[] +): ProjectGroupHeaderDragSession { + return { + groupId, + bucketKey: 'root', + sidebarProjectGroupHeaderIds, + pointerId: 1, + headerRects: [], + handleEl: document.createElement('div'), + startX: 0, + startY: 0, + latestPointerY: 0, + promoted: true + } +} + +describe('commitProjectGroupHeaderDragDrop', () => { + it('commits a finite tabOrder for the dragged Project Group', () => { + const onCommitProjectGroupTabOrder = vi.fn() + const groups = [ + group('a', { tabOrder: 0 }), + group('b', { tabOrder: 10 }), + group('c', { tabOrder: 20 }) + ] + const projectGroupById = new Map(groups.map((entry) => [entry.id, entry])) + + commitProjectGroupHeaderDragDrop({ + session: makeSession('c', ['a', 'b', 'c']), + sidebarDropIndex: 0, + projectGroupById, + onCommitProjectGroupTabOrder + }) + + expect(onCommitProjectGroupTabOrder).toHaveBeenCalledWith('c', -1) + }) + + it('computes order only from the captured sibling bucket', () => { + const onCommitProjectGroupTabOrder = vi.fn() + const root = group('root') + const siblingA = group('sibling-a', { parentGroupId: root.id, tabOrder: 0 }) + const siblingB = group('sibling-b', { parentGroupId: root.id, tabOrder: 10 }) + const otherParentGroup = group('other-parent-group', { tabOrder: -100 }) + const projectGroupById = new Map( + [root, siblingA, siblingB, otherParentGroup].map((entry) => [entry.id, entry]) + ) + + commitProjectGroupHeaderDragDrop({ + session: makeSession('sibling-b', ['sibling-a', 'sibling-b']), + sidebarDropIndex: 0, + projectGroupById, + onCommitProjectGroupTabOrder + }) + + expect(onCommitProjectGroupTabOrder).toHaveBeenCalledWith('sibling-b', -1) + }) + + it('does not commit when the drop keeps the group in the same slot', () => { + const onCommitProjectGroupTabOrder = vi.fn() + const groups = [group('a'), group('b'), group('c')] + const projectGroupById = new Map(groups.map((entry) => [entry.id, entry])) + + commitProjectGroupHeaderDragDrop({ + session: makeSession('b', ['a', 'b', 'c']), + sidebarDropIndex: 2, + projectGroupById, + onCommitProjectGroupTabOrder + }) + + expect(onCommitProjectGroupTabOrder).not.toHaveBeenCalled() + }) + + it('does not commit when a stale session no longer contains the dragged group', () => { + const onCommitProjectGroupTabOrder = vi.fn() + const groups = [group('a'), group('b'), group('c')] + const projectGroupById = new Map(groups.map((entry) => [entry.id, entry])) + + commitProjectGroupHeaderDragDrop({ + session: makeSession('c', ['a', 'b']), + sidebarDropIndex: 0, + projectGroupById, + onCommitProjectGroupTabOrder + }) + + expect(onCommitProjectGroupTabOrder).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/sidebar/project-group-header-drag-commit.ts b/src/renderer/src/components/sidebar/project-group-header-drag-commit.ts new file mode 100644 index 00000000000..3ef85959577 --- /dev/null +++ b/src/renderer/src/components/sidebar/project-group-header-drag-commit.ts @@ -0,0 +1,50 @@ +import { + getProjectGroupTabOrderForSidebarDrop, + mapSidebarProjectGroupDropIndexToSiblingInsertIndex +} from './project-group-header-drop' +import type { ProjectGroupHeaderDragSession } from './project-group-header-drag-contract' +import type { ProjectGroup } from '../../../../shared/types' + +export function commitProjectGroupHeaderDragDrop(args: { + session: ProjectGroupHeaderDragSession + sidebarDropIndex: number + projectGroupById: ReadonlyMap + onCommitProjectGroupTabOrder: (groupId: string, tabOrder: number) => void +}): void { + const draggedGroup = args.projectGroupById.get(args.session.groupId) + if (!draggedGroup) { + return + } + + const sidebarProjectGroupHeaderIds = args.session.sidebarProjectGroupHeaderIds + const sourceIndex = sidebarProjectGroupHeaderIds.indexOf(args.session.groupId) + if (sourceIndex === -1) { + return + } + if (args.sidebarDropIndex === sourceIndex) { + return + } + + const siblings = sidebarProjectGroupHeaderIds + .filter((groupId) => groupId !== args.session.groupId) + .map((groupId) => args.projectGroupById.get(groupId)) + .filter((group): group is ProjectGroup => group !== undefined) + const siblingDropIndex = mapSidebarProjectGroupDropIndexToSiblingInsertIndex({ + sidebarDropIndex: args.sidebarDropIndex, + sourceIndex, + siblingCount: siblings.length + }) + const sourceIndexInSiblings = Math.min(sourceIndex, siblings.length) + if (siblingDropIndex === sourceIndexInSiblings) { + return + } + + const tabOrder = getProjectGroupTabOrderForSidebarDrop({ + siblings, + dropIndex: siblingDropIndex + }) + if (!Number.isFinite(tabOrder)) { + return + } + args.onCommitProjectGroupTabOrder(draggedGroup.id, tabOrder) +} diff --git a/src/renderer/src/components/sidebar/project-group-header-drag-contract.ts b/src/renderer/src/components/sidebar/project-group-header-drag-contract.ts new file mode 100644 index 00000000000..7127ce6bd8c --- /dev/null +++ b/src/renderer/src/components/sidebar/project-group-header-drag-contract.ts @@ -0,0 +1,77 @@ +import type { PointerEvent } from 'react' + +import type { + ProjectGroupHeaderDragBucketKey, + ProjectGroupHeaderDragRect +} from './project-group-header-drop' +import type { ProjectGroup } from '../../../../shared/types' + +export type ProjectGroupDragState = { + draggingGroupId: string | null + dropIndex: number | null + dropIndicatorY: number | null +} + +export const INITIAL_PROJECT_GROUP_DRAG_STATE: ProjectGroupDragState = { + draggingGroupId: null, + dropIndex: null, + dropIndicatorY: null +} + +export type UseProjectGroupHeaderDragArgs = { + sidebarProjectGroupHeaderIdsByBucket: ReadonlyMap< + ProjectGroupHeaderDragBucketKey, + readonly string[] + > + projectGroupById: ReadonlyMap + onCommitProjectGroupTabOrder: (groupId: string, tabOrder: number) => void + getScrollContainer: () => HTMLElement | null +} + +export type ProjectGroupHeaderDragController = { + state: ProjectGroupDragState + onHandlePointerDown: (event: PointerEvent, groupId: string) => void +} + +export type ProjectGroupHeaderDragSession = { + groupId: string + bucketKey: ProjectGroupHeaderDragBucketKey + sidebarProjectGroupHeaderIds: readonly string[] + pointerId: number + headerRects: ProjectGroupHeaderDragRect[] + handleEl: HTMLElement + startX: number + startY: number + latestPointerY: number + promoted: boolean +} + +export const PROJECT_GROUP_HEADER_DRAG_THRESHOLD_PX = 4 + +const PROJECT_GROUP_HEADER_DRAG_HANDLE_SELECTOR = '[data-project-group-header-drag-handle]' + +const PROJECT_GROUP_HEADER_ACTION_SELECTOR = + '[data-repo-header-action], [data-repo-header-collapse-affordance], button, a, input, textarea, select, [contenteditable=""], [contenteditable="true"]' + +export function isProjectGroupHeaderDragHandleTarget( + target: EventTarget | null, + currentTarget: HTMLElement +): boolean { + if (!(target instanceof HTMLElement)) { + return false + } + const dragHandle = target.closest(PROJECT_GROUP_HEADER_DRAG_HANDLE_SELECTOR) + return dragHandle !== null && currentTarget.contains(dragHandle) +} + +export function isProjectGroupHeaderActionTarget( + target: EventTarget | null, + currentTarget: HTMLElement +): boolean { + if (!(target instanceof HTMLElement) || target === currentTarget) { + return false + } + return ( + currentTarget.contains(target) && target.closest(PROJECT_GROUP_HEADER_ACTION_SELECTOR) !== null + ) +} diff --git a/src/renderer/src/components/sidebar/project-group-header-drag-start.ts b/src/renderer/src/components/sidebar/project-group-header-drag-start.ts new file mode 100644 index 00000000000..89eb5bd9dd0 --- /dev/null +++ b/src/renderer/src/components/sidebar/project-group-header-drag-start.ts @@ -0,0 +1,64 @@ +import type { PointerEvent } from 'react' + +import { + getProjectGroupHeaderDragBucketKey, + measureProjectGroupHeaderDragRects, + type ProjectGroupHeaderDragBucketKey +} from './project-group-header-drop' +import { + isProjectGroupHeaderActionTarget, + isProjectGroupHeaderDragHandleTarget, + type ProjectGroupHeaderDragSession +} from './project-group-header-drag-contract' +import type { ProjectGroup } from '../../../../shared/types' + +export function createProjectGroupHeaderDragSession(args: { + event: PointerEvent + groupId: string + projectGroupById: ReadonlyMap + sidebarProjectGroupHeaderIdsByBucket: ReadonlyMap< + ProjectGroupHeaderDragBucketKey, + readonly string[] + > + getScrollContainer: () => HTMLElement | null +}): ProjectGroupHeaderDragSession | null { + if (args.event.button !== 0) { + return null + } + if (!isProjectGroupHeaderDragHandleTarget(args.event.target, args.event.currentTarget)) { + return null + } + if (isProjectGroupHeaderActionTarget(args.event.target, args.event.currentTarget)) { + return null + } + const group = args.projectGroupById.get(args.groupId) + if (!group) { + return null + } + const bucketKey = getProjectGroupHeaderDragBucketKey(group, args.projectGroupById) + const sidebarProjectGroupHeaderIds = + args.sidebarProjectGroupHeaderIdsByBucket.get(bucketKey) ?? [] + // Why: a lone group in a parent bucket cannot move without reparenting. + if (sidebarProjectGroupHeaderIds.length <= 1) { + return null + } + const container = args.getScrollContainer() + if (!container) { + return null + } + const handleEl = args.event.currentTarget + // Why: defer pointer capture until the threshold so ordinary group header + // clicks still toggle collapse through the row handler. + return { + groupId: args.groupId, + bucketKey, + sidebarProjectGroupHeaderIds, + pointerId: args.event.pointerId, + headerRects: measureProjectGroupHeaderDragRects(container, bucketKey), + handleEl, + startX: args.event.clientX, + startY: args.event.clientY, + latestPointerY: args.event.clientY, + promoted: false + } +} diff --git a/src/renderer/src/components/sidebar/project-group-header-drag.ts b/src/renderer/src/components/sidebar/project-group-header-drag.ts new file mode 100644 index 00000000000..6896dff069f --- /dev/null +++ b/src/renderer/src/components/sidebar/project-group-header-drag.ts @@ -0,0 +1,304 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +import { + computeProjectGroupHeaderDropPreview, + measureProjectGroupHeaderDragRects +} from './project-group-header-drop' +import { commitProjectGroupHeaderDragDrop } from './project-group-header-drag-commit' +import { + INITIAL_PROJECT_GROUP_DRAG_STATE, + PROJECT_GROUP_HEADER_DRAG_THRESHOLD_PX, + type ProjectGroupDragState, + type ProjectGroupHeaderDragController, + type ProjectGroupHeaderDragSession, + type UseProjectGroupHeaderDragArgs +} from './project-group-header-drag-contract' +import { createProjectGroupHeaderDragSession } from './project-group-header-drag-start' +import { getWorktreeSidebarDragAutoscroll } from './worktree-sidebar-drag-autoscroll' + +// Why pointer events instead of HTML5 DnD: Project Group rows are virtualized +// and may unmount while scrolling; cached row-model indices keep drops stable. + +export function useProjectGroupHeaderDrag({ + sidebarProjectGroupHeaderIdsByBucket, + projectGroupById, + onCommitProjectGroupTabOrder, + getScrollContainer +}: UseProjectGroupHeaderDragArgs): ProjectGroupHeaderDragController { + const [state, setState] = useState(INITIAL_PROJECT_GROUP_DRAG_STATE) + const [sessionArmed, setSessionArmed] = useState(false) + const latestDropIndexRef = useRef(null) + latestDropIndexRef.current = state.dropIndex + const sidebarProjectGroupHeaderIdsByBucketRef = useRef(sidebarProjectGroupHeaderIdsByBucket) + sidebarProjectGroupHeaderIdsByBucketRef.current = sidebarProjectGroupHeaderIdsByBucket + const projectGroupByIdRef = useRef(projectGroupById) + projectGroupByIdRef.current = projectGroupById + const onCommitProjectGroupTabOrderRef = useRef(onCommitProjectGroupTabOrder) + onCommitProjectGroupTabOrderRef.current = onCommitProjectGroupTabOrder + const getContainerRef = useRef(getScrollContainer) + getContainerRef.current = getScrollContainer + const autoscrollLastFrameTimeRef = useRef(null) + const autoscrollFrameIdRef = useRef(null) + + const dragSessionRef = useRef(null) + const clickSwallowTimeoutRef = useRef | null>(null) + + const refreshHeaderRects = useCallback(() => { + const container = getContainerRef.current() + const session = dragSessionRef.current + if (!container || !session) { + return [] + } + const rects = measureProjectGroupHeaderDragRects(container, session.bucketKey) + session.headerRects = rects + return rects + }, []) + + const computeDrop = useCallback( + (pointerY: number): { dropIndex: number; dropIndicatorY: number } | null => { + const session = dragSessionRef.current + const container = getContainerRef.current() + if (!session || !container) { + return null + } + const containerRect = container.getBoundingClientRect() + return computeProjectGroupHeaderDropPreview({ + pointerY, + containerTop: containerRect.top, + scrollTop: container.scrollTop, + rects: session.headerRects, + sidebarProjectGroupHeaderIds: session.sidebarProjectGroupHeaderIds + }) + }, + [] + ) + + const cancelAutoscroll = useCallback(() => { + if (autoscrollFrameIdRef.current !== null) { + window.cancelAnimationFrame(autoscrollFrameIdRef.current) + autoscrollFrameIdRef.current = null + } + autoscrollLastFrameTimeRef.current = null + }, []) + + const endDrag = useCallback( + (commit: boolean) => { + cancelAutoscroll() + const session = dragSessionRef.current + if (!session) { + setState(INITIAL_PROJECT_GROUP_DRAG_STATE) + setSessionArmed(false) + return + } + try { + session.handleEl.releasePointerCapture(session.pointerId) + } catch { + // capture may already be released (pointercancel, element unmounted) + } + if (session.promoted) { + const handleEl = session.handleEl + const swallow = (event: MouseEvent): void => { + const target = event.target as Node | null + if (target && handleEl.contains(target)) { + event.stopPropagation() + event.preventDefault() + } + window.removeEventListener('click', swallow, true) + } + window.addEventListener('click', swallow, true) + clickSwallowTimeoutRef.current = setTimeout(() => { + window.removeEventListener('click', swallow, true) + clickSwallowTimeoutRef.current = null + }, 0) + } + const sidebarDropIndex = + commit && session.promoted && latestDropIndexRef.current !== null + ? latestDropIndexRef.current + : null + dragSessionRef.current = null + setState(INITIAL_PROJECT_GROUP_DRAG_STATE) + setSessionArmed(false) + if (sidebarDropIndex === null) { + return + } + + commitProjectGroupHeaderDragDrop({ + session, + sidebarDropIndex, + projectGroupById: projectGroupByIdRef.current, + onCommitProjectGroupTabOrder: onCommitProjectGroupTabOrderRef.current + }) + }, + [cancelAutoscroll] + ) + + const runAutoscrollFrame = useCallback( + (frameTime: number) => { + autoscrollFrameIdRef.current = null + const session = dragSessionRef.current + const container = getContainerRef.current() + if (!session?.promoted || !container) { + cancelAutoscroll() + return + } + + const previousFrameTime = autoscrollLastFrameTimeRef.current ?? frameTime + autoscrollLastFrameTimeRef.current = frameTime + const autoscroll = getWorktreeSidebarDragAutoscroll({ + point: { clientX: 0, clientY: session.latestPointerY }, + containerRect: container.getBoundingClientRect(), + scrollTop: container.scrollTop, + scrollHeight: container.scrollHeight, + clientHeight: container.clientHeight, + elapsedMs: frameTime - previousFrameTime + }) + if (autoscroll) { + container.scrollTop = autoscroll.scrollTop + refreshHeaderRects() + } + + const drop = computeDrop(session.latestPointerY) + if (drop) { + setState((prev) => + prev.dropIndex === drop.dropIndex && prev.dropIndicatorY === drop.dropIndicatorY + ? prev + : { draggingGroupId: session.groupId, ...drop } + ) + } + + autoscrollFrameIdRef.current = window.requestAnimationFrame(runAutoscrollFrame) + }, + [cancelAutoscroll, computeDrop, refreshHeaderRects] + ) + + const ensureAutoscroll = useCallback(() => { + if (autoscrollFrameIdRef.current !== null) { + return + } + autoscrollLastFrameTimeRef.current = null + autoscrollFrameIdRef.current = window.requestAnimationFrame(runAutoscrollFrame) + }, [runAutoscrollFrame]) + + useEffect(() => { + if (!sessionArmed) { + return + } + const onPointerMove = (event: PointerEvent): void => { + const session = dragSessionRef.current + if (!session || event.pointerId !== session.pointerId) { + return + } + session.latestPointerY = event.clientY + if (!session.promoted) { + const dx = event.clientX - session.startX + const dy = event.clientY - session.startY + if ( + dx * dx + dy * dy < + PROJECT_GROUP_HEADER_DRAG_THRESHOLD_PX * PROJECT_GROUP_HEADER_DRAG_THRESHOLD_PX + ) { + return + } + session.promoted = true + // Why: virtualized headers may detach during drag; global listeners + // still keep the operation alive if pointer capture is unavailable. + if (session.handleEl.isConnected) { + try { + session.handleEl.setPointerCapture(session.pointerId) + } catch { + // Ignore capture failure; global listeners will handle the drag. + } + } + refreshHeaderRects() + setState({ draggingGroupId: session.groupId, dropIndex: null, dropIndicatorY: null }) + } + refreshHeaderRects() + const drop = computeDrop(event.clientY) + if (drop) { + setState((prev) => + prev.dropIndex === drop.dropIndex && prev.dropIndicatorY === drop.dropIndicatorY + ? prev + : { draggingGroupId: session.groupId, ...drop } + ) + } + ensureAutoscroll() + } + const onPointerUp = (event: PointerEvent): void => { + const session = dragSessionRef.current + if (!session || event.pointerId !== session.pointerId) { + return + } + endDrag(true) + } + const onPointerCancel = (event: PointerEvent): void => { + const session = dragSessionRef.current + if (!session || event.pointerId !== session.pointerId) { + return + } + endDrag(false) + } + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { + endDrag(false) + } + } + const onBlur = (): void => endDrag(false) + + window.addEventListener('pointermove', onPointerMove) + window.addEventListener('pointerup', onPointerUp) + window.addEventListener('pointercancel', onPointerCancel) + window.addEventListener('keydown', onKeyDown) + window.addEventListener('blur', onBlur) + return () => { + window.removeEventListener('pointermove', onPointerMove) + window.removeEventListener('pointerup', onPointerUp) + window.removeEventListener('pointercancel', onPointerCancel) + window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('blur', onBlur) + cancelAutoscroll() + if (clickSwallowTimeoutRef.current !== null) { + clearTimeout(clickSwallowTimeoutRef.current) + clickSwallowTimeoutRef.current = null + } + } + }, [cancelAutoscroll, computeDrop, endDrag, ensureAutoscroll, refreshHeaderRects, sessionArmed]) + + useEffect(() => { + if (state.draggingGroupId === null) { + return + } + const body = document.body + const prevCursor = body.style.cursor + const prevUserSelect = body.style.userSelect + body.style.cursor = 'grabbing' + body.style.userSelect = 'none' + return () => { + body.style.cursor = prevCursor + body.style.userSelect = prevUserSelect + } + }, [state.draggingGroupId]) + + const onHandlePointerDown = useCallback( + (event: React.PointerEvent, groupId: string) => { + const session = createProjectGroupHeaderDragSession({ + event, + groupId, + projectGroupById: projectGroupByIdRef.current, + sidebarProjectGroupHeaderIdsByBucket: sidebarProjectGroupHeaderIdsByBucketRef.current, + getScrollContainer: getContainerRef.current + }) + if (!session) { + return + } + dragSessionRef.current = session + setSessionArmed(true) + }, + [] + ) + + return { state, onHandlePointerDown } +} + +export { + isProjectGroupHeaderActionTarget, + isProjectGroupHeaderDragHandleTarget +} from './project-group-header-drag-contract' diff --git a/src/renderer/src/components/sidebar/project-group-header-drop.test.ts b/src/renderer/src/components/sidebar/project-group-header-drop.test.ts new file mode 100644 index 00000000000..5abf7648677 --- /dev/null +++ b/src/renderer/src/components/sidebar/project-group-header-drop.test.ts @@ -0,0 +1,169 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from 'vitest' + +import { + computeProjectGroupHeaderDropPreview, + getProjectGroupHeaderDragBucketKey, + getProjectGroupTabOrderForSidebarDrop, + getSidebarOrderedProjectGroupHeaderIdsByBucket, + mapSidebarProjectGroupDropIndexToSiblingInsertIndex +} from './project-group-header-drop' +import type { Row } from './worktree-list-groups' +import type { ProjectGroup, Repo } from '../../../../shared/types' + +function group(id: string, overrides: Partial = {}): ProjectGroup { + return { + id, + name: id, + parentPath: null, + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +describe('getProjectGroupHeaderDragBucketKey', () => { + it('uses root for top-level groups', () => { + expect(getProjectGroupHeaderDragBucketKey(group('root'))).toBe('root') + }) + + it('scopes child groups to their parent bucket', () => { + const root = group('root') + const child = group('child', { parentGroupId: root.id }) + const groupsById = new Map([ + [root.id, root], + [child.id, child] + ]) + + expect(getProjectGroupHeaderDragBucketKey(child, groupsById)).toBe('parent:root') + }) + + it('falls back to root when persisted parent metadata is missing', () => { + const orphan = group('orphan', { parentGroupId: 'missing' }) + + expect(getProjectGroupHeaderDragBucketKey(orphan, new Map([[orphan.id, orphan]]))).toBe('root') + }) +}) + +describe('getSidebarOrderedProjectGroupHeaderIdsByBucket', () => { + it('groups Project Group headers by effective parent bucket', () => { + const rootA = group('root-a') + const rootB = group('root-b') + const childA = group('child-a', { parentGroupId: rootA.id }) + const repo = { id: 'repo-a', projectGroupId: rootA.id } as Repo + const groupsById = new Map([ + [rootA.id, rootA], + [rootB.id, rootB], + [childA.id, childA] + ]) + const rows = [ + { + type: 'header', + key: 'project-group:root-a', + label: 'A', + count: 0, + tone: '', + projectGroup: rootA + }, + { type: 'header', key: 'repo:repo-a', label: 'repo', count: 0, tone: '', repo }, + { + type: 'header', + key: 'project-group:child-a', + label: 'A child', + count: 0, + tone: '', + projectGroup: childA + }, + { + type: 'header', + key: 'project-group:root-b', + label: 'B', + count: 0, + tone: '', + projectGroup: rootB + } + ] as Row[] + + expect(getSidebarOrderedProjectGroupHeaderIdsByBucket(rows, groupsById)).toEqual( + new Map([ + ['root', ['root-a', 'root-b']], + ['parent:root-a', ['child-a']] + ]) + ) + }) +}) + +describe('mapSidebarProjectGroupDropIndexToSiblingInsertIndex', () => { + it('keeps upward drops at the same target index after removing the source', () => { + expect( + mapSidebarProjectGroupDropIndexToSiblingInsertIndex({ + sidebarDropIndex: 0, + sourceIndex: 2, + siblingCount: 2 + }) + ).toBe(0) + }) + + it('shifts downward drops because the source header is removed first', () => { + expect( + mapSidebarProjectGroupDropIndexToSiblingInsertIndex({ + sidebarDropIndex: 3, + sourceIndex: 0, + siblingCount: 2 + }) + ).toBe(2) + }) +}) + +describe('computeProjectGroupHeaderDropPreview', () => { + it('uses row-model header indices instead of mounted subset order', () => { + const preview = computeProjectGroupHeaderDropPreview({ + pointerY: 105, + containerTop: 0, + scrollTop: 0, + sidebarProjectGroupHeaderIds: ['a', 'b', 'c', 'd', 'e'], + rects: [ + { groupId: 'b', bucketKey: 'root', headerIndex: 1, top: 100, bottom: 128 }, + { groupId: 'c', bucketKey: 'root', headerIndex: 2, top: 200, bottom: 228 }, + { groupId: 'd', bucketKey: 'root', headerIndex: 3, top: 300, bottom: 328 } + ] + }) + + expect(preview).toEqual({ dropIndex: 1, dropIndicatorY: 96 }) + }) +}) + +describe('getProjectGroupTabOrderForSidebarDrop', () => { + it('uses a midpoint between sibling tab orders when there is room', () => { + expect( + getProjectGroupTabOrderForSidebarDrop({ + siblings: [group('a', { tabOrder: 0 }), group('b', { tabOrder: 10 })], + dropIndex: 1 + }) + ).toBe(5) + }) + + it('assigns an order before the first sibling', () => { + expect( + getProjectGroupTabOrderForSidebarDrop({ + siblings: [group('a', { tabOrder: 10 }), group('b', { tabOrder: 20 })], + dropIndex: 0 + }) + ).toBe(9) + }) + + it('keeps a deterministic finite anchor when sibling orders collide', () => { + const tabOrder = getProjectGroupTabOrderForSidebarDrop({ + siblings: [group('a', { tabOrder: 10 }), group('b', { tabOrder: 10 })], + dropIndex: 1 + }) + + expect(tabOrder).toBe(11) + expect(Number.isFinite(tabOrder)).toBe(true) + }) +}) diff --git a/src/renderer/src/components/sidebar/project-group-header-drop.ts b/src/renderer/src/components/sidebar/project-group-header-drop.ts new file mode 100644 index 00000000000..95546266779 --- /dev/null +++ b/src/renderer/src/components/sidebar/project-group-header-drop.ts @@ -0,0 +1,210 @@ +import { getWorktreeSidebarBoundaryDrop } from './worktree-sidebar-drag-autoscroll' +import type { Row } from './worktree-list-groups' +import type { ProjectGroup } from '../../../../shared/types' + +export type ProjectGroupHeaderDragBucketKey = string + +export type ProjectGroupHeaderDragRect = { + groupId: string + bucketKey: ProjectGroupHeaderDragBucketKey + // Index among sibling Project Group headers in the row model, not mounted DOM. + headerIndex: number + top: number + bottom: number +} + +export type ProjectGroupHeaderDropPreview = { + dropIndex: number + dropIndicatorY: number +} + +const INDICATOR_GAP_PX = 4 +const ROOT_PROJECT_GROUP_HEADER_BUCKET = 'root' + +type SidebarProjectGroupHeader = ProjectGroup | { id: null } | undefined + +function isConcreteProjectGroup( + projectGroup: SidebarProjectGroupHeader +): projectGroup is ProjectGroup { + return typeof projectGroup?.id === 'string' +} + +export function getProjectGroupHeaderDragBucketKey( + group: Pick, + projectGroupById?: ReadonlyMap +): ProjectGroupHeaderDragBucketKey { + const parentGroupId = group.parentGroupId ?? null + if (!parentGroupId) { + return ROOT_PROJECT_GROUP_HEADER_BUCKET + } + if (projectGroupById && !projectGroupById.has(parentGroupId)) { + return ROOT_PROJECT_GROUP_HEADER_BUCKET + } + return `parent:${parentGroupId}` +} + +export function getSidebarOrderedProjectGroupHeaderIdsByBucket( + rows: readonly Row[], + projectGroupById?: ReadonlyMap +): Map { + const buckets = new Map() + for (const row of rows) { + if (row.type !== 'header' || row.repo || !isConcreteProjectGroup(row.projectGroup)) { + continue + } + const bucketKey = getProjectGroupHeaderDragBucketKey(row.projectGroup, projectGroupById) + const list = buckets.get(bucketKey) ?? [] + list.push(row.projectGroup.id) + buckets.set(bucketKey, list) + } + return buckets +} + +export function getProjectGroupTabOrderForSidebarDrop(args: { + siblings: readonly ProjectGroup[] + dropIndex: number +}): number { + const ordered = args.siblings.slice() + if (ordered.length === 0) { + return 0 + } + const getOrder = (group: ProjectGroup | undefined): number | undefined => + group && Number.isFinite(group.tabOrder) ? group.tabOrder : undefined + const before = getOrder(ordered[args.dropIndex - 1]) + const after = getOrder(ordered[args.dropIndex]) + if (before === undefined && after === undefined) { + return 0 + } + if (before === undefined) { + return after !== undefined ? after - 1 : 0 + } + if (after === undefined) { + return before + 1 + } + if (after > before) { + const midpoint = before + (after - before) / 2 + return Number.isFinite(midpoint) ? midpoint : before / 2 + after / 2 + } + // Why: duplicate legacy ranks leave no slot between siblings; a finite anchor + // lets the next drag establish a stable persisted order. + return before + 1 +} + +export function mapSidebarProjectGroupDropIndexToSiblingInsertIndex(args: { + sidebarDropIndex: number + sourceIndex: number + siblingCount: number +}): number { + // Why: sidebar drop indices include the dragged header, but tabOrder is + // computed against the sibling list after that header is removed. + const adjustedDropIndex = + args.sourceIndex >= 0 && args.sidebarDropIndex > args.sourceIndex + ? args.sidebarDropIndex - 1 + : args.sidebarDropIndex + return Math.max(0, Math.min(args.siblingCount, adjustedDropIndex)) +} + +function getVirtualRowStart(virtualRow: HTMLElement | null): number | null { + if (!virtualRow) { + return null + } + const rawStart = virtualRow.getAttribute('data-worktree-virtual-row-start') + if (rawStart === null) { + return null + } + const start = Number(rawStart) + return Number.isFinite(start) ? start : null +} + +export function measureProjectGroupHeaderDragRects( + container: HTMLElement, + bucketKey?: ProjectGroupHeaderDragBucketKey +): ProjectGroupHeaderDragRect[] { + const containerRect = container.getBoundingClientRect() + const rects: ProjectGroupHeaderDragRect[] = [] + container.querySelectorAll('[data-project-group-header-id]').forEach((element) => { + const groupId = element.getAttribute('data-project-group-header-id') + const elementBucketKey = element.getAttribute('data-project-group-header-bucket') + const rawHeaderIndex = element.getAttribute('data-project-group-header-index') + const headerIndex = rawHeaderIndex === null ? Number.NaN : Number(rawHeaderIndex) + if (!groupId || !elementBucketKey || !Number.isFinite(headerIndex)) { + return + } + if (bucketKey !== undefined && elementBucketKey !== bucketKey) { + return + } + const rect = element.getBoundingClientRect() + const virtualRow = element.closest('[data-worktree-virtual-row]') + const virtualRowStart = getVirtualRowStart(virtualRow) + const top = + virtualRow && virtualRowStart !== null + ? virtualRowStart + rect.top - virtualRow.getBoundingClientRect().top + : rect.top - containerRect.top + container.scrollTop + rects.push({ + groupId, + bucketKey: elementBucketKey, + headerIndex, + top, + bottom: top + rect.height + }) + }) + rects.sort((left, right) => left.top - right.top) + return rects +} + +export function computeProjectGroupHeaderDropPreview(args: { + pointerY: number + containerTop: number + scrollTop: number + rects: readonly ProjectGroupHeaderDragRect[] + sidebarProjectGroupHeaderIds: readonly string[] +}): ProjectGroupHeaderDropPreview | null { + const { rects, sidebarProjectGroupHeaderIds } = args + if (rects.length === 0 || sidebarProjectGroupHeaderIds.length === 0) { + return null + } + + const localY = args.pointerY - args.containerTop + args.scrollTop + const first = rects[0]! + const last = rects.at(-1)! + const boundaryDrop = getWorktreeSidebarBoundaryDrop({ + localY, + firstRect: { + worktreeId: first.groupId, + groupIndex: first.headerIndex, + top: first.top, + bottom: first.bottom + }, + lastRect: { + worktreeId: last.groupId, + groupIndex: last.headerIndex, + top: last.top, + bottom: last.bottom + }, + sourceGroupSize: sidebarProjectGroupHeaderIds.length + }) + if (boundaryDrop.kind === 'outside') { + return null + } + + let dropIndex = last.headerIndex + 1 + let indicatorY = last.bottom + INDICATOR_GAP_PX + if (boundaryDrop.kind === 'drop') { + dropIndex = boundaryDrop.dropIndex + indicatorY = boundaryDrop.indicatorY + } else { + for (const rect of rects) { + const mid = (rect.top + rect.bottom) / 2 + if (localY < mid) { + dropIndex = rect.headerIndex + indicatorY = Math.max(0, rect.top - INDICATOR_GAP_PX) + break + } + } + } + + return { + dropIndex, + dropIndicatorY: Math.max(args.scrollTop, indicatorY) + } +} diff --git a/src/renderer/src/components/sidebar/worktree-drag-preview-offsets.ts b/src/renderer/src/components/sidebar/worktree-drag-preview-offsets.ts index 13d5281d1ad..678cee74421 100644 --- a/src/renderer/src/components/sidebar/worktree-drag-preview-offsets.ts +++ b/src/renderer/src/components/sidebar/worktree-drag-preview-offsets.ts @@ -38,18 +38,55 @@ function getFallbackGap(rects: readonly WorktreeDragPreviewRect[]): number { return gaps[Math.floor(gaps.length / 2)]! } +function getPreviewLayoutDraggedIds( + groupIds: readonly string[], + draggedIds: readonly string[], + draggingWorktreeId?: string | null +): readonly string[] { + if (draggedIds.length <= 1) { + return draggedIds + } + const draggedSet = new Set(draggedIds) + if ( + draggingWorktreeId && + draggedSet.has(draggingWorktreeId) && + groupIds.includes(draggingWorktreeId) + ) { + return [draggingWorktreeId] + } + const firstVisibleDraggedId = groupIds.find((id) => draggedSet.has(id)) + return firstVisibleDraggedId ? [firstVisibleDraggedId] : draggedIds.slice(0, 1) +} + export function buildWorktreeDragPreviewOffsets(args: { groupIds: readonly string[] draggedIds: readonly string[] + draggingWorktreeId?: string | null dropIndex: number rects: readonly WorktreeDragPreviewRect[] }): Map { - const nextIds = moveWorktreeIdsWithinGroup(args.groupIds, args.draggedIds, args.dropIndex) + const committedNextIds = moveWorktreeIdsWithinGroup( + args.groupIds, + args.draggedIds, + args.dropIndex + ) + if (arraysEqual(committedNextIds, args.groupIds)) { + return new Map() + } + + // Why: dragging a large multi-select batch should advertise the insertion + // point without opening a giant hole that makes the sidebar jump around. + const layoutDraggedIds = getPreviewLayoutDraggedIds( + args.groupIds, + args.draggedIds, + args.draggingWorktreeId + ) + const nextIds = moveWorktreeIdsWithinGroup(args.groupIds, layoutDraggedIds, args.dropIndex) if (arraysEqual(nextIds, args.groupIds)) { return new Map() } - const draggedSet = new Set(args.draggedIds) + const draggedSet = new Set(layoutDraggedIds) const newIndexById = new Map() nextIds.forEach((id, index) => newIndexById.set(id, index)) diff --git a/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.test.ts b/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.test.ts new file mode 100644 index 00000000000..0f70912df1c --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import { + getReorderedWorktreeIdsToUnnest, + getWorktreeLineageDropTargetId, + isWorktreeLineageDropZoneHit +} from './worktree-lineage-drag-drop' + +describe('isWorktreeLineageDropZoneHit', () => { + it('keeps the top and bottom of a card available for reorder drops', () => { + const rect = { top: 100, bottom: 200 } as DOMRect + + expect(isWorktreeLineageDropZoneHit({ pointerY: 120, rect })).toBe(false) + expect(isWorktreeLineageDropZoneHit({ pointerY: 150, rect })).toBe(true) + expect(isWorktreeLineageDropZoneHit({ pointerY: 180, rect })).toBe(false) + }) + + it('caps the parent-drop band on tall cards', () => { + const rect = { top: 0, bottom: 180 } as DOMRect + + expect(isWorktreeLineageDropZoneHit({ pointerY: 67, rect })).toBe(false) + expect(isWorktreeLineageDropZoneHit({ pointerY: 90, rect })).toBe(true) + expect(isWorktreeLineageDropZoneHit({ pointerY: 113, rect })).toBe(false) + }) +}) + +describe('getWorktreeLineageDropTargetId', () => { + it('returns the row id only when the pointer is in the card content middle band', () => { + const { container, target } = makeTarget({ worktreeId: 'parent', top: 100, bottom: 200 }) + + expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 120 })).toBeNull() + expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 150 })).toBe('parent') + }) + + it('ignores content targets outside the sidebar container', () => { + const { container, target } = makeTarget({ + worktreeId: 'parent', + top: 100, + bottom: 200, + contained: false + }) + + expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 150 })).toBeNull() + }) +}) + +describe('getReorderedWorktreeIdsToUnnest', () => { + it('clears parents only for directly dragged nested cards', () => { + expect( + getReorderedWorktreeIdsToUnnest({ + draggedIds: ['child', 'child', 'root', 'grandchild'], + sourceGroupIds: ['child', 'root', 'grandchild'], + lineageById: { + child: true, + grandchild: true + } + }) + ).toEqual(['child', 'grandchild']) + }) + + it('does not clear selected nested cards outside the reordered source group', () => { + expect( + getReorderedWorktreeIdsToUnnest({ + draggedIds: ['source-child', 'other-child'], + sourceGroupIds: ['source-child'], + lineageById: { + 'source-child': true, + 'other-child': true + } + }) + ).toEqual(['source-child']) + }) +}) + +function makeTarget(args: { + worktreeId: string + top: number + bottom: number + contained?: boolean +}): { + container: HTMLElement + target: Element +} { + const row = { + getAttribute: (name: string) => (name === 'data-worktree-drag-id' ? args.worktreeId : null) + } as HTMLElement + const content = { + getBoundingClientRect: () => ({ top: args.top, bottom: args.bottom }), + closest: (selector: string) => (selector === '[data-worktree-drag-id]' ? row : null) + } as HTMLElement + const target = { + closest: (selector: string) => + selector === '[data-worktree-card-hover-trigger]' ? content : null + } as Element + const contained = args.contained ?? true + const container = { + contains: (element: Element) => contained && (element === content || element === row) + } as HTMLElement + return { container, target } +} diff --git a/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts b/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts new file mode 100644 index 00000000000..dc011d33a77 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts @@ -0,0 +1,68 @@ +const WORKTREE_CARD_CONTENT_TARGET_SELECTOR = '[data-worktree-card-hover-trigger]' +const WORKTREE_DRAG_ROW_SELECTOR = '[data-worktree-drag-id]' + +const LINEAGE_DROP_ZONE_RATIO = 0.4 +const LINEAGE_DROP_ZONE_MAX_HEIGHT_PX = 44 + +type VerticalRect = Pick + +export function isWorktreeLineageDropZoneHit(args: { + pointerY: number + rect: VerticalRect +}): boolean { + const height = Math.max(0, args.rect.bottom - args.rect.top) + if (height <= 0) { + return false + } + + const zoneHeight = Math.min(height * LINEAGE_DROP_ZONE_RATIO, LINEAGE_DROP_ZONE_MAX_HEIGHT_PX) + const zoneTop = args.rect.top + (height - zoneHeight) / 2 + const zoneBottom = args.rect.bottom - (height - zoneHeight) / 2 + return args.pointerY >= zoneTop && args.pointerY <= zoneBottom +} + +export function getWorktreeLineageDropTargetId(args: { + container: HTMLElement + target: Element + pointerY: number +}): string | null { + const contentTarget = args.target.closest(WORKTREE_CARD_CONTENT_TARGET_SELECTOR) + if (!contentTarget || !args.container.contains(contentTarget)) { + return null + } + + // Why: nesting should be deliberate; the top/bottom of a card stays available + // for reorder drops instead of treating the whole card as a parent target. + if ( + !isWorktreeLineageDropZoneHit({ + pointerY: args.pointerY, + rect: contentTarget.getBoundingClientRect() + }) + ) { + return null + } + + const rowTarget = contentTarget.closest(WORKTREE_DRAG_ROW_SELECTOR) + if (!rowTarget || !args.container.contains(rowTarget)) { + return null + } + return rowTarget.getAttribute('data-worktree-drag-id') +} + +export function getReorderedWorktreeIdsToUnnest(args: { + draggedIds: readonly string[] + sourceGroupIds: readonly string[] + lineageById: Readonly> +}): string[] { + const ids: string[] = [] + const seen = new Set() + const sourceGroupIdSet = new Set(args.sourceGroupIds) + for (const id of args.draggedIds) { + if (seen.has(id) || !sourceGroupIdSet.has(id) || !args.lineageById[id]) { + continue + } + seen.add(id) + ids.push(id) + } + return ids +} diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts index 805aaa4c6de..f0ef24b9ef6 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -2275,6 +2275,67 @@ describe('project groups', () => { ]) }) + it('orders Project Group siblings by tabOrder within each parent bucket', () => { + const rootA: ProjectGroup = { + id: 'group-root-a', + name: 'Platform', + parentPath: '/platform', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 20, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + const rootB: ProjectGroup = { + ...rootA, + id: 'group-root-b', + name: 'Infrastructure', + tabOrder: 10 + } + const childLate: ProjectGroup = { + ...rootA, + id: 'group-child-late', + name: 'late', + parentGroupId: rootB.id, + tabOrder: 30 + } + const childEarly: ProjectGroup = { + ...rootA, + id: 'group-child-early', + name: 'early', + parentGroupId: rootB.id, + tabOrder: 5 + } + + const rows = buildRows( + 'repo', + [], + new Map(), + null, + new Set(), + undefined, + undefined, + 'recent', + {}, + undefined, + false, + undefined, + [rootA, rootB, childLate, childEarly] + ) + + expect(rows.filter((row) => row.type === 'header').map((row) => row.key)).toEqual([ + 'project-group:group-root-b', + 'project-group:group-child-early', + 'project-group:group-child-late', + 'project-group:group-root-a' + ]) + expect(rows.filter((row) => row.type === 'header').map((row) => row.projectGroupDepth)).toEqual( + [0, 1, 1, 0] + ) + }) + it('renders nested Project Groups before repos assigned to their leaf group', () => { const rootGroup: ProjectGroup = { id: 'group-root', diff --git a/src/renderer/src/components/sidebar/worktree-manual-order.test.ts b/src/renderer/src/components/sidebar/worktree-manual-order.test.ts index 7753afd8cd3..3847c2168b6 100644 --- a/src/renderer/src/components/sidebar/worktree-manual-order.test.ts +++ b/src/renderer/src/components/sidebar/worktree-manual-order.test.ts @@ -146,6 +146,64 @@ describe('buildWorktreeDragPreviewOffsets', () => { expect(Array.from(offsets)).toEqual([['parent', 108]]) }) + + it('reserves one card-height slot while previewing a multi-select batch', () => { + const offsets = buildWorktreeDragPreviewOffsets({ + groupIds: ['a', 'b', 'c', 'd', 'e'], + draggedIds: ['b', 'c', 'd'], + draggingWorktreeId: 'b', + dropIndex: 5, + rects: [ + { worktreeId: 'a', groupIndex: 0, top: 0, bottom: 50 }, + { worktreeId: 'b', groupIndex: 1, top: 56, bottom: 106 }, + { worktreeId: 'c', groupIndex: 2, top: 112, bottom: 162 }, + { worktreeId: 'd', groupIndex: 3, top: 168, bottom: 218 }, + { worktreeId: 'e', groupIndex: 4, top: 224, bottom: 274 } + ] + }) + + expect(Array.from(offsets)).toEqual([ + ['c', -56], + ['d', -56], + ['e', -56] + ]) + }) + + it('uses the grabbed selected card as the one preview placeholder', () => { + const offsets = buildWorktreeDragPreviewOffsets({ + groupIds: ['a', 'b', 'c', 'd', 'e'], + draggedIds: ['b', 'c', 'd'], + draggingWorktreeId: 'd', + dropIndex: 5, + rects: [ + { worktreeId: 'a', groupIndex: 0, top: 0, bottom: 50 }, + { worktreeId: 'b', groupIndex: 1, top: 56, bottom: 106 }, + { worktreeId: 'c', groupIndex: 2, top: 112, bottom: 162 }, + { worktreeId: 'd', groupIndex: 3, top: 168, bottom: 218 }, + { worktreeId: 'e', groupIndex: 4, top: 224, bottom: 274 } + ] + }) + + expect(Array.from(offsets)).toEqual([['e', -56]]) + }) + + it('returns no preview offsets for a no-op multi-select hover', () => { + const offsets = buildWorktreeDragPreviewOffsets({ + groupIds: ['a', 'b', 'c', 'd', 'e'], + draggedIds: ['b', 'c', 'd'], + draggingWorktreeId: 'b', + dropIndex: 4, + rects: [ + { worktreeId: 'a', groupIndex: 0, top: 0, bottom: 50 }, + { worktreeId: 'b', groupIndex: 1, top: 56, bottom: 106 }, + { worktreeId: 'c', groupIndex: 2, top: 112, bottom: 162 }, + { worktreeId: 'd', groupIndex: 3, top: 168, bottom: 218 }, + { worktreeId: 'e', groupIndex: 4, top: 224, bottom: 274 } + ] + }) + + expect(offsets.size).toBe(0) + }) }) describe('buildManualOrderUpdatesForVisibleGroups', () => { diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.test.ts b/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.test.ts index 2e3c5582abc..a197759a245 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.test.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.test.ts @@ -183,7 +183,7 @@ describe('refreshWorktreeSidebarDragSession', () => { ).toBeNull() expect( refreshWorktreeSidebarDragSession({ - session: SESSION, + session: { ...SESSION, reorderUnitDraggedIds: ['missing'] }, groups: [{ key: 'repo:one', worktreeIds: ['a', 'b'] }], unitGroups: [{ key: 'repo:one', worktreeIds: ['a'], units: [] }], rects: [] @@ -201,6 +201,39 @@ describe('refreshWorktreeSidebarDragSession', () => { }) ).toEqual({ ...SESSION, rects: [] }) }) + + it('keeps child-card reorder drags even when the child is not a top-level unit', () => { + const childSession: WorktreeSidebarDragSession = { + ...SESSION, + draggingWorktreeId: 'child', + draggedIds: ['child'], + reorderDraggedIds: ['child'], + reorderUnitDraggedIds: ['child'] + } + const rects: WorktreeSidebarDragRect[] = [ + { worktreeId: 'parent', groupIndex: 0, top: 0, bottom: 80 }, + { worktreeId: 'child', groupIndex: 1, top: 88, bottom: 128 }, + { worktreeId: 'sibling', groupIndex: 2, top: 136, bottom: 176 } + ] + + expect( + refreshWorktreeSidebarDragSession({ + session: childSession, + groups: [{ key: 'repo:one', worktreeIds: ['parent', 'child', 'sibling'] }], + unitGroups: [ + { + key: 'repo:one', + worktreeIds: ['parent', 'sibling'], + units: [ + { worktreeId: 'parent', worktreeIds: ['parent', 'child'] }, + { worktreeId: 'sibling', worktreeIds: ['sibling'] } + ] + } + ], + rects + }) + ).toEqual({ ...childSession, rects }) + }) }) function makeContainer(elements: readonly ReturnType[]): HTMLElement { diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.ts b/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.ts index 4ccbf340549..27e2a3d7d2d 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.ts @@ -180,8 +180,13 @@ export function refreshWorktreeSidebarDragSession(args: { return null } + const sourceGroupIds = new Set(sourceGroup.worktreeIds) const sourceUnitIds = new Set(sourceUnitGroup.worktreeIds) - if (args.session.reorderUnitDraggedIds.some((worktreeId) => !sourceUnitIds.has(worktreeId))) { + if ( + args.session.reorderUnitDraggedIds.some( + (worktreeId) => !sourceUnitIds.has(worktreeId) && !sourceGroupIds.has(worktreeId) + ) + ) { return null } diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.test.ts b/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.test.ts index 69f4c693de0..9fb1e630276 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.test.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.test.ts @@ -60,6 +60,58 @@ describe('computeWorktreeSidebarDropPreview', () => { }) expect(Array.from(preview?.previewOffsetsByWorktreeId ?? [])).toEqual([['sibling', -288]]) }) + + it('uses one card-height placeholder for multi-select reorder previews', () => { + const preview = computeWorktreeSidebarDropPreview({ + pointerY: 280, + containerTop: 0, + scrollTop: 0, + rects: [ + { worktreeId: 'a', groupIndex: 0, top: 0, bottom: 50 }, + { worktreeId: 'b', groupIndex: 1, top: 56, bottom: 106 }, + { worktreeId: 'c', groupIndex: 2, top: 112, bottom: 162 }, + { worktreeId: 'd', groupIndex: 3, top: 168, bottom: 218 }, + { worktreeId: 'e', groupIndex: 4, top: 224, bottom: 274 } + ], + groupIds: ['a', 'b', 'c', 'd', 'e'], + draggedIds: ['b', 'c', 'd'], + draggingWorktreeId: 'b' + }) + + expect(preview).toMatchObject({ + dropIndex: 5, + dropIndicatorY: 277 + }) + expect(Array.from(preview?.previewOffsetsByWorktreeId ?? [])).toEqual([ + ['c', -56], + ['d', -56], + ['e', -56] + ]) + }) + + it('uses the grabbed selected card as the multi-select preview placeholder', () => { + const preview = computeWorktreeSidebarDropPreview({ + pointerY: 280, + containerTop: 0, + scrollTop: 0, + rects: [ + { worktreeId: 'a', groupIndex: 0, top: 0, bottom: 50 }, + { worktreeId: 'b', groupIndex: 1, top: 56, bottom: 106 }, + { worktreeId: 'c', groupIndex: 2, top: 112, bottom: 162 }, + { worktreeId: 'd', groupIndex: 3, top: 168, bottom: 218 }, + { worktreeId: 'e', groupIndex: 4, top: 224, bottom: 274 } + ], + groupIds: ['a', 'b', 'c', 'd', 'e'], + draggedIds: ['b', 'c', 'd'], + draggingWorktreeId: 'd' + }) + + expect(preview).toMatchObject({ + dropIndex: 5, + dropIndicatorY: 277 + }) + expect(Array.from(preview?.previewOffsetsByWorktreeId ?? [])).toEqual([['e', -56]]) + }) }) describe('resolveWorktreeSidebarStatusDropCommitTarget', () => { diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.ts b/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.ts index b150188bdb9..d3702968e59 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.ts @@ -99,6 +99,7 @@ export function computeWorktreeSidebarDropPreview(args: { rects: readonly WorktreeSidebarDragRect[] groupIds: readonly string[] draggedIds: readonly string[] + draggingWorktreeId?: string | null }): WorktreeSidebarDropPreview | null { const rects = getWorktreeSidebarDragUnitRects({ rects: args.rects, @@ -139,6 +140,7 @@ export function computeWorktreeSidebarDropPreview(args: { const previewOffsetsByWorktreeId = buildWorktreeDragPreviewOffsets({ groupIds: args.groupIds, draggedIds: args.draggedIds, + draggingWorktreeId: args.draggingWorktreeId, dropIndex, rects }) diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.test.ts b/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.test.ts new file mode 100644 index 00000000000..1da451ff024 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.test.ts @@ -0,0 +1,43 @@ +// @vitest-environment happy-dom + +import { describe, expect, it } from 'vitest' +import { isSidebarPointerDragBlocked } from './worktree-sidebar-pointer-drag-dom' + +describe('worktree sidebar pointer drag DOM guards', () => { + it('allows plain row targets to start pointer drags', () => { + const row = document.createElement('div') + const card = document.createElement('div') + row.appendChild(card) + + expect(isSidebarPointerDragBlocked(card, row)).toBe(false) + }) + + it('blocks portaled hover card targets outside the row', () => { + const row = document.createElement('div') + const hoverCardContent = document.createElement('div') + document.body.append(row, hoverCardContent) + + expect(isSidebarPointerDragBlocked(hoverCardContent, row)).toBe(true) + + hoverCardContent.remove() + row.remove() + }) + + it('blocks interactive targets inside the row', () => { + const row = document.createElement('div') + const button = document.createElement('button') + row.appendChild(button) + + expect(isSidebarPointerDragBlocked(button, row)).toBe(true) + }) + + it('blocks icon targets inside interactive row controls', () => { + const row = document.createElement('div') + const button = document.createElement('button') + const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg') + button.appendChild(icon) + row.appendChild(button) + + expect(isSidebarPointerDragBlocked(icon, row)).toBe(true) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.ts b/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.ts index 740a32abf92..15d102ac3b5 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.ts @@ -17,7 +17,15 @@ const INTERACTIVE_DRAG_BLOCKER_SELECTOR = [ ].join(',') export function isSidebarPointerDragBlocked(target: EventTarget | null, row: HTMLElement): boolean { - if (!(target instanceof HTMLElement)) { + if (!(target instanceof Node)) { + return false + } + // Why: Radix hover cards portal outside the row, but React still bubbles their + // pointer events through row handlers; text selection there must not drag rows. + if (!row.contains(target)) { + return true + } + if (!(target instanceof Element)) { return false } const blocker = target.closest(INTERACTIVE_DRAG_BLOCKER_SELECTOR) diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index eb43c2c5625..d08ffca7a06 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -901,70 +901,63 @@ function MiniBar({ leftPct }: { leftPct: number }): React.JSX.Element { // Inline usage bars (compact bars for inactive accounts in the switcher) // --------------------------------------------------------------------------- -function InlineUsageBars({ +export function InlineUsageBars({ limits, isFetching }: { limits: ProviderRateLimits isFetching: boolean }): React.JSX.Element { - const sessionLeft = limits.session - ? Math.max(0, Math.round(100 - limits.session.usedPercent)) - : null - const weeklyLeft = limits.weekly ? Math.max(0, Math.round(100 - limits.weekly.usedPercent)) : null - const fableLeft = limits.fableWeekly - ? Math.max(0, Math.round(100 - limits.fableWeekly.usedPercent)) - : null + const usageWindows = [ + limits.session + ? { + key: 'session', + left: Math.max(0, Math.round(100 - limits.session.usedPercent)), + label: translate('auto.components.status.bar.StatusBar.d79c3362c4', '% 5h') + } + : null, + limits.weekly + ? { + key: 'weekly', + left: Math.max(0, Math.round(100 - limits.weekly.usedPercent)), + label: translate('auto.components.status.bar.StatusBar.5c938d39ac', '% wk') + } + : null, + limits.fableWeekly + ? { + key: 'fableWeekly', + left: Math.max(0, Math.round(100 - limits.fableWeekly.usedPercent)), + label: translate('auto.components.status.bar.StatusBar.54e8d6bb2d', '% Fable') + } + : null + ].filter((window): window is { key: string; left: number; label: string } => window !== null) return ( -
- {sessionLeft !== null && ( -
-
+
+ {usageWindows.map((window) => ( +
+
- - {sessionLeft} - {translate('auto.components.status.bar.StatusBar.d79c3362c4', '% 5h')} + + {window.left} + {window.label}
- )} - {weeklyLeft !== null && ( -
-
-
-
- - {weeklyLeft} - {translate('auto.components.status.bar.StatusBar.5c938d39ac', '% wk')} - -
- )} - {fableLeft !== null && ( -
-
-
-
- - {fableLeft} - {translate('auto.components.status.bar.StatusBar.54e8d6bb2d', '% Fable')} - -
- )} - {limits.status === 'error' && !limits.session && !limits.weekly && !limits.fableWeekly && ( + ))} + {usageWindows.length === 0 && limits.status === 'error' ? ( {translate('auto.components.status.bar.StatusBar.f19a63e7cd', 'Sign in to see usage')} - )} + ) : null}
) } diff --git a/src/renderer/src/components/status-bar/inline-usage-bars.test.tsx b/src/renderer/src/components/status-bar/inline-usage-bars.test.tsx new file mode 100644 index 00000000000..0160978beb7 --- /dev/null +++ b/src/renderer/src/components/status-bar/inline-usage-bars.test.tsx @@ -0,0 +1,53 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import type { ProviderRateLimits } from '../../../../shared/rate-limit-types' + +vi.mock('@/i18n/i18n', () => ({ + i18n: { language: 'en' }, + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('@/lib/agent-catalog', () => ({ + AgentIcon: () => null +})) + +function claudeLimits(): ProviderRateLimits { + return { + provider: 'claude', + session: { + usedPercent: 32, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + }, + weekly: { + usedPercent: 16, + windowMinutes: 10080, + resetsAt: null, + resetDescription: null + }, + fableWeekly: { + usedPercent: 42, + windowMinutes: 10080, + resetsAt: null, + resetDescription: null + }, + updatedAt: Date.now(), + error: null, + status: 'ok' + } +} + +describe('InlineUsageBars', () => { + it('renders Claude Fable usage in inactive account preview rows', async () => { + const { InlineUsageBars } = await import('./StatusBar') + + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('68% 5h') + expect(markup).toContain('84% wk') + expect(markup).toContain('58% Fable') + }) +}) diff --git a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx index a53e80e0117..f4dc863f8e8 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx @@ -11,6 +11,11 @@ vi.mock('react', async () => { return { ...actual, useEffect: () => {}, + // Why: this shallow harness calls the component as a plain function (no React + // render), so ref/callback hooks must be stubbed like useState/useEffect. The + // favicon tests never fire pointer events, so non-persistent refs are fine. + useRef: (initial: T) => ({ current: initial }), + useCallback: (fn: T) => fn, useState(initial: T | (() => T)) { const stateIndex = reactHookRuntime.index++ if (!(stateIndex in reactHookRuntime.states)) { diff --git a/src/renderer/src/components/tab-bar/BrowserTab.tsx b/src/renderer/src/components/tab-bar/BrowserTab.tsx index 83ad2c5f45f..0dfb97a9af6 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.tsx @@ -26,6 +26,7 @@ import { preventMiddleButtonDefault } from './middle-button-default-guard' import { translate } from '@/i18n/i18n' import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules' import { TabWorkspaceLayoutMenuSection } from './TabWorkspaceLayoutMenuSection' +import { useTabStripPointerActivation } from './tab-strip-pointer-activation' function formatBrowserTabUrlLabel(url: string): string { if (url === ORCA_BROWSER_BLANK_URL || url === 'about:blank') { @@ -169,6 +170,10 @@ export default function BrowserTab({ return () => window.removeEventListener('blur', dismiss) }, [menuOpen]) + // Why: defer activation to pointer-up so dragging the tab (reorder / move into + // another pane / split) does not switch the active tab mid-gesture. + const { onPointerDown: onTabPointerDown } = useTabStripPointerActivation({ onActivate }) + const tabRoot = (
{ - if (e.button !== 0) { - return - } - onActivate() - listeners?.onPointerDown?.(e) + onTabPointerDown( + e, + listeners?.onPointerDown as ((event: React.PointerEvent) => void) | undefined + ) }} onMouseDown={(e) => { if (e.button === 1) { diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx index 0d48f3dfe41..59a1c18bcee 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx @@ -19,6 +19,8 @@ const appStoreMocks = vi.hoisted(() => ({ })) })) +const renameFileOnDiskMock = vi.hoisted(() => vi.fn()) + vi.mock('react', async () => { const actual = await vi.importActual('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() return { @@ -147,7 +149,7 @@ vi.mock('@/components/editor/editor-labels', () => ({ })) vi.mock('@/lib/rename-file', () => ({ - renameFileOnDisk: vi.fn() + renameFileOnDisk: renameFileOnDiskMock })) vi.mock('@/lib/file-type-icons', () => ({ @@ -326,6 +328,27 @@ function findSpanByText(node: unknown, label: string): ReactElementLike { return span } +function pressInputKey( + input: ReactElementLike, + key: string, + options?: { isComposing?: boolean; keyCode?: number } +): { + preventDefault: ReturnType + stopPropagation: ReturnType +} { + const event = { + key, + nativeEvent: { + isComposing: options?.isComposing ?? false, + keyCode: options?.keyCode ?? 13 + }, + preventDefault: vi.fn(), + stopPropagation: vi.fn() + } + ;(input.props.onKeyDown as (nextEvent: typeof event) => void)(event) + return event +} + describe('EditorFileTab rename menu', () => { beforeEach(() => { reactHookRuntime.states = [] @@ -371,6 +394,38 @@ describe('EditorFileTab rename menu', () => { expect(select).not.toHaveBeenCalled() }) + it('ignores IME composition Enter before renaming the editor file tab', async () => { + const file = baseFile() + const firstRender = expandNode((await renderEditorFileTab(file)).element) + const renameItem = findMenuItemByText(firstRender, 'Rename') + + ;(renameItem.props.onSelect as () => void)() + + const secondRender = expandNode((await renderEditorFileTab(file)).element) + const input = findElementsByType(secondRender, 'input')[0] + const setInputRef = input.props.ref as (input: HTMLInputElement | null) => void + setInputRef({ + focus: vi.fn(), + select: vi.fn(), + setSelectionRange: vi.fn(), + value: '日本語.md' + } as unknown as HTMLInputElement) + + const composingEvent = pressInputKey(input, 'Enter', { isComposing: true }) + + expect(composingEvent.preventDefault).not.toHaveBeenCalled() + expect(renameFileOnDiskMock).not.toHaveBeenCalled() + + pressInputKey(input, 'Enter') + + expect(renameFileOnDiskMock).toHaveBeenCalledWith({ + oldPath: '/repo/untitled-5.md', + newName: '日本語.md', + worktreeId: 'wt-1', + worktreePath: '/repo' + }) + }) + it('disables Rename for diff tabs that do not map to one writable file', async () => { const file = baseFile({ mode: 'diff', diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx index 694d03b0b59..9de55f9c95e 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx @@ -6,6 +6,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { basename, normalizeRelativePath } from '@/lib/path' import { getEditorDisplayLabel } from '@/components/editor/editor-labels' import { renameFileOnDisk } from '@/lib/rename-file' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { detectLanguage } from '@/lib/language-detect' import { getFileTypeIcon } from '@/lib/file-type-icons' import { useRepoById, useWorktreeById } from '@/store/selectors' @@ -29,6 +30,7 @@ import { EditorFileTabContextMenu } from './EditorFileTabContextMenu' import { translate } from '@/i18n/i18n' import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules' import { EditorFileTabCloseButton } from './EditorFileTabCloseButton' +import { useTabStripPointerActivation } from './tab-strip-pointer-activation' export default function EditorFileTab({ file, @@ -200,6 +202,12 @@ export default function EditorFileTab({ }, [menuOpen]) const dragListeners = isRenaming ? undefined : listeners + // Why: defer activation to pointer-up so dragging the tab (reorder / move into + // another pane / split) does not switch the active tab mid-gesture. + const { onPointerDown: onTabPointerDown } = useTabStripPointerActivation({ + onActivate, + disabled: isRenaming + }) const tabRoot = (
{ - if (isRenaming || e.button !== 0) { - return - } - onActivate() - dragListeners?.onPointerDown?.(e) + onTabPointerDown( + e, + dragListeners?.onPointerDown as ((event: React.PointerEvent) => void) | undefined + ) }} onDoubleClick={() => { if (file.isPreview && onMakePermanent) { @@ -281,6 +288,11 @@ export default function EditorFileTab({ onClick={(e) => e.stopPropagation()} onDoubleClick={(e) => e.stopPropagation()} onKeyDown={(e) => { + // Why: an Enter that only confirms a CJK IME candidate must not + // commit the rename; wait for a non-composition Enter. + if (isImeCompositionKeyDown(e)) { + return + } if (e.key === 'Enter') { e.preventDefault() e.stopPropagation() diff --git a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx index 10a81ab059c..bb4e757d1c8 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx @@ -208,7 +208,11 @@ function makeTerminalTab() { } } -async function renderSortableTab(): Promise { +async function renderSortableTab({ + onSetCustomTitle = vi.fn() +}: { + onSetCustomTitle?: (tabId: string, title: string | null) => void +} = {}): Promise { reactHookRuntime.index = 0 const module = await import('./SortableTab') return module.default({ @@ -224,7 +228,7 @@ async function renderSortableTab(): Promise { onClose: vi.fn(), onCloseOthers: vi.fn(), onCloseToRight: vi.fn(), - onSetCustomTitle: vi.fn(), + onSetCustomTitle, onSetTabColor: vi.fn(), onTogglePin: vi.fn(), onToggleExpand: vi.fn(), @@ -282,6 +286,25 @@ function findElementsByType(node: unknown, typeName: string): ReactElementLike[] return results } +function pressInputKey( + input: ReactElementLike, + key: string, + options?: { isComposing?: boolean; keyCode?: number } +): { + preventDefault: ReturnType +} { + const event = { + key, + nativeEvent: { + isComposing: options?.isComposing ?? false, + keyCode: options?.keyCode ?? 13 + }, + preventDefault: vi.fn() + } + ;(input.props.onKeyDown as (nextEvent: typeof event) => void)(event) + return event +} + describe('SortableTab rename shortcut signal', () => { beforeEach(() => { reactHookRuntime.states = [] @@ -312,4 +335,26 @@ describe('SortableTab rename shortcut signal', () => { expect(inputs[0].props.value).toBe('Runtime terminal title') expect(inputs[0].props['data-tab-rename-input']).toBe('true') }) + + it('ignores IME composition Enter before committing the custom tab title', async () => { + const onSetCustomTitle = vi.fn() + + await renderSortableTab({ onSetCustomTitle }) + let rerender = expandNode(await renderSortableTab({ onSetCustomTitle })) + let input = findElementsByType(rerender, 'input')[0] + ;(input.props.onChange as (event: { target: { value: string } }) => void)({ + target: { value: '日本語 terminal' } + }) + rerender = expandNode(await renderSortableTab({ onSetCustomTitle })) + input = findElementsByType(rerender, 'input')[0] + + const composingEvent = pressInputKey(input, 'Enter', { isComposing: true }) + + expect(composingEvent.preventDefault).not.toHaveBeenCalled() + expect(onSetCustomTitle).not.toHaveBeenCalled() + + pressInputKey(input, 'Enter') + + expect(onSetCustomTitle).toHaveBeenCalledWith('terminal-tab-1', '日本語 terminal') + }) }) diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index e19f1258059..fa90c59e553 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -5,6 +5,7 @@ import { ShellIcon } from './shell-icons' import { AgentIcon } from '@/lib/agent-catalog' import { stripLeadingAgentTitleDecoration } from '../../../../shared/agent-title-decoration' import { useTabAgent } from '@/lib/use-tab-agent' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { Input } from '@/components/ui/input' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' @@ -24,6 +25,7 @@ import { SortableTabContextMenu } from './SortableTabContextMenu' import { translate } from '@/i18n/i18n' import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules' import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel' +import { useTabStripPointerActivation } from './tab-strip-pointer-activation' type SortableTabProps = { tab: TerminalTab @@ -209,6 +211,16 @@ export default function SortableTab({ // so dnd-kit's a11y attributes (aria-roledescription, etc.) remain on the element — only // the pointer listeners are gated so a drag can't start while typing. const dragListeners = isEditing ? undefined : listeners + const handleActivate = useCallback(() => { + onActivate(tab.id) + }, [onActivate, tab.id]) + // Why: defer activation to pointer-up so pressing a tab to drag it (reorder / + // move into another pane / split) does not switch the active tab or steal + // terminal focus mid-gesture. See tab-strip-pointer-activation. + const { onPointerDown: onTabPointerDown } = useTabStripPointerActivation({ + onActivate: handleActivate, + disabled: isEditing + }) const closeShortcut = useShortcutKeyDetails('tab.close') const tabTitle = tab.customTitle ?? tab.title const tabRoot = ( @@ -242,11 +254,10 @@ export default function SortableTab({ handleRenameOpen() }} onPointerDown={(e) => { - if (isEditing || e.button !== 0) { - return - } - onActivate(tab.id) - dragListeners?.onPointerDown?.(e) + onTabPointerDown( + e, + dragListeners?.onPointerDown as ((event: React.PointerEvent) => void) | undefined + ) }} onMouseDown={(e) => { // Why: prevent default browser middle-click behavior (auto-scroll) @@ -333,6 +344,11 @@ export default function SortableTab({ onChange={(event) => setRenameValue(event.target.value)} onBlur={commitRename} onKeyDown={(event) => { + // Why: an Enter that only confirms a CJK IME candidate must not + // commit the rename; wait for a non-composition Enter. + if (isImeCompositionKeyDown(event)) { + return + } if (event.key === 'Enter') { event.preventDefault() commitRename() diff --git a/src/renderer/src/components/tab-bar/tab-strip-pointer-activation.test.tsx b/src/renderer/src/components/tab-bar/tab-strip-pointer-activation.test.tsx new file mode 100644 index 00000000000..3bd467e49b0 --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-strip-pointer-activation.test.tsx @@ -0,0 +1,111 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TAB_DRAG_ACTIVATION_DISTANCE_PX } from '../tab-group/useTabDragSplit' +import { useTabStripPointerActivation } from './tab-strip-pointer-activation' + +function pointerDownEvent(clientX: number, clientY: number, button = 0): React.PointerEvent { + return { button, clientX, clientY } as unknown as React.PointerEvent +} + +function firePointer(type: string, clientX: number, clientY: number): void { + act(() => { + window.dispatchEvent(new PointerEvent(type, { clientX, clientY, bubbles: true })) + }) +} + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('useTabStripPointerActivation', () => { + it('activates on a release that never crossed the drag threshold (a click)', () => { + const onActivate = vi.fn() + const dragListener = vi.fn() + const { result } = renderHook(() => useTabStripPointerActivation({ onActivate })) + + act(() => result.current.onPointerDown(pointerDownEvent(10, 10), dragListener)) + // Why: the dnd-kit gesture must start on pointerdown even though activation + // is deferred. + expect(dragListener).toHaveBeenCalledTimes(1) + expect(onActivate).not.toHaveBeenCalled() + + // Release within the threshold -> click -> activate. + firePointer('pointerup', 12, 11) + expect(onActivate).toHaveBeenCalledTimes(1) + }) + + it('suppresses activation when the pointer travels past the drag threshold', () => { + const onActivate = vi.fn() + const { result } = renderHook(() => useTabStripPointerActivation({ onActivate })) + + act(() => result.current.onPointerDown(pointerDownEvent(10, 10))) + firePointer('pointermove', 10 + TAB_DRAG_ACTIVATION_DISTANCE_PX + 5, 10) + firePointer('pointerup', 300, 40) + + expect(onActivate).not.toHaveBeenCalled() + }) + + it('stays a drag even if the pointer returns near the start before release', () => { + const onActivate = vi.fn() + const { result } = renderHook(() => useTabStripPointerActivation({ onActivate })) + + act(() => result.current.onPointerDown(pointerDownEvent(10, 10))) + // Cross the threshold, then come back over the origin and release. + firePointer('pointermove', 200, 200) + firePointer('pointerup', 11, 11) + + expect(onActivate).not.toHaveBeenCalled() + }) + + it('does not activate when the press is cancelled', () => { + const onActivate = vi.fn() + const { result } = renderHook(() => useTabStripPointerActivation({ onActivate })) + + act(() => result.current.onPointerDown(pointerDownEvent(10, 10))) + firePointer('pointercancel', 10, 10) + firePointer('pointerup', 10, 10) + + expect(onActivate).not.toHaveBeenCalled() + }) + + it('ignores non-left buttons and disabled presses', () => { + const onActivate = vi.fn() + const dragListener = vi.fn() + const { result, rerender } = renderHook( + ({ disabled }: { disabled: boolean }) => + useTabStripPointerActivation({ onActivate, disabled }), + { initialProps: { disabled: false } } + ) + + // Right-click: ignored. + act(() => result.current.onPointerDown(pointerDownEvent(10, 10, 2), dragListener)) + firePointer('pointerup', 10, 10) + expect(onActivate).not.toHaveBeenCalled() + expect(dragListener).not.toHaveBeenCalled() + + // Disabled: ignored. + rerender({ disabled: true }) + act(() => result.current.onPointerDown(pointerDownEvent(10, 10), dragListener)) + firePointer('pointerup', 10, 10) + expect(onActivate).not.toHaveBeenCalled() + expect(dragListener).not.toHaveBeenCalled() + }) + + it('activates a click that lands after a prior drag gesture (regression #6395)', () => { + const onActivate = vi.fn() + const { result } = renderHook(() => useTabStripPointerActivation({ onActivate })) + + // First gesture: a drag (reorder). No activation. + act(() => result.current.onPointerDown(pointerDownEvent(10, 10))) + firePointer('pointermove', 300, 10) + firePointer('pointerup', 300, 10) + expect(onActivate).not.toHaveBeenCalled() + + // Second gesture: a plain click. Must activate. + act(() => result.current.onPointerDown(pointerDownEvent(400, 10))) + firePointer('pointerup', 400, 10) + expect(onActivate).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/tab-bar/tab-strip-pointer-activation.ts b/src/renderer/src/components/tab-bar/tab-strip-pointer-activation.ts new file mode 100644 index 00000000000..e1a32e1e0d8 --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-strip-pointer-activation.ts @@ -0,0 +1,91 @@ +import { useCallback, useEffect, useRef } from 'react' +import { TAB_DRAG_ACTIVATION_DISTANCE_PX } from '../tab-group/useTabDragSplit' + +/** + * Defer tab activation to pointer-up and suppress it when the press turns into a + * drag. PR #5927 shipped this so dragging a tab (to reorder, move into another + * pane, or split) never switched the active tab or stole terminal focus + * mid-gesture; #6395 removed it (activating eagerly on pointerdown) to fix + * click-to-switch-after-reorder, which regressed the drag feature. + * + * We gate on measured pointer DISPLACEMENT, not the drag-active context ref the + * old hook used — that ref clears asynchronously relative to the drop's + * pointerup, which is what made #6395's click-after-reorder misfire. Displacement + * mirrors dnd-kit's own activation threshold exactly: once the pointer travels + * past it the gesture is a drag (activation suppressed); a release within it is a + * click (activate). Because each press measures its own gesture, a click after a + * reorder always activates. + */ +export function useTabStripPointerActivation({ + onActivate, + disabled = false +}: { + onActivate: () => void + disabled?: boolean +}): { + onPointerDown: ( + event: React.PointerEvent, + dragListener?: (event: React.PointerEvent) => void + ) => void +} { + const onActivateRef = useRef(onActivate) + onActivateRef.current = onActivate + const cleanupRef = useRef<(() => void) | null>(null) + + // Why: a press still holding when the tab unmounts (tab closed mid-drag, group + // collapse) would otherwise leak its window listeners and later fire activation + // on a dead closure. + useEffect(() => () => cleanupRef.current?.(), []) + + const onPointerDown = useCallback( + (event: React.PointerEvent, dragListener?: (event: React.PointerEvent) => void) => { + if (disabled || event.button !== 0) { + return + } + // Why: start the dnd-kit gesture immediately on pointerdown; only the + // activation decision is deferred to release. + dragListener?.(event) + + cleanupRef.current?.() + const startX = event.clientX + const startY = event.clientY + let draggedPastThreshold = false + + const cleanup = (): void => { + window.removeEventListener('pointermove', onPointerMove) + window.removeEventListener('pointerup', onPointerUp) + window.removeEventListener('pointercancel', onPointerCancel) + cleanupRef.current = null + } + const onPointerMove = (moveEvent: PointerEvent): void => { + if ( + Math.hypot(moveEvent.clientX - startX, moveEvent.clientY - startY) >= + TAB_DRAG_ACTIVATION_DISTANCE_PX + ) { + draggedPastThreshold = true + } + } + const onPointerUp = (): void => { + const wasDrag = draggedPastThreshold + cleanup() + // Why: only a release that never crossed the drag threshold is a click. + // Activating after a real drag would yank the just-dropped tab's pane + // back to the source selection. + if (!wasDrag) { + onActivateRef.current() + } + } + const onPointerCancel = (): void => { + cleanup() + } + + window.addEventListener('pointermove', onPointerMove) + window.addEventListener('pointerup', onPointerUp) + window.addEventListener('pointercancel', onPointerCancel) + cleanupRef.current = cleanup + }, + [disabled] + ) + + return { onPointerDown } +} diff --git a/src/renderer/src/components/tab-group/TabGroupPanel.tsx b/src/renderer/src/components/tab-group/TabGroupPanel.tsx index 9f85c325a08..57dbf5946fc 100644 --- a/src/renderer/src/components/tab-group/TabGroupPanel.tsx +++ b/src/renderer/src/components/tab-group/TabGroupPanel.tsx @@ -225,7 +225,9 @@ export default function TabGroupPanel({ user can only drag from the tiny left-sidebar header strip. */}
{/* Why: Electron's native drag hit-test only respects no-drag on DOM diff --git a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts index fe50561d270..e4b6b151c21 100644 --- a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts +++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts @@ -151,6 +151,7 @@ export function useTabGroupWorkspaceModel({ createdAt: item.createdAt, generation: terminalTab?.generation, shellOverride: terminalTab?.shellOverride, + startupCwd: terminalTab?.startupCwd, // Why: carry the launched agent through the rebuilt tab so the tab // bar can show the provider icon before the agent's first hook — // this object is reconstructed from the unified-tab model, so any diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index b1f90ac2f60..dd5d2116b8c 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -15,7 +15,11 @@ import { resolveOpaqueTerminalBackground, resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme' -import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' +import type { + ManagedPane, + PaneExternalDropTarget, + PaneManager +} from '@/lib/pane-manager/pane-manager' import TerminalSearch from '@/components/TerminalSearch' import type { PtyTransport } from './pty-transport' import { fitPanes, isWindowsUserAgent } from './pane-helpers' @@ -62,6 +66,11 @@ import { useSystemPrefersDark } from './use-system-prefers-dark' import { useTerminalPaneGlobalEffects } from './use-terminal-pane-global-effects' import { useTerminalPaneLifecycle } from './use-terminal-pane-lifecycle' import { useTerminalPaneContextMenu } from './use-terminal-pane-context-menu' +import { + detachTerminalPaneToTab, + isTerminalTabStripDropTarget, + resolveTerminalTabStripDropTarget +} from './terminal-pane-tab-detach' import type { PreparedAgentSessionFork } from './terminal-agent-session-fork' import { useNotificationDispatch } from './use-notification-dispatch' import { connectPanePty } from './pty-connection' @@ -1298,6 +1307,54 @@ export default function TerminalPane({ setPendingCloseConfirmation(null) }, []) + const resolveExternalPaneDropTarget = useCallback( + ({ + sourcePaneId, + clientX, + clientY + }: { + sourcePaneId: number + clientX: number + clientY: number + }): PaneExternalDropTarget | null => { + const manager = managerRef.current + const panes = manager?.getPanes() ?? [] + if (panes.length <= 1 || !panes.some((pane) => pane.id === sourcePaneId)) { + return null + } + return resolveTerminalTabStripDropTarget({ + clientX, + clientY, + groupsByWorktree: useAppStore.getState().groupsByWorktree, + worktreeId + }) + }, + [worktreeId] + ) + + const handleExternalPaneDrop = useCallback( + (sourcePaneId: number, target: PaneExternalDropTarget): boolean => { + if (!isTerminalTabStripDropTarget(target)) { + return false + } + const fallbackPtyId = paneTransportsRef.current.get(sourcePaneId)?.getPtyId() ?? null + return ( + detachTerminalPaneToTab({ + fallbackPtyId, + getStore: useAppStore.getState, + manager: managerRef.current, + persistLayoutSnapshot, + sourcePaneId, + sourceTabId: tabId, + targetGroupId: target.groupId, + targetIndex: target.insertionIndex, + worktreeId + }) !== null + ) + }, + [persistLayoutSnapshot, tabId, worktreeId] + ) + useTerminalPaneLifecycle({ tabId, worktreeId, @@ -1354,7 +1411,9 @@ export default function TerminalPane({ paneTitlesRef, setRenamingPaneId, setPaneCount, - setPaneLayoutRevision + setPaneLayoutRevision, + resolveExternalPaneDropTarget, + onExternalPaneDrop: handleExternalPaneDrop }) useEffect(() => { diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx index 78e80b3231e..80a386b3c8d 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx @@ -45,17 +45,24 @@ function renderOverlay({ paneCount = 2, showAlwaysOnHeaders = true, onClosePane = vi.fn(), - onRemoveTitle = vi.fn() + onRemoveTitle = vi.fn(), + onRenameSubmit = vi.fn(), + renameValue = '', + renamingPaneId = null }: { paneTitles: Record paneCount?: number showAlwaysOnHeaders?: boolean onClosePane?: ReturnType onRemoveTitle?: ReturnType + onRenameSubmit?: ReturnType + renameValue?: string + renamingPaneId?: number | null }): { container: HTMLDivElement onClosePane: ReturnType onRemoveTitle: ReturnType + onRenameSubmit: ReturnType } { const panes = [makePane(1), makePane(2)] const container = document.createElement('div') @@ -76,8 +83,8 @@ function renderOverlay({ 1: { left: 0, top: 0, width: 200 }, 2: { left: 220, top: 0, width: 200 } }} - renamingPaneId={null} - renameValue="" + renamingPaneId={renamingPaneId} + renameValue={renameValue} renameInputRef={createRef()} titleUsesLightSurface={false} paneTitleBackground="transparent" @@ -93,14 +100,31 @@ function renderOverlay({ onRemoveTitle={onRemoveTitle as (paneId: number) => void} onClosePane={onClosePane as (paneId: number) => void} onRenameValueChange={vi.fn()} - onRenameSubmit={vi.fn()} + onRenameSubmit={onRenameSubmit as () => void} onRenameCancel={vi.fn()} onRenameBlur={vi.fn()} /> ) }) mounted.push({ container, root }) - return { container, onClosePane, onRemoveTitle } + return { container, onClosePane, onRemoveTitle, onRenameSubmit } +} + +function pressInputKey( + input: HTMLInputElement, + key: string, + options?: { isComposing?: boolean; keyCode?: number } +): void { + act(() => { + const event = new KeyboardEvent('keydown', { key, bubbles: true }) + if (options?.isComposing !== undefined) { + Object.defineProperty(event, 'isComposing', { value: options.isComposing }) + } + if (options?.keyCode !== undefined) { + Object.defineProperty(event, 'keyCode', { value: options.keyCode }) + } + input.dispatchEvent(event) + }) } afterEach(() => { @@ -142,4 +166,23 @@ describe('TerminalPaneHeaderOverlay', () => { expect(onClosePane).toHaveBeenCalledWith(1) expect(onRemoveTitle).not.toHaveBeenCalled() }) + + it('ignores IME composition Enter before submitting a pane title rename', () => { + const { container, onRenameSubmit } = renderOverlay({ + paneTitles: { 1: 'server', 2: '' }, + renamingPaneId: 1, + renameValue: '日本語 pane' + }) + const input = container.querySelector('.pane-title-input') + + expect(input).not.toBeNull() + + pressInputKey(input as HTMLInputElement, 'Enter', { isComposing: true }) + + expect(onRenameSubmit).not.toHaveBeenCalled() + + pressInputKey(input as HTMLInputElement, 'Enter') + + expect(onRenameSubmit).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx index 3ad25d41bdd..fc1602f866e 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx @@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' import { WORKSPACE_FILE_PATH_MIME, WORKSPACE_FILE_PATHS_MIME } from '@/lib/workspace-file-drag' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import type { PtyTransport } from './pty-transport' import { handleInternalTerminalFileDrop } from './terminal-drop-handler' @@ -183,6 +184,11 @@ export default function TerminalPaneHeaderOverlay({ value={renameValue} onChange={(event) => onRenameValueChange(event.target.value)} onKeyDown={(event) => { + // Why: an Enter that only confirms a CJK IME candidate must + // not commit the rename; wait for a non-composition Enter. + if (isImeCompositionKeyDown(event)) { + return + } if (event.key === 'Enter') { onRenameSubmit() } else if (event.key === 'Escape') { diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx index dd71eba0cf7..0e68af9126f 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx @@ -50,6 +50,7 @@ type TerminalOverlaySlotProps = { terminalGeneration: number | undefined worktreeId: string worktreePath: string + startupCwd: string | undefined groupId: string | undefined isWorktreeActive: boolean isVisible: boolean @@ -66,6 +67,7 @@ const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ terminalGeneration, worktreeId, worktreePath, + startupCwd, groupId, isWorktreeActive, isVisible, @@ -223,7 +225,7 @@ const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ key={`${terminalTabId}-${terminalGeneration ?? 0}`} tabId={terminalTabId} worktreeId={worktreeId} - cwd={worktreePath} + cwd={startupCwd ?? worktreePath} isActive={isActive || activityTerminalPortal?.active === true} // Why: split-group changes reparent TabGroupPanel subtrees. Keeping the // TerminalPane mounted here preserves alt-screen TUI state while this @@ -365,6 +367,7 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ terminalGeneration={terminalTab.generation} worktreeId={worktreeId} worktreePath={worktreePath} + startupCwd={terminalTab.startupCwd} groupId={assignment?.groupId} isWorktreeActive={isWorktreeActive} isVisible={isVisible} diff --git a/src/renderer/src/components/terminal-pane/layout-serialization.ts b/src/renderer/src/components/terminal-pane/layout-serialization.ts index 423a08b8697..b2ab8d1e2ab 100644 --- a/src/renderer/src/components/terminal-pane/layout-serialization.ts +++ b/src/renderer/src/components/terminal-pane/layout-serialization.ts @@ -58,27 +58,32 @@ export const POST_REPLAY_MODE_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KIT export const POST_REPLAY_LIVE_SNAPSHOT_RESET = `${RESET_TERMINAL_CURSOR_STYLE}\x1b[?25h\x1b[?1004l` // Why: daemon snapshot restore reattaches to a live session, so we avoid the -// full POST_REPLAY_MODE_RESET bundle there — a still-running TUI may still -// rely on mouse or bracketed-paste modes. Four exceptions are safe to reset: +// full POST_REPLAY_MODE_RESET bundle there. The default still clears the two +// stale mode bits most harmful to a plain shell after a TUI exits badly: // // 0 q — DECSCUSR cursor style/blink reset: raw replay can contain a stale // steady cursor override, while SerializeAddon does not preserve an // authoritative current cursor style. Reset to the user's configured // xterm cursor; the post-reattach SIGWINCH lets live TUIs repaint if // they need a different cursor. -// 25 — DECTCEM cursor visibility: SerializeAddon bakes `?25l` into the -// snapshot when the cursor was hidden at capture time. Without `?25h` -// here the cursor stays invisible after reattach. If a TUI is still -// running and wants the cursor hidden, the SIGWINCH sent immediately -// after restore triggers a repaint that re-hides it — a brief flash -// that is far less harmful than a permanently invisible cursor. -// 1004 — focus event reporting: preserving `?1004h` makes restored shells -// ring BEL on pane focus/blur (shells like zsh treat `\e[I`/`\e[O` -// as unbound key input). +// 25 — DECTCEM cursor visibility: snapshots can preserve hidden cursor +// state from a no-longer-running TUI; reset by default so shells do +// not inherit a permanently invisible cursor. +// 1004 — focus event reporting: snapshots can preserve focus reporting from +// a no-longer-running TUI; reset by default so shells do not receive +// stray focus-in/focus-out bytes. // <99u/=0u — Kitty keyboard mode is renderer-side xterm state; stale copies // can make the next Ctrl+C encode as CSI-u after reattach. export const POST_REPLAY_REATTACH_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h\x1b[?1004l` +// Why: a live agent TUI legitimately owns focus reporting; resetting `?1004h` +// would suppress the post-reattach focus-in the agent needs to move its real +// cursor back to the input caret (the IME anchor). Cursor visibility is still +// reset: agent detection can false-positive on a dead TUI's leftovers, and a +// permanently invisible shell cursor is far worse than the brief flash a live +// agent re-hides on its post-reattach SIGWINCH repaint. +export const POST_REPLAY_LIVE_AGENT_REATTACH_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h` + // Cross-platform monospace fallback chain ensures the terminal always has a // usable font regardless of OS. macOS-only fonts like SF Mono and Menlo are // harmless on other platforms (the browser skips them), while Cascadia Mono / diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index afe2aa4b06c..6711cefb3c7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -1,7 +1,9 @@ /* oxlint-disable max-lines */ import type * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Terminal } from '@xterm/headless' import { + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, POST_REPLAY_MODE_RESET, POST_REPLAY_REATTACH_RESET, RESET_KITTY_KEYBOARD_PROTOCOL, @@ -44,6 +46,26 @@ async function drainPendingTimeouts(pendingTimeouts: (() => void)[], limit = 100 } } +function writeHeadlessTerminal(term: Terminal, data: string): Promise { + return new Promise((resolve) => term.write(data, resolve)) +} + +async function renderHeadlessBuffer(writes: string[], cols = 80, rows = 8): Promise { + const term = new Terminal({ cols, rows, allowProposedApi: true }) + try { + for (const write of writes) { + await writeHeadlessTerminal(term, write) + } + const lines: string[] = [] + for (let lineIndex = 0; lineIndex < term.buffer.active.length; lineIndex++) { + lines.push(term.buffer.active.getLine(lineIndex)?.translateToString(true) ?? '') + } + return lines + } finally { + term.dispose() + } +} + const toastInfo = vi.fn() const LEAF_1 = '11111111-1111-4111-8111-111111111111' as const const LEAF_2 = '22222222-2222-4222-8222-222222222222' as const @@ -208,6 +230,9 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { if (/Codex( working)?/.test(title)) { return /working/.test(title) ? 'working' : 'idle' } + if (/^\s*(?:[\u2800-\u28ff]\s+)?(?:Pi|OMP)(?: ready| idle)?\s*$/i.test(title)) { + return /[\u2800-\u28ff]/u.test(title) ? 'working' : 'idle' + } return null }) } @@ -330,7 +355,8 @@ function createPane(paneId: number) { active: activeBuffer }, modes: { - bracketedPasteMode: false + bracketedPasteMode: false, + sendFocusMode: false }, options: { ignoreBracketedPasteMode: false, @@ -449,6 +475,52 @@ function createDeps(overrides: Record = {}) { } } +function setReattachPaneTitle(title: string): void { + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty', title }] + }, + runtimePaneTitlesByTabId: { + 'tab-1': { 1: title } + } + } as StoreState +} + +async function withMockedDocumentActiveElement( + activeElement: unknown, + run: () => Promise +): Promise { + const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document') + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { activeElement } + }) + try { + return await run() + } finally { + if (originalDocument) { + Object.defineProperty(globalThis, 'document', originalDocument) + } else { + Reflect.deleteProperty(globalThis, 'document') + } + } +} + +function configureTerminalFocusMode( + pane: ReturnType, + textarea: HTMLTextAreaElement +): void { + Object.assign(pane.terminal, { textarea }) + Object.assign(pane.terminal.modes, { sendFocusMode: true }) + pane.terminal.write.mockImplementation((_data: string, callback?: () => void) => { + callback?.() + }) +} + +const ANSI_POSITIONED_CURSOR_AGENT_REATTACH_SCREEN = + '\x1b[4;3HCursor Agent\x1b[5;3Hv2026.06.29\x1b[9;3H→ Plan, search, build anything' + // Why: setting activeRuntimeEnvironmentId in mockStoreState exercises the // remote-runtime path where the renderer still owns OSC 9999 status. function enableActiveRuntimeEnvironment(environmentId = 'env-1'): void { @@ -657,15 +729,18 @@ describe('connectPanePty', () => { markWorktreeUnread: vi.fn(), observeTerminalGitHubPullRequestLink: vi.fn(), recordTerminalInput: vi.fn(), - setAgentStatus: vi.fn((paneKey: string, payload: Record) => { - mockStoreState.agentStatusByPaneKey[paneKey] = { - ...payload, - paneKey, - updatedAt: Date.now(), - stateStartedAt: Date.now(), - stateHistory: [] + setAgentStatus: vi.fn( + (paneKey: string, payload: Record, terminalTitle?: string | null) => { + mockStoreState.agentStatusByPaneKey[paneKey] = { + ...payload, + paneKey, + ...(terminalTitle ? { terminalTitle } : {}), + updatedAt: Date.now(), + stateStartedAt: Date.now(), + stateHistory: [] + } } - }), + ), removeAgentStatus: vi.fn(), dropAgentStatus: vi.fn(), markTerminalTabUnread: vi.fn(), @@ -673,7 +748,6 @@ describe('connectPanePty', () => { markAgentCompletionPaneUnread: vi.fn() } as StoreState ;(globalThis as unknown as { window: unknown }).window = { - dispatchEvent: vi.fn(), api: { ssh: { connect: vi.fn().mockResolvedValue({ status: 'connected' }), @@ -711,7 +785,10 @@ describe('connectPanePty', () => { inferInterrupt: vi.fn().mockResolvedValue(false), drop: vi.fn() } - } + }, + dispatchEvent: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn() } globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { callback(0) @@ -854,6 +931,451 @@ describe('connectPanePty', () => { ) }) + it('normalizes Pi-compatible remote runtime status to OMP after typed omp command', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-omp') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'omp\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(transport.sendInput).toHaveBeenCalledWith('omp\r') + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'OMP ready') + expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', 'OMP ready') + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'omp', + terminalTitle: 'OMP ready' + }) + }) + + it('normalizes after shell word deletion edits a typed command to omp', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-omp-edited') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'pi \x17omp\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(transport.sendInput).toHaveBeenCalledWith('pi \x17omp\r') + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'OMP ready') + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'omp', + terminalTitle: 'OMP ready' + }) + }) + + it('keeps Pi-compatible remote runtime status as Pi after typed pi command', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-pi') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'pi\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(transport.sendInput).toHaveBeenCalledWith('pi\r') + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Pi ready') + expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', 'Pi ready') + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'pi', + terminalTitle: 'Pi ready' + }) + }) + + it('does not infer shell ownership from prompts typed inside an existing Pi session', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const now = Date.now() + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'done', + prompt: '', + agentType: 'pi', + paneKey, + terminalTitle: 'Pi ready', + updatedAt: now, + stateStartedAt: now, + stateHistory: [] + } + mockStoreState.runtimePaneTitlesByTabId = { 'tab-1': { 1: 'Pi ready' } } + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-pi-prompt') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'omp\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(transport.sendInput).toHaveBeenCalledWith('omp\r') + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Pi ready') + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'pi', + terminalTitle: 'Pi ready' + }) + }) + + it('does not infer shell ownership from prompts typed in a title-only Pi session', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.runtimePaneTitlesByTabId = { 'tab-1': { 1: 'Pi ready' } } + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-pi-title-only') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'omp\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(transport.sendInput).toHaveBeenCalledWith('omp\r') + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Pi ready') + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'pi', + terminalTitle: 'Pi ready' + }) + }) + + it('lets a new typed omp command override a stale retained done status', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const now = Date.now() + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'done', + prompt: '', + agentType: 'pi', + paneKey, + terminalTitle: 'Pi ready', + updatedAt: now, + stateStartedAt: now, + stateHistory: [] + } + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-stale-done') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'omp\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'OMP ready') + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'omp', + terminalTitle: 'OMP ready' + }) + }) + + it('tracks cursor edits when inferring a typed omp command', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-cursor-edit') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'op\x1b[Dm\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(transport.sendInput).toHaveBeenCalledWith('op\x1b[Dm\r') + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'omp', + terminalTitle: 'OMP ready' + }) + }) + + it('tracks delete-key cursor edits when inferring a typed omp command', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-delete-edit') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'ommp\x1b[D\x1b[D\x1b[3~\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'omp', + terminalTitle: 'OMP ready' + }) + }) + + it('skips manual agent inference for large paste chunks', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-large-paste') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, `${'x'.repeat(4097)}omp\r`) + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'pi', + terminalTitle: 'Pi ready' + }) + }) + + it('resumes manual agent inference when large paste input is cancelled', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-cancelled-large-paste') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'x'.repeat(4097)) + sendTerminalInputThroughPane(pane, '\x03') + sendTerminalInputThroughPane(pane, 'omp\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'omp', + terminalTitle: 'OMP ready' + }) + }) + + it('preserves typed shell ownership through same-chunk command-finished side effects', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-command-finished') + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks?: ConnectCallbacks }) => { + dataCallbackRef.current = callbacks?.onData ?? null + return 'remote:web-env-1@@pty-command-finished' + } + ) + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'omp\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + const dataCallback = dataCallbackRef.current + if (!dataCallback || !onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + dataCallback('\x1b]133;D;0\x07') + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'omp', + terminalTitle: 'OMP ready' + }) + }) + it('queues visible bulk output off the synchronous xterm write path', async () => { const { connectPanePty } = await import('./pty-connection') const pane = createPane(1) @@ -4341,7 +4863,7 @@ describe('connectPanePty', () => { expect(transport.connect).toHaveBeenCalledTimes(1) }) - it('resets reattach cursor/focus state after daemon snapshot replay without applying the full mode reset', async () => { + it('resets reattach renderer state after daemon snapshot replay without applying the full mode reset', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { @@ -4386,6 +4908,241 @@ describe('connectPanePty', () => { ) }) + it('preserves live modes and injects focus-in after focused agent reattach', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lrestored cursor snapshot' } + } + return null + }) + transportFactoryQueue.push(transport) + setReattachPaneTitle('Cursor Agent') + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + // Why: public xterm modes plus an agent title are the stable signal for a + // live focus-driven TUI; avoid private `_core` field probes. + configureTerminalFocusMode(pane, textarea) + await withMockedDocumentActiveElement(textarea, async () => { + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I') + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + expect.any(Function) + ) + }) + }) + + it('does not inject focus-in after reattach when the terminal does not own DOM focus', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lrestored cursor snapshot' } + } + return null + }) + transportFactoryQueue.push(transport) + setReattachPaneTitle('Cursor Agent') + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + // Why: a different element owns focus, so the reattach must not send a stray + // focus-in to a background pane. + await withMockedDocumentActiveElement({}, async () => { + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + expect.any(Function) + ) + }) + }) + + it('resets stale focus and cursor modes for a focused non-agent shell reattach', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lstale shell snapshot' } + } + return null + }) + transportFactoryQueue.push(transport) + setReattachPaneTitle('zsh') + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + await withMockedDocumentActiveElement(textarea, async () => { + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_REATTACH_RESET, + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + expect.any(Function) + ) + }) + }) + + it('does not treat persisted tab launchAgent metadata as a live agent reattach', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lstale shell snapshot' } + } + return null + }) + transportFactoryQueue.push(transport) + // Why: launchAgent is launch ownership metadata that never decays after + // the agent exits; it must not preserve stale modes for the shell left + // behind after an unclean agent death. + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty', title: 'zsh', launchAgent: 'claude' }] + }, + runtimePaneTitlesByTabId: { + 'tab-1': { 1: 'zsh' } + } + } as StoreState + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + await withMockedDocumentActiveElement(textarea, async () => { + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_REATTACH_RESET, + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + expect.any(Function) + ) + }) + }) + + it('does not treat an agent-name token in a shell title as a live agent reattach', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lstale shell snapshot' } + } + return null + }) + transportFactoryQueue.push(transport) + // Why: broad token matching would classify this ordinary ssh title as an + // agent and preserve stale modes / inject focus-in into a bare shell. + setReattachPaneTitle('ssh devin@host') + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + await withMockedDocumentActiveElement(textarea, async () => { + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_REATTACH_RESET, + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + expect.any(Function) + ) + }) + }) + + it('does not treat ordinary shell scrollback mentioning Cursor Agent as a live agent reattach', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: sessionId, + snapshot: + '\x1b[?1004h\x1b[?25l$ grep -R "Cursor Agent" docs\r\nCursor Agent IME notes\r\n' + } + } + return null + }) + transportFactoryQueue.push(transport) + setReattachPaneTitle('zsh') + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + await withMockedDocumentActiveElement(textarea, async () => { + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_REATTACH_RESET, + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + expect.any(Function) + ) + }) + }) + it('resets an already-idle agent cursor again after reattach SIGWINCH repaint', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('tab-pty') @@ -7032,6 +7789,261 @@ describe('connectPanePty', () => { binding.dispose() }) + it('does not apply stale background Codex query chunks after hidden snapshot restore', async () => { + const visibilityChangeHandler: { current: (() => void) | null } = { current: null } + ;(globalThis as { document?: Document }).document = { + visibilityState: 'visible', + addEventListener: vi.fn((type: string, listener: EventListenerOrEventListenerObject) => { + if (type === 'visibilitychange') { + visibilityChangeHandler.current = listener as () => void + } + }), + removeEventListener: vi.fn() + } as unknown as Document + + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: + | (( + data: string, + meta?: { seq?: number; rawLength?: number; background?: boolean } + ) => void) + | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + + const hiddenFrame = '\r\x1b[2KWorking hidden' + const staleQueryFrame = '\r\x1b[2KWorking stale\x1b[6n' + const currentFrame = '\r\x1b[2KWorking current' + const staleSeq = hiddenFrame.length + staleQueryFrame.length + const currentSeq = staleSeq + currentFrame.length + // The main emulator has already parsed every frame, so the snapshot covers + // the query frame that is still in flight on the pty:data channel. + getMainBufferSnapshot.mockResolvedValue({ + data: currentFrame, + cols: 80, + rows: 8, + seq: currentSeq + }) + + const pane = createPane(1) + pane.terminal.cols = 80 + pane.terminal.rows = 8 + const { writes } = captureCallbackTerminalWrites(pane) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) + const binding = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(hiddenFrame, { + seq: hiddenFrame.length, + rawLength: hiddenFrame.length + }) + ;(deps.isVisibleRef as { current: boolean }).current = true + visibilityChangeHandler.current?.() + await flushAsyncTicks(20) + + // The in-flight chunks arrive in channel order after the restore already + // rebuilt the buffer from the newer snapshot. + vi.useFakeTimers() + try { + capturedDataCallback.current?.(staleQueryFrame, { + seq: staleSeq, + rawLength: staleQueryFrame.length, + background: true + }) + capturedDataCallback.current?.(currentFrame, { + seq: currentSeq, + rawLength: currentFrame.length, + background: true + }) + vi.advanceTimersByTime(50) + await flushAsyncTicks(6) + } finally { + vi.useRealTimers() + } + + const rendererBuffer = await renderHeadlessBuffer(writes, 80, 8) + const referenceBuffer = await renderHeadlessBuffer( + [hiddenFrame, staleQueryFrame, currentFrame], + 80, + 8 + ) + + expect(writes).not.toContain(staleQueryFrame) + expect(rendererBuffer).toEqual(referenceBuffer) + binding.dispose() + }) + + it('restores hidden Codex output after a suppressed exit revives the same ptyId with a restarted seq counter', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: + | (( + data: string, + meta?: { seq?: number; rawLength?: number; background?: boolean } + ) => void) + | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + + const hiddenFrame = '\r\x1b[2KWorking hidden' + const visibleFrame = '\r\x1b[2KWorking now' + const hiddenSeq = hiddenFrame.length + const visibleSeq = hiddenSeq + visibleFrame.length + // Why 3 extra bytes: the main emulator typically runs ahead of the chunks + // the renderer has received, so the snapshot seq exceeds the channel seq. + const preExitSnapshotSeq = visibleSeq + 3 + getMainBufferSnapshot.mockResolvedValue({ + data: visibleFrame, + cols: 80, + rows: 8, + seq: preExitSnapshotSeq + }) + + const pane = createPane(1) + pane.terminal.cols = 80 + pane.terminal.rows = 8 + const { writes } = captureCallbackTerminalWrites(pane) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' }, + consumeSuppressedPtyExit: vi.fn(() => true) + }) + const binding = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(hiddenFrame, { seq: hiddenSeq, rawLength: hiddenFrame.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(visibleFrame, { + seq: visibleSeq, + rawLength: visibleFrame.length + }) + await flushAsyncTicks(20) + + const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined + expect(onPtyExit).toBeTypeOf('function') + onPtyExit?.('pty-id') + + // Revived session: same ptyId, main seq counter restarted. The first + // revived chunk lands below the pre-exit snapshot seq but above the last + // channel seq, so only the exit reset can stop it being judged covered. + ;(deps.isVisibleRef as { current: boolean }).current = false + const revivedHiddenFrame = '\r\x1b[2KWorking revived' + capturedDataCallback.current?.(revivedHiddenFrame, { + seq: preExitSnapshotSeq - 1, + rawLength: revivedHiddenFrame.length + }) + + const revivedSnapshotFrame = '\r\x1b[2KREVIVED SNAPSHOT' + getMainBufferSnapshot.mockResolvedValue({ + data: revivedSnapshotFrame, + cols: 80, + rows: 8, + seq: preExitSnapshotSeq - 1 + }) + ;(deps.isVisibleRef as { current: boolean }).current = true + const revivedVisibleFrame = '\r\x1b[2Kafter revive' + capturedDataCallback.current?.(revivedVisibleFrame, { + seq: preExitSnapshotSeq - 1 + revivedVisibleFrame.length, + rawLength: revivedVisibleFrame.length + }) + await flushAsyncTicks(20) + + const written = writes.join('') + expect(written).toContain('REVIVED SNAPSHOT') + expect(written).toContain('after revive') + binding.dispose() + }) + + it('restores hidden Codex output when the pty seq counter restarts without an observed exit', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: + | (( + data: string, + meta?: { seq?: number; rawLength?: number; background?: boolean } + ) => void) + | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + + const pane = createPane(1) + pane.terminal.cols = 80 + pane.terminal.rows = 8 + const { writes } = captureCallbackTerminalWrites(pane) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: true }, + startup: { command: 'codex' } + }) + const binding = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + const establishedFrame = '\r\x1b[2KWorking established' + capturedDataCallback.current?.(establishedFrame, { + seq: 50_000, + rawLength: establishedFrame.length + }) + await flushAsyncTicks(6) + + // Main lost the pty silently (no exit reached the renderer) and the seq + // counter restarted: the channel seq regression must invalidate the old + // high-water mark instead of covering the new stream. + ;(deps.isVisibleRef as { current: boolean }).current = false + const revivedHiddenFrame = '\r\x1b[2KWorking revived' + capturedDataCallback.current?.(revivedHiddenFrame, { + seq: revivedHiddenFrame.length, + rawLength: revivedHiddenFrame.length + }) + + const revivedSnapshotFrame = '\r\x1b[2KREVIVED SNAPSHOT2' + getMainBufferSnapshot.mockResolvedValue({ + data: revivedSnapshotFrame, + cols: 80, + rows: 8, + seq: revivedHiddenFrame.length + }) + ;(deps.isVisibleRef as { current: boolean }).current = true + const revivedVisibleFrame = '\r\x1b[2Kafter revive' + capturedDataCallback.current?.(revivedVisibleFrame, { + seq: revivedHiddenFrame.length + revivedVisibleFrame.length, + rawLength: revivedVisibleFrame.length + }) + await flushAsyncTicks(20) + + expect(writes.join('')).toContain('REVIVED SNAPSHOT2') + binding.dispose() + }) + it('writes ordinary hidden output live instead of proactively restoring a snapshot', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') @@ -7348,7 +8360,7 @@ describe('connectPanePty', () => { disposable.dispose() }) - it('preserves scrollback by skipping the destructive clear when restoring an alternate-screen snapshot', async () => { + it('clears only the alternate screen when restoring an alternate-screen snapshot', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') const capturedDataCallback: { @@ -7397,6 +8409,19 @@ describe('connectPanePty', () => { '\x1b[2J\x1b[3J\x1b[H', expect.any(Function) ) + // Why: the snapshot's ?1049h no-ops on an already-alt pane and serialized + // frames skip blank cells, so the pre-hide frame bleeds through unless the + // alt screen is cleared (without \x1b[3J) before the snapshot paints. + expect(pane.terminal.write).toHaveBeenCalledWith( + '\x1b[?1049h\x1b[2J\x1b[H', + expect.any(Function) + ) + const writes = (pane.terminal.write as ReturnType).mock.calls.map( + (call) => call[0] + ) + expect(writes.indexOf('\x1b[?1049h\x1b[2J\x1b[H')).toBeLessThan( + writes.indexOf('altscreen-snapshot\r\n') + ) expect(pane.terminal.write).toHaveBeenCalledWith('altscreen-snapshot\r\n', expect.any(Function)) disposable.dispose() }) @@ -8578,6 +9603,82 @@ describe('connectPanePty', () => { disposable.dispose() }) + it('preserves live agent modes when queued replay data carries the Cursor Agent screen', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const transport = createMockTransport('remote:env-1@@terminal-1') + const capturedReplayCallback: { + current: ((data: string, meta?: { clearBeforeReplay?: boolean }) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedReplayCallback.current = callbacks.onReplayData ?? null + return { id: 'remote:env-1@@terminal-1', replay: '' } + }) + transportFactoryQueue.push(transport) + setReattachPaneTitle('renamed shell') + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + const manager = createManager(1) + const deps = createDeps() + const disposable = await withMockedDocumentActiveElement(textarea, async () => { + const connection = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedReplayCallback.current?.(ANSI_POSITIONED_CURSOR_AGENT_REATTACH_SCREEN) + await flushAsyncTicks(12) + + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + expect.any(Function) + ) + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I') + return connection + }) + disposable.dispose() + }) + + it('downgrades a scrollback-only Cursor Agent signal when the parsed viewport shows a shell', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const transport = createMockTransport('remote:env-1@@terminal-1') + const capturedReplayCallback: { + current: ((data: string, meta?: { clearBeforeReplay?: boolean }) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedReplayCallback.current = callbacks.onReplayData ?? null + return { id: 'remote:env-1@@terminal-1', replay: '' } + }) + transportFactoryQueue.push(transport) + setReattachPaneTitle('renamed shell') + + const pane = createPane(1) + // Why: an inspectable buffer whose visible rows carry no Cursor Agent + // screen models a shell foreground after a dead run left its screen in + // scrollback. + Object.assign(pane.terminal.buffer.active, { + cursorX: 2, + getLine: () => undefined + }) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + const manager = createManager(1) + const deps = createDeps() + const disposable = await withMockedDocumentActiveElement(textarea, async () => { + const connection = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedReplayCallback.current?.(ANSI_POSITIONED_CURSOR_AGENT_REATTACH_SCREEN) + await flushAsyncTicks(12) + + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[?1004l', expect.any(Function)) + expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') + return connection + }) + disposable.dispose() + }) + it('does not clear restored scrollback when eager metadata replay opts out', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') @@ -8859,6 +9960,308 @@ describe('connectPanePty', () => { expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) }) + it('schedules WebGL atlas recovery for Vim-style foreground alternate-screen redraws', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate' + const refresh = vi.fn() + let parseCallback: (() => void) | undefined + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + parseCallback = callback + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.( + '\x1b[2J\x1b[H{"name":"eepo"}\r\n\x1b[2;1H{"name":"expo"}\x1b[K' + ) + + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + parseCallback?.() + expect(refresh).toHaveBeenCalledWith(0, 39, true) + expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + } finally { + restoreNavigator() + } + }) + + it('schedules WebGL atlas recovery when a foreground rewrite enters alternate screen', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const refresh = vi.fn() + let parseCallback: (() => void) | undefined + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + parseCallback = callback + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b[?1049h\x1b[2J\x1b[HVim package.json') + + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + // Why: xterm switches to the alternate buffer while parsing the chunk; + // the write callback observes the post-parse buffer state. + ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate' + parseCallback?.() + expect(refresh).toHaveBeenCalledWith(0, 39, true) + expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + } finally { + restoreNavigator() + } + }) + + it('schedules WebGL atlas recovery when the alternate-screen enter sequence splits across chunks', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const refresh = vi.fn() + let parseCallback: (() => void) | undefined + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + parseCallback = callback + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + // Why: PTY reads split CSI sequences at arbitrary byte boundaries (real + // vim sessions split cursor moves at 1024-byte chunk edges), so the + // enter sequence itself can straddle two onData chunks. + capturedDataCallback.current?.('\x1b[?104') + parseCallback?.() + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + + capturedDataCallback.current?.('9h\x1b[2J\x1b[H~\x1b[K') + ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate' + parseCallback?.() + expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + } finally { + restoreNavigator() + } + }) + + it('schedules WebGL atlas recovery when a foreground rewrite leaves alternate screen', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate' + const refresh = vi.fn() + let parseCallback: (() => void) | undefined + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + parseCallback = callback + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + // Vim's final frame erases the status line and restores the normal + // buffer in one chunk; the atlas must still rebuild for the restored + // prompt even though the post-parse buffer is back to normal. + capturedDataCallback.current?.('\x1b[34;1H\x1b[K\x1b[34;1H\x1b[?1049l\x1b[?25h') + ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'normal' + parseCallback?.() + expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + } finally { + restoreNavigator() + } + }) + + it('schedules WebGL atlas recovery when one chunk enters and exits alternate screen', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + let bufferChangeListener: (() => void) | undefined + ;( + pane.terminal.buffer as { + onBufferChange?: (listener: () => void) => { dispose: () => void } + } + ).onBufferChange = (listener) => { + bufferChangeListener = listener + return { dispose: vi.fn() } + } + const refresh = vi.fn() + let parseCallback: (() => void) | undefined + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + parseCallback = callback + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + // A coalesced backlog flush can parse a whole enter -> draw -> exit TUI + // interaction in one write, netting buffer type back to 'normal'; the + // buffer-switch count is what still marks it as alternate-screen work. + capturedDataCallback.current?.('\x1b[?1049h\x1b[2J\x1b[Hpager frame\x1b[K\x1b[?1049l') + bufferChangeListener?.() + bufferChangeListener?.() + parseCallback?.() + expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + } finally { + restoreNavigator() + } + }) + + it('schedules WebGL atlas recovery for real captured Vim redraw chunks split mid-sequence', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate' + const refresh = vi.fn() + let parseCallback: (() => void) | undefined + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + parseCallback = callback + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + // Captured from a real `vim package.json` session: a 1024-byte PTY read + // boundary cuts the cursor move \x1b[30;5H into "\x1b[30" + ";5H". + capturedDataCallback.current?.('"rules": {\x1b[29;15H\x1b[K\x1b[30') + parseCallback?.() + expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + + capturedDataCallback.current?.( + ';5H "js-combine-iterations": "off"\r\n }\x1b[31;6H\x1b[K\x1b[33;1H\x1b[?25h' + ) + parseCallback?.() + expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(2) + } finally { + restoreNavigator() + } + }) + + it('does not schedule WebGL atlas recovery for ordinary foreground shell rewrites', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const refresh = vi.fn() + let parseCallback: (() => void) | undefined + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + parseCallback = callback + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\r\x1b[Korca % npm test') + + parseCallback?.() + expect(refresh).toHaveBeenCalledWith(0, 39, true) + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + } finally { + restoreNavigator() + } + }) + it('does not schedule WebGL atlas recovery for plain synchronized foreground frames', async () => { const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') try { diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index e9db2010b0b..6961625b74f 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -1,7 +1,8 @@ /* oxlint-disable max-lines */ import type { PaneManager, ManagedPane } from '@/lib/pane-manager/pane-manager' import type { ManagedPaneInternal } from '@/lib/pane-manager/pane-manager-types' -import type { IDisposable } from '@xterm/xterm' +import type { IBuffer, IDisposable } from '@xterm/xterm' +import { resolveCursorAgentImeAnchor } from '@/lib/pane-manager/terminal-ime-anchor' import { detectAgentStatusFromTitle, isGeminiTerminalTitle, @@ -49,6 +50,7 @@ import { type PanePtyResizeHoldFlushDetail } from '@/lib/pane-manager/pane-pty-resize-hold' import { + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, POST_REPLAY_LIVE_SNAPSHOT_RESET, POST_REPLAY_MODE_RESET, POST_REPLAY_REATTACH_RESET, @@ -87,7 +89,11 @@ import { makePaneKey, parseLegacyNumericPaneKey } from '../../../../shared/stabl import { createTerminalCommandLifecycle } from './terminal-command-lifecycle' import { dispatchTerminalCommandFinishedEvent } from '@/hooks/terminal-command-finished-event' import { e2eConfig } from '@/lib/e2e-config' -import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry, + type AgentType +} from '../../../../shared/agent-status-types' import { isWebTerminalSurfaceTabId } from '@/runtime/web-terminal-surface-id' import { createAgentInterruptInference, @@ -158,6 +164,10 @@ import { normalizeCompatibleAgentTitleForOwner, resolveCompatibleAgentTypeForOwner } from '../../../../shared/agent-title-owner' +import { + isExpectedAgentProcess, + recognizeAgentProcessFromCommandLine +} from '../../../../shared/agent-process-recognition' import type { SetupSplitDirection, TuiAgent } from '../../../../shared/types' import { isWslUncPath } from '../../../../shared/wsl-paths' import { TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config' @@ -167,7 +177,6 @@ import { beginAgentStartupDeliveryAttempt, releaseAgentStartupDeliveryAttempt } from '@/lib/agent-startup-delayed-delivery' -import { isExpectedAgentProcess } from '../../../../shared/agent-process-recognition' const pendingSpawnByPaneKey = new Map>() // Why: TUI repaints can re-emit an already-handled fatal line after a pane @@ -182,6 +191,7 @@ const AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS = 1500 const AGENT_TASK_COMPLETE_NOTIFICATION_DETAIL_MAX_AGE_MS = 10_000 const COMMAND_CODE_OUTPUT_DONE_SETTLE_MS = 1500 const SSH_SHELL_READY_STARTUP_FALLBACK_MS = 1500 +const MANUAL_AGENT_COMMAND_MAX_CHARS = 4096 const STARTUP_DRAFT_PASTE_QUIET_MS = 1500 const STARTUP_DRAFT_PASTE_TIMEOUT_MS = 8000 const HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS = 5000 @@ -195,6 +205,8 @@ const SYNCHRONIZED_OUTPUT_END_SEQUENCE = '\x1b[?2026l' const SYNCHRONIZED_OUTPUT_MARKER_TAIL_CHARS = SYNCHRONIZED_OUTPUT_START_SEQUENCE.length - 1 const CURSOR_SHOW_SEQUENCE = '\x1b[?25h' const CURSOR_HIDE_SEQUENCE = '\x1b[?25l' +const TERMINAL_FOCUS_IN_SEQUENCE = '\x1b[I' +const FOCUS_REPORTING_DISABLE_SEQUENCE = '\x1b[?1004l' const REATTACH_IDLE_AGENT_CURSOR_RESET_DELAY_MS = 250 const FOREGROUND_THROUGHPUT_IMMEDIATE_CHARS = 2048 const FOREGROUND_INTERACTIVE_REDRAW_CHARS = 128 * 1024 @@ -220,6 +232,103 @@ type E2eTerminalPtyDataInjectionApi = { keys: () => string[] } +type TerminalWithFocusMode = { + textarea?: HTMLTextAreaElement | null + modes?: { + sendFocusMode?: boolean + } +} + +type TerminalWithInspectableBuffer = { + cols: number + rows: number + buffer?: { + active?: IBuffer + } +} + +// Why: replay bytes can carry a dead run's screen in scrollback — or still +// painted in the viewport with a shell prompt below it — so once xterm has +// parsed the replay the confirmation needs both the cursor-agent screen shape +// AND the parked cursor. A dead screen leaves the shell cursor after its +// prompt; a live agent that needs the focus-in is by definition parked, and a +// live agent that is not parked only loses focus reporting the way the +// pre-fix reattach always did. Returns null when the buffer is not +// inspectable (e.g. test doubles). +function parsedViewportShowsParkedCursorAgentScreen( + terminal: TerminalWithInspectableBuffer +): boolean | null { + const buffer = terminal.buffer?.active + if ( + !buffer || + typeof buffer.getLine !== 'function' || + typeof buffer.cursorX !== 'number' || + typeof buffer.cursorY !== 'number' + ) { + return null + } + return ( + resolveCursorAgentImeAnchor({ + buffer, + rows: terminal.rows, + cols: terminal.cols, + cursorX: buffer.cursorX, + cursorY: buffer.cursorY + }) !== null + ) +} + +function terminalHasFocusReportingEnabled(terminal: TerminalWithFocusMode): boolean { + return terminal.modes?.sendFocusMode === true +} + +function terminalOwnsDomFocus(terminal: TerminalWithFocusMode): boolean { + if (typeof document === 'undefined' || !terminal.textarea) { + return false + } + return document.activeElement === terminal.textarea +} + +function stripAnsiCsiSequences(data: string): string { + let normalized = '' + let index = 0 + while (index < data.length) { + if (data.charCodeAt(index) === 0x1b && data[index + 1] === '[') { + index += 2 + while (index < data.length) { + const code = data.charCodeAt(index) + index += 1 + if (code >= 0x40 && code <= 0x7e) { + break + } + } + continue + } + normalized += data[index] + index += 1 + } + return normalized +} + +const CURSOR_AGENT_REATTACH_HEADER = 'Cursor Agent' +const CURSOR_AGENT_REATTACH_INPUT_MARKER = '→' +const CURSOR_AGENT_REATTACH_SCREEN_SIGNAL_MAX_CHARS = 5000 + +function hasCursorAgentReattachPayloadScreenSignal(data: string): boolean { + const normalized = stripAnsiCsiSequences(data) + // Why: anchor on the LAST header occurrence — replay buffers keep scrollback, + // and an earlier finished run must not classify the current screen. + const headerIndex = normalized.lastIndexOf(CURSOR_AGENT_REATTACH_HEADER) + if (headerIndex === -1) { + return false + } + const screenTail = normalized.slice( + headerIndex + CURSOR_AGENT_REATTACH_HEADER.length, + headerIndex + CURSOR_AGENT_REATTACH_SCREEN_SIGNAL_MAX_CHARS + ) + return screenTail.includes(`${CURSOR_AGENT_REATTACH_INPUT_MARKER} `) +} + type E2eTerminalPtyDataInjectionWindow = Window & { __terminalPtyDataInjection?: E2eTerminalPtyDataInjectionApi __terminalHiddenSnapshotOverride?: E2eTerminalHiddenSnapshotOverrideApi @@ -942,6 +1051,7 @@ export function connectPanePty( let unregisterDocumentVisibilityRecovery: (() => void) | null = null let cleanupHiddenOutputRestoreDeferredRetry = (): void => {} let cleanupHiddenOutputRestoreForegroundDeadline = (): void => {} + let resetRendererOrderedSeqForPtyExit: (exitedPtyId: string) => void = () => {} let cleanupStartupDraftPasteTimers = (): void => {} let unregisterE2ePtyDataInjection = (): void => {} let startupInjectTimer: ReturnType | null = null @@ -1144,12 +1254,219 @@ export function connectPanePty( } /** * Resolves the authoritative owner agent type for this pane, checking tab launch, - * pane startup, and store state configuration. + * pane startup, typed command ownership, and store state configuration. * * Why: launch ownership wins so Pi-compatible live titles/hooks can't repaint an - * OMP-owned pane back to Pi; the stored status agentType is only the last-resort - * fallback because it can itself be a Pi-compatible frame. + * OMP-owned pane back to Pi; command ownership covers manually typed `omp` + * in generic terminals where launch metadata does not exist. */ + let commandInferredPaneAgent: TuiAgent | null = null + let pendingShellCommandLine = '' + let pendingShellCommandCursor = 0 + let commandInferredPaneAgentGeneration = 0 + let shellCommandInferenceSuspendedUntilCommandEnd = false + const resetPendingShellCommandLine = (): void => { + pendingShellCommandLine = '' + pendingShellCommandCursor = 0 + } + const rememberCommandInferredPaneAgent = (): void => { + const commandLine = pendingShellCommandLine.trim() + resetPendingShellCommandLine() + const nextAgent = commandLine + ? (recognizeAgentProcessFromCommandLine(commandLine)?.agent ?? null) + : null + commandInferredPaneAgent = nextAgent + commandInferredPaneAgentGeneration += 1 + } + const clearCommandInferredPaneAgent = (): void => { + commandInferredPaneAgent = null + resetPendingShellCommandLine() + commandInferredPaneAgentGeneration += 1 + } + const clearCommandInferredPaneAgentAfterPtySideEffects = (): void => { + const generation = commandInferredPaneAgentGeneration + resetPendingShellCommandLine() + queueMicrotask(() => { + setTimeout(() => { + if (commandInferredPaneAgentGeneration === generation) { + clearCommandInferredPaneAgent() + } + }, 0) + }) + } + const appendPendingShellCommandInput = (text: string): void => { + const available = MANUAL_AGENT_COMMAND_MAX_CHARS - pendingShellCommandLine.length + if (available <= 0) { + shellCommandInferenceSuspendedUntilCommandEnd = true + return + } + const inserted = text.slice(0, available) + pendingShellCommandLine = + pendingShellCommandLine.slice(0, pendingShellCommandCursor) + + inserted + + pendingShellCommandLine.slice(pendingShellCommandCursor) + pendingShellCommandCursor += inserted.length + if (inserted.length < text.length) { + shellCommandInferenceSuspendedUntilCommandEnd = true + } + } + const deletePendingShellCommandWord = (): void => { + const beforeCursor = pendingShellCommandLine.slice(0, pendingShellCommandCursor) + const afterCursor = pendingShellCommandLine.slice(pendingShellCommandCursor) + const nextBeforeCursor = beforeCursor.replace(/[^\S\r\n]*\S+[^\S\r\n]*$/, '') + pendingShellCommandLine = nextBeforeCursor + afterCursor + pendingShellCommandCursor = nextBeforeCursor.length + } + const cancelSuspendedShellCommandInference = (): void => { + if (!shellCommandInferenceSuspendedUntilCommandEnd) { + return + } + shellCommandInferenceSuspendedUntilCommandEnd = false + resetPendingShellCommandLine() + } + const deletePendingShellCommandCharacter = (): void => { + if (pendingShellCommandCursor === 0) { + return + } + pendingShellCommandLine = + pendingShellCommandLine.slice(0, pendingShellCommandCursor - 1) + + pendingShellCommandLine.slice(pendingShellCommandCursor) + pendingShellCommandCursor -= 1 + } + const deletePendingShellCommandCharacterAtCursor = (): void => { + if (pendingShellCommandCursor >= pendingShellCommandLine.length) { + return + } + pendingShellCommandLine = + pendingShellCommandLine.slice(0, pendingShellCommandCursor) + + pendingShellCommandLine.slice(pendingShellCommandCursor + 1) + } + const movePendingShellCommandCursor = (delta: number): void => { + pendingShellCommandCursor = Math.min( + pendingShellCommandLine.length, + Math.max(0, pendingShellCommandCursor + delta) + ) + } + const consumeShellCommandCsiSequence = (data: string, index: number): number | null => { + if (data.charCodeAt(index) !== 0x1b || data[index + 1] !== '[') { + return null + } + let cursor = index + 2 + while (cursor < data.length && /[0-9;?]/.test(data[cursor]!)) { + cursor += 1 + } + const final = data[cursor] + if (!final || !/[~A-Za-z]/.test(final)) { + return null + } + const params = data.slice(index + 2, cursor) + if (final === 'D') { + movePendingShellCommandCursor(-1) + } else if (final === 'C') { + movePendingShellCommandCursor(1) + } else if (final === 'H' || (final === '~' && params === '1')) { + pendingShellCommandCursor = 0 + } else if (final === 'F' || (final === '~' && params === '4')) { + pendingShellCommandCursor = pendingShellCommandLine.length + } else if (final === '~' && params === '3') { + deletePendingShellCommandCharacterAtCursor() + } else if (final === '~' && (params === '200' || params === '201')) { + // Bracketed paste wrappers are terminal framing, not shell command text. + } else { + resetPendingShellCommandLine() + } + return cursor + 1 + } + const getLivePaneAgentTitle = (): string | null => { + const state = useAppStore.getState() + const runtimeTitle = state.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] + const tabTitle = (state.tabsByWorktree[deps.worktreeId] ?? []).find( + (entry) => entry.id === deps.tabId + )?.title + return runtimeTitle ?? tabTitle ?? null + } + const hasFreshPaneAgentSurface = (): boolean => { + const state = useAppStore.getState() + const entry = state.agentStatusByPaneKey[cacheKey] + const now = Date.now() + const entryIsFresh = + entry && + typeof entry.updatedAt === 'number' && + now - entry.updatedAt <= AGENT_STATUS_STALE_AFTER_MS + if (entryIsFresh && entry.state !== 'done') { + return true + } + const liveTitle = getLivePaneAgentTitle() + return detectAgentStatusFromTitle(liveTitle ?? '') !== null + } + const observeAcceptedShellCommandInput = (data: string): void => { + if (commandInferredPaneAgent) { + return + } + // Why: bytes typed inside a live agent TUI are prompt text, not shell + // commands, even if they spell another agent binary name. + if (hasFreshPaneAgentSurface()) { + resetPendingShellCommandLine() + return + } + if (shellCommandInferenceSuspendedUntilCommandEnd) { + if (data.includes('\x03') || data.includes('\x15')) { + shellCommandInferenceSuspendedUntilCommandEnd = false + resetPendingShellCommandLine() + } + if (data.includes('\r') || data.includes('\n')) { + shellCommandInferenceSuspendedUntilCommandEnd = false + } + return + } + if (data.length > MANUAL_AGENT_COMMAND_MAX_CHARS) { + resetPendingShellCommandLine() + shellCommandInferenceSuspendedUntilCommandEnd = !data.includes('\r') && !data.includes('\n') + return + } + for (let index = 0; index < data.length; index += 1) { + const char = data[index]! + if (char === '\r' || char === '\n') { + shellCommandInferenceSuspendedUntilCommandEnd = false + rememberCommandInferredPaneAgent() + if (commandInferredPaneAgent) { + return + } + continue + } + if (char === '\x7f' || char === '\b') { + deletePendingShellCommandCharacter() + continue + } + if (char === '\x17') { + deletePendingShellCommandWord() + continue + } + if (char === '\x03' || char === '\x15') { + resetPendingShellCommandLine() + continue + } + if (char === '\x1b') { + const nextIndex = consumeShellCommandCsiSequence(data, index) + if (nextIndex !== null) { + index = nextIndex - 1 + continue + } + resetPendingShellCommandLine() + continue + } + if (char < ' ') { + resetPendingShellCommandLine() + continue + } + if (char >= ' ') { + appendPendingShellCommandInput(char) + if (shellCommandInferenceSuspendedUntilCommandEnd) { + return + } + } + } + } const getAuthoritativePaneAgent = (): AgentType | undefined => { const state = useAppStore.getState() const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find( @@ -1159,6 +1476,7 @@ export function connectPanePty( tab?.launchAgent ?? paneStartup?.launchAgent ?? paneStartup?.initialAgentStatus?.agent ?? + commandInferredPaneAgent ?? state.agentStatusByPaneKey[cacheKey]?.agentType ) } @@ -1236,6 +1554,52 @@ export function connectPanePty( )?.title return runtimeTitle ?? tabTitle ?? null } + let reattachReplayPayloadHasCursorAgentSignal = false + // Why: post-parse veto callbacks must only judge the latest replay frame; a + // newer frame bumps the generation so a stale callback stands down. + let reattachReplayPayloadSignalGeneration = 0 + const rememberReattachPayloadAgentSignal = ( + data: string, + opts: { fullScreenReplay: boolean } + ): void => { + reattachReplayPayloadSignalGeneration += 1 + // Why: ordinary scrollback can mention agent names. Treat replay bytes as + // a live Cursor Agent signal only when they look like its restored screen. + const signal = hasCursorAgentReattachPayloadScreenSignal(data) + // Why: incremental (non-clearing) replay frames repaint only part of the + // screen, so their bytes can only add evidence — a full-screen replay is + // the authoritative repaint that may clear the flag. + reattachReplayPayloadHasCursorAgentSignal = opts.fullScreenReplay + ? signal + : reattachReplayPayloadHasCursorAgentSignal || signal + } + const isCursorAgentNativeTitle = (title: string): boolean => { + return title.trim().toLowerCase() === CURSOR_AGENT_REATTACH_HEADER.toLowerCase() + } + const hasLiveAgentReattachStatusOrTitleSignal = (): boolean => { + // Why: launch ownership (tab.launchAgent) never decays after the agent + // exits, so it must not count as liveness here — only live status, live + // titles, and the replayed screen shape do. + if (useAppStore.getState().agentStatusByPaneKey[cacheKey]) { + return true + } + const title = getCurrentTerminalTitle() ?? '' + // Why: broad token matching (getAgentLabel) fires on titles like + // "ssh devin@host"; that surface is too loose to gate mode preservation + // and PTY byte injection, so only exact/status titles count here. + return detectAgentStatusFromTitle(title) !== null || isCursorAgentNativeTitle(title) + } + const hasLiveAgentReattachSignal = (): boolean => { + return hasLiveAgentReattachStatusOrTitleSignal() || reattachReplayPayloadHasCursorAgentSignal + } + const shouldPreserveAgentReattachModes = (): boolean => { + // Why: ordinary shells can inherit stale ?25l/?1004h from replay bytes. + // Preserve those modes only when reattach still looks agent-owned. + return hasLiveAgentReattachSignal() + } + const shouldSendFocusedAgentReattachFocusIn = (): boolean => { + return terminalOwnsDomFocus(pane.terminal) && shouldPreserveAgentReattachModes() + } const scheduleReattachIdleAgentCursorReset = (): void => { const status = detectAgentStatusFromTitle(getCurrentTerminalTitle() ?? '') if (status !== 'idle' && status !== 'permission') { @@ -1382,6 +1746,7 @@ export function connectPanePty( } const commandLifecycle = createTerminalCommandLifecycle({ onCommandFinished: () => { + clearCommandInferredPaneAgentAfterPtySideEffects() // Why: the finished command may have moved HEAD or the index (e.g. // `git checkout`); nudge git UI now instead of waiting for a poll. dispatchTerminalCommandFinishedEvent(deps.worktreeId) @@ -1612,6 +1977,7 @@ export function connectPanePty( if (handledExitPtyId === ptyId) { return } + resetRendererOrderedSeqForPtyExit(ptyId) const currentPaneTransport = deps.paneTransportsRef.current.get(pane.id) if (currentPaneTransport && currentPaneTransport !== transport) { // Why: an old transport can deliver a late exit after this pane has @@ -2157,6 +2523,7 @@ export function connectPanePty( } } const onAgentExited = (): void => { + clearCommandInferredPaneAgent() // Why: when the terminal title reverts to a plain shell (e.g., "bash", "zsh"), // the agent has exited. Clear any running cache timer so the sidebar doesn't // show a stale countdown for a tab that no longer has an active Claude session. @@ -2426,6 +2793,11 @@ export function connectPanePty( // excluded because those transports do not expose sendInputAccepted. const acknowledgedIntent = intent ?? inferIntentFromExactTerminalInput(data) if (acknowledgedIntent && transport.sendInputAccepted) { + if (acknowledgedIntent === 'ctrl-c') { + // Why: the accepted-write callback is async; let the next command be + // inferred if the user cancelled an oversized line and immediately typed. + cancelSuspendedShellCommandInference() + } clearPendingTerminalInputIntent() markTerminalInputSent() const writePromise = transport @@ -2433,6 +2805,7 @@ export function connectPanePty( .then((accepted) => { if (accepted) { recordAcceptedTerminalInputForHibernation() + observeAcceptedShellCommandInput(data) observeAcceptedTerminalInput(data, acknowledgedIntent) interruptInference.observeInputIntent(acknowledgedIntent) observeTitleOnlyInterrupt() @@ -2447,6 +2820,7 @@ export function connectPanePty( if (intent) { if (transport.sendInput(data)) { markAcceptedTerminalInputSent() + observeAcceptedShellCommandInput(data) observeAcceptedTerminalInput(data, intent) } clearPendingTerminalInputIntent() @@ -2454,6 +2828,7 @@ export function connectPanePty( } if (transport.sendInput(data)) { markAcceptedTerminalInputSent() + observeAcceptedShellCommandInput(data) observeAcceptedTerminalInput(data) observeSentTerminalInputIntent(data) } else { @@ -2515,6 +2890,14 @@ export function connectPanePty( forwardPtyResize(cols, rows) }) + // Why: a rewrite chunk can enter AND exit the alternate screen in one parse + // (fast-quitting TUI), netting buffer.active.type back to 'normal'; counting + // switches keeps those redraws visible to the atlas-recovery check. + let alternateScreenBufferSwitches = 0 + const onBufferChangeDisposable = pane.terminal.buffer.onBufferChange?.(() => { + alternateScreenBufferSwitches += 1 + }) + // Why: renderer resize forwarding is fire-and-forget. A visible pane can // finish with xterm at the right grid while the PTY silently kept an older // grid, so Codex keeps composing against stale columns. Fit first so xterm's @@ -3473,6 +3856,52 @@ export function connectPanePty( return replayIntoTerminalAsync(pane, deps.replayingPanesRef, data) } + const reattachReplayResetSequence = (): string => { + return shouldPreserveAgentReattachModes() + ? POST_REPLAY_LIVE_AGENT_REATTACH_RESET + : POST_REPLAY_REATTACH_RESET + } + + const sendFocusedReattachFocusInAfterReplay = (): void => { + const scheduledGeneration = reattachReplayPayloadSignalGeneration + void waitForTerminalOutputParsed(pane.terminal).then(() => { + if (disposed) { + return + } + // Why: a newer replay frame owns the judgment; its own post-parse + // callback will re-evaluate against its own viewport. + if (scheduledGeneration !== reattachReplayPayloadSignalGeneration) { + return + } + // Why: the replay-byte signal also matches a dead run's screen — in + // scrollback or still painted above a fresh shell prompt. The parsed + // viewport is the ground truth; unless it shows a parked-cursor + // cursor-agent screen and no status/title corroborates, downgrade to + // the plain-shell behavior (drop focus reporting, skip focus-in). + if ( + !hasLiveAgentReattachStatusOrTitleSignal() && + reattachReplayPayloadHasCursorAgentSignal + ) { + if (parsedViewportShowsParkedCursorAgentScreen(pane.terminal) === false) { + reattachReplayPayloadHasCursorAgentSignal = false + writeReplayData(FOCUS_REPORTING_DISABLE_SEQUENCE) + return + } + } + // Why: a live TUI such as cursor-agent parks the real terminal cursor off + // its own input caret and moves it back only on a focus-in. Reattach + // reuses the same live PTY and the xterm textarea already holds DOM + // focus, so xterm never emits the focus-in the agent needs and the parked + // cursor anchors the IME/caret to the wrong cell. Gated on ?1004h so a + // bare shell never receives a stray \x1b[I. + const sendFocusMode = terminalHasFocusReportingEnabled(pane.terminal) + if (!shouldSendFocusedAgentReattachFocusIn() || !sendFocusMode) { + return + } + transport.sendInput(TERMINAL_FOCUS_IN_SEQUENCE) + }) + } + let replayWriteQueue = Promise.resolve() type PendingReplayData = { data: string @@ -3491,9 +3920,15 @@ export function connectPanePty( if (clearBeforeReplay) { await writeReplayDataAsync('\x1b[2J\x1b[3J\x1b[H') } + if (clearBeforeReplay || data.length > 0) { + // Why: an empty clearing frame is still an authoritative repaint and + // must clear a stale agent signal from an earlier payload. + rememberReattachPayloadAgentSignal(data, { fullScreenReplay: clearBeforeReplay }) + } await writeReplayDataAsync(data) if (clearBeforeReplay || data.length > 0) { - await writeReplayDataAsync(POST_REPLAY_REATTACH_RESET) + await writeReplayDataAsync(reattachReplayResetSequence()) + sendFocusedReattachFocusInAfterReplay() } if (disposed) { pendingReplayData = null @@ -3561,6 +3996,10 @@ export function connectPanePty( let hiddenSynchronizedOutputMarkerTail = '' let hiddenRewriteChunkEndedWithCarriageReturn = false let hiddenRewriteCsiScanTail = '' + let rendererOrderedPtyId: string | null = null + let rendererOrderedSeq: number | null = null + let rendererChannelSeqPtyId: string | null = null + let rendererChannelSeq: number | null = null function canUseMainBufferSnapshot(ptyId: string | null): ptyId is string { return Boolean(ptyId) && !isRemoteRuntimePtyId(ptyId) @@ -3686,6 +4125,25 @@ export function connectPanePty( return decision.prefersRenderRefresh } + // Why: Vim-style TUI redraws are plain-ASCII in-place rewrites whose erased + // cells can keep stale WebGL glyphs until the shared atlas rebuilds. Whether + // a rewrite touched the alternate screen is only authoritative once xterm + // parses the chunk (enter/exit sequences can split across PTY chunks), so + // capture the pre-parse state and decide the rest at parse completion. + function alternateScreenRewriteAtlasRecoveryOnParsed(): () => void { + const wasAlternateScreenBuffer = pane.terminal.buffer.active.type === 'alternate' + const switchesBeforeParse = alternateScreenBufferSwitches + return () => { + if ( + wasAlternateScreenBuffer || + alternateScreenBufferSwitches !== switchesBeforeParse || + pane.terminal.buffer.active.type === 'alternate' + ) { + scheduleTerminalWebglAtlasRecovery() + } + } + } + function shouldForceForegroundRenderRefresh(data: string): { refresh: boolean inPlaceRewrite: boolean @@ -3773,6 +4231,13 @@ export function connectPanePty( !foregroundOutput && hiddenOutputNeedsAtlasRecoveryAfterParse(data) const recoverWebglAtlasAfterParse = renderRefreshDecision.recoverWebglAtlasAfterParse || recoverHiddenWebglAtlasAfterParse + // Why: atlas recovery must repaint from the parsed xterm buffer, not a + // pre-write snapshot that a late TUI redraw can immediately stale. + const onParsedAtlasRecovery = recoverWebglAtlasAfterParse + ? scheduleTerminalWebglAtlasRecovery + : renderRefreshDecision.inPlaceRewrite + ? alternateScreenRewriteAtlasRecoveryOnParsed() + : undefined const foregroundRenderRefreshNeeded = renderRefreshDecision.refresh // Why: see nativeWindowsRewriteNeedsFollowupRenderRefresh — Claude Code's // in-place prompt redraws on Windows ConPTY can paint one frame late, so a @@ -3819,9 +4284,7 @@ export function connectPanePty( foregroundRenderRefreshNeeded), followupForegroundRefresh: nativeWindowsCursorRestore || nativeWindowsInPlaceRewriteFollowup, - // Why: atlas recovery must repaint from the parsed xterm buffer, not - // a pre-write snapshot that a late TUI redraw can immediately stale. - onParsed: recoverWebglAtlasAfterParse ? scheduleTerminalWebglAtlasRecovery : undefined, + onParsed: onParsedAtlasRecovery, stripTransientCursorShows: shouldProtectNativeWindowsSynchronizedOutput && foreground, coalesceForeground: synchronizedForegroundOutput && synchronizedOutputEnded, holdForeground: synchronizedForegroundOutput && nextSynchronizedForegroundOutputActive @@ -4035,6 +4498,79 @@ export function connectPanePty( return chunk.data.slice(offset) } + function recordRendererOrderedSeq(meta?: Pick): void { + if (typeof meta?.seq !== 'number') { + return + } + const ptyId = transport.getPtyId() + if (!ptyId) { + return + } + if (rendererOrderedPtyId !== ptyId) { + rendererOrderedPtyId = ptyId + rendererOrderedSeq = meta.seq + return + } + rendererOrderedSeq = Math.max(rendererOrderedSeq ?? 0, meta.seq) + } + + resetRendererOrderedSeqForPtyExit = (exitedPtyId: string): void => { + // Why: an exit ends this ptyId's seq domain. A revived session can reuse + // the id with a restarted main-side counter, and a stale high-water mark + // would wrongly cover — and silently drop — every hidden byte it emits. + if (rendererOrderedPtyId === exitedPtyId) { + rendererOrderedPtyId = null + rendererOrderedSeq = null + } + if (rendererChannelSeqPtyId === exitedPtyId) { + rendererChannelSeqPtyId = null + rendererChannelSeq = null + } + } + + function observeRendererOrderedSeqRegression(meta: PtyDataMeta | undefined): void { + if (typeof meta?.seq !== 'number') { + return + } + const ptyId = transport.getPtyId() + if (!ptyId) { + return + } + if (rendererChannelSeqPtyId !== ptyId) { + rendererChannelSeqPtyId = ptyId + rendererChannelSeq = meta.seq + return + } + if (rendererChannelSeq !== null && meta.seq < rendererChannelSeq) { + // Why: pty:data delivery is FIFO per pty, so seq only moves backwards + // when the session was revived without an observed exit and restarted + // its counter. Drop the stale ordered baseline instead of letting it + // cover the new stream's bytes. + if (rendererOrderedPtyId === ptyId) { + rendererOrderedPtyId = null + rendererOrderedSeq = null + } + } + rendererChannelSeq = meta.seq + } + + function getHiddenRendererDataAfterOrderedSeq( + data: string, + meta: PtyDataMeta | undefined + ): string | null { + if ( + rendererOrderedPtyId === null || + rendererOrderedSeq === null || + transport.getPtyId() !== rendererOrderedPtyId + ) { + return data + } + return getChunkDataAfterSnapshot( + { data, seq: meta?.seq, rawLength: meta?.rawLength }, + rendererOrderedSeq + ) + } + function drainPendingLiveChunksAfterSnapshot(snapshotSeq: number | undefined): boolean { if (hiddenOutputRestorePendingOverflow) { hiddenOutputRestorePendingOverflow = false @@ -4058,6 +4594,7 @@ export function connectPanePty( } if (data) { writePtyOutputToXterm(data, true) + recordRendererOrderedSeq(chunk) } } if (hiddenOutputRestorePendingOverflow) { @@ -4308,10 +4845,18 @@ export function connectPanePty( // clearing on restore loses scroll-up after a hidden->visible return. // Mirrors the attach-time guard in pty-transport.ts. writeReplayData('\x1b[2J\x1b[3J\x1b[H') + } else { + // Why: the snapshot's own ?1049h is a no-op when the pane is already on + // the alternate screen, and the serialized frame skips blank cells — so + // without clearing the alt screen the pre-hide frame bleeds through + // every cell the final frame leaves blank. \x1b[2J on the alt buffer + // does not touch the normal buffer's scrollback the TUI returns to. + writeReplayData('\x1b[?1049h\x1b[2J\x1b[H') } writeReplayData(snapshot.data) writeReplayData(POST_REPLAY_LIVE_SNAPSHOT_RESET) hiddenRendererStateDirty = false + recordRendererOrderedSeq(snapshot) resetHiddenRendererRiskState() recordTerminalOutput(pane.terminal) const currentPtyId = transport.getPtyId() @@ -4561,6 +5106,21 @@ export function connectPanePty( meta, pendingForegroundQuery?.consumedCurrentChars ?? 0 ) + observeRendererOrderedSeqRegression(meta) + const orderedRendererData = foreground + ? rendererData + : getHiddenRendererDataAfterOrderedSeq(rendererData, rendererMeta) + if (orderedRendererData === null) { + // Why: renderer-side filtering cannot map cleaned text back onto raw + // sequence offsets. Rebuild from main instead of risking stale bytes. + markHiddenOutputRestoreNeeded() + schedulePendingStartupCommandDelivery() + return + } + if (!foreground && orderedRendererData.length === 0) { + schedulePendingStartupCommandDelivery() + return + } if (pendingForegroundQuery?.statelessQueryData) { writePtyOutputToXterm(pendingForegroundQuery.statelessQueryData, true, { hiddenStartupRendererQuery: true @@ -4579,11 +5139,11 @@ export function connectPanePty( meta?.background === true && shouldWritePtyOutputForeground(deps.isVisibleRef.current) && pane.terminal.buffer.active.type === 'alternate' && - !containsStatefulRendererQuery(data) + !containsStatefulRendererQuery(orderedRendererData) if (skipBackgroundAlternateScreenFrame) { - skipBackgroundAlternateScreenOutput(data) - } else if (shouldSkipHiddenRendererOutput(foreground, data)) { - skipHiddenRendererOutput(data) + skipBackgroundAlternateScreenOutput(orderedRendererData) + } else if (shouldSkipHiddenRendererOutput(foreground, orderedRendererData)) { + skipHiddenRendererOutput(orderedRendererData) } else if ( (hiddenOutputRestoreNeeded || hiddenOutputRestoreInFlight) && restoreAppliesToCurrentPty @@ -4592,7 +5152,7 @@ export function connectPanePty( if (pendingForegroundQuery?.statefulQueryData) { queueLiveChunkDuringRestore(pendingForegroundQuery.statefulQueryData) } - queueLiveChunkDuringRestore(rendererData, rendererMeta) + queueLiveChunkDuringRestore(orderedRendererData, rendererMeta) requestHiddenOutputRestoreIfNeeded() } else if (hiddenOutputRestoreInFlight) { resetSkippedHiddenRendererRiskState() @@ -4605,7 +5165,10 @@ export function connectPanePty( hiddenStartupRendererQuery: true }) } - writePtyOutputToXterm(rendererData, foreground) + writePtyOutputToXterm(orderedRendererData, foreground) + if (foreground) { + recordRendererOrderedSeq(rendererMeta) + } } schedulePendingStartupCommandDelivery() @@ -4692,12 +5255,14 @@ export function connectPanePty( // the daemon and relay are by definition tracking the same session // and only the freshest source belongs on screen. if (connectResult?.snapshot) { + rememberReattachPayloadAgentSignal(connectResult.snapshot, { fullScreenReplay: true }) writeReplayData('\x1b[2J\x1b[3J\x1b[H') writeReplayData(connectResult.snapshot) // Snapshot reattach keeps a live session, so avoid the broader mode - // reset. We only drop stale cursor/focus state that should not leak - // from replay bytes into the restored renderer terminal. - writeReplayData(POST_REPLAY_REATTACH_RESET) + // reset. We only drop renderer-owned state that should not leak from + // replay bytes into the restored renderer terminal. + writeReplayData(reattachReplayResetSequence()) + sendFocusedReattachFocusInAfterReplay() if (connectResult.coldRestore) { // Snapshot superseded the cold-restore payload — ack it so the // daemon does not redeliver it on the next reattach. @@ -4706,13 +5271,15 @@ export function connectPanePty( } } } else if (connectResult?.replay) { + rememberReattachPayloadAgentSignal(connectResult.replay, { fullScreenReplay: true }) // Relay replay holds the last 100 KB of raw output. The xterm may // already hold pre-disconnect content; clear first to avoid - // duplication. The reattach reset prevents stale cursor/focus mode - // bits in the replayed data from leaking into the restored terminal. + // duplication. The reattach reset clears renderer-owned state without + // tearing down the still-running TUI's live modes. writeReplayData('\x1b[2J\x1b[3J\x1b[H') writeReplayData(connectResult.replay) - writeReplayData(POST_REPLAY_REATTACH_RESET) + writeReplayData(reattachReplayResetSequence()) + sendFocusedReattachFocusInAfterReplay() if (connectResult.coldRestore) { if (!isRemoteRuntimePtyId(ptyId)) { window.api.pty.ackColdRestore(ptyId) @@ -5465,6 +6032,7 @@ export function connectPanePty( onDataDisposable.dispose() terminalCapabilityRepliesDisposable.dispose() onResizeDisposable.dispose() + onBufferChangeDisposable?.dispose() pane.container.removeEventListener(PANE_PTY_RESIZE_HOLD_FLUSH_EVENT, onHeldPtyResizeFlush) geometryReportObserver?.disconnect() if (pendingGeometryReportRaf !== null) { diff --git a/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts b/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts new file mode 100644 index 00000000000..ddf856f05ae --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest' +import { + TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS, + createPtyInputWriteQueue +} from './pty-input-write-queue' +import { + TERMINAL_INPUT_CHUNK_MAX_BYTES, + TERMINAL_INPUT_MAX_BYTES +} from '../../../../shared/terminal-input' + +const WHEEL_UP_REPORT = '\x1b[<64;60;20M' + +type WriteRecord = { id: string; data: string } + +function createRecordingQueue(options: { writable?: () => boolean } = {}): { + writes: WriteRecord[] + queue: ReturnType +} { + const writes: WriteRecord[] = [] + const queue = createPtyInputWriteQueue({ + isWritable: () => options.writable?.() ?? true, + write: (id, data) => writes.push({ id, data }) + }) + return { writes, queue } +} + +describe('pty input write queue', () => { + it('coalesces a dense burst of wheel reports instead of one write per macrotask turn', async () => { + const { writes, queue } = createRecordingQueue() + + // Simulates a 2s aggressive trackpad gesture at 120Hz: 240 SGR reports + // enqueued while the drain cannot run between events. + for (let i = 0; i < 240; i += 1) { + expect(queue.enqueue('pty-1', WHEEL_UP_REPORT)).toBe(true) + } + await queue.waitForDrain() + + // First report flushes immediately (keystroke latency); everything queued + // behind it must drain as a single coalesced write, not 239 timer turns. + expect(writes.length).toBe(2) + expect(writes[0]?.data).toBe(WHEEL_UP_REPORT) + expect(writes[1]?.data).toBe(WHEEL_UP_REPORT.repeat(239)) + expect(writes.map((write) => write.id)).toEqual(['pty-1', 'pty-1']) + }) + + it('preserves byte order and content across coalesced writes', async () => { + const { writes, queue } = createRecordingQueue() + + const inputs = ['a', '\x1b[<65;1;1M', 'bc', '\x1b[A', 'd'] + for (const input of inputs) { + queue.enqueue('pty-1', input) + } + await queue.waitForDrain() + + expect(writes.map((write) => write.data).join('')).toBe(inputs.join('')) + }) + + it('does not coalesce across different PTY ids', async () => { + const writes: WriteRecord[] = [] + const queue = createPtyInputWriteQueue({ + isWritable: () => true, + write: (id, data) => writes.push({ id, data }) + }) + + queue.enqueue('pty-1', 'a') + queue.enqueue('pty-1', 'b') + queue.enqueue('pty-2', 'c') + queue.enqueue('pty-1', 'd') + await queue.waitForDrain() + + expect(writes).toEqual([ + { id: 'pty-1', data: 'a' }, + { id: 'pty-1', data: 'b' }, + { id: 'pty-2', data: 'c' }, + { id: 'pty-1', data: 'd' } + ]) + }) + + it('keeps coalesced payloads under the input chunk byte cap', async () => { + const { writes, queue } = createRecordingQueue() + + const piece = 'x'.repeat(1000) + for (let i = 0; i < 12; i += 1) { + queue.enqueue('pty-1', piece) + } + await queue.waitForDrain() + + expect(writes.map((write) => write.data).join('')).toBe(piece.repeat(12)) + for (const write of writes) { + expect(write.data.length).toBeLessThanOrEqual(TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS) + } + expect(writes.length).toBeGreaterThan(1) + }) + + it('still chunks oversized items and keeps trailing input ordered behind them', async () => { + const { writes, queue } = createRecordingQueue() + + const large = 'y'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2 + 100) + queue.enqueue('pty-1', 'before') + queue.enqueue('pty-1', large) + queue.enqueue('pty-1', 'after') + await queue.waitForDrain() + + expect(writes.map((write) => write.data).join('')).toBe(`before${large}after`) + expect(writes.at(-1)?.data).toBe('after') + for (const write of writes) { + expect(write.data.length).toBeLessThanOrEqual(TERMINAL_INPUT_CHUNK_MAX_BYTES) + } + }) + + it('rejects input over the terminal input byte limit without writing', async () => { + const { writes, queue } = createRecordingQueue() + + expect(queue.enqueue('pty-1', 'z'.repeat(TERMINAL_INPUT_MAX_BYTES + 1))).toBe(false) + await queue.waitForDrain() + + expect(writes).toEqual([]) + }) + + it('drops queued input for PTYs that are no longer writable', async () => { + let writable = true + const { writes, queue } = createRecordingQueue({ writable: () => writable }) + + queue.enqueue('pty-1', 'a') + writable = false + queue.enqueue('pty-1', 'b') + await queue.waitForDrain() + + expect(writes).toEqual([{ id: 'pty-1', data: 'a' }]) + }) + + it('clear() drops pending input that has not been written yet', async () => { + const writes: WriteRecord[] = [] + const pendingYields: (() => void)[] = [] + const queue = createPtyInputWriteQueue({ + isWritable: () => true, + write: (id, data) => writes.push({ id, data }), + yieldBetweenWrites: () => + new Promise((resolve) => { + pendingYields.push(resolve) + }) + }) + + const large = 'y'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 3) + queue.enqueue('pty-1', large) + queue.enqueue('pty-1', 'tail') + // First chunk is written synchronously, then the drain parks on the yield. + expect(writes.length).toBe(1) + + queue.clear() + pendingYields.shift()?.() + await queue.waitForDrain() + + expect(writes.length).toBe(1) + expect(writes.map((write) => write.data).join('')).not.toContain('tail') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts b/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts new file mode 100644 index 00000000000..b20e764df15 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts @@ -0,0 +1,155 @@ +import { + isTerminalInputTooLargeWithDeferredMeasurement, + iterateTerminalInputChunks +} from '../../../../shared/terminal-input' + +// Why: 4096 UTF-16 code units encode to at most ~12KB UTF-8, safely under the +// 16KB TERMINAL_INPUT_CHUNK_MAX_BYTES cap without paying byte measurement on +// the hot input path. +export const TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS = 4096 + +type PendingPtyInputWrite = { + id: string + text: string + tooLarge: boolean | Promise + chunks?: Iterator + nextChunk?: string +} + +export type PtyInputWriteQueue = { + enqueue: (id: string, data: string) => boolean + waitForDrain: () => Promise + clear: () => void +} + +export type PtyInputWriteQueueDeps = { + isWritable: (id: string) => boolean + write: (id: string, data: string) => void + yieldBetweenWrites?: () => Promise +} + +function defaultYieldBetweenWrites(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)) +} + +function isCoalescibleText(text: string): boolean { + return text.length <= TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS +} + +export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInputWriteQueue { + const yieldBetweenWrites = deps.yieldBetweenWrites ?? defaultYieldBetweenWrites + let pending: PendingPtyInputWrite[] = [] + let drainPromise: Promise | null = null + + async function drain(): Promise { + while (pending.length > 0) { + const next = pending[0] + if (!next) { + pending.shift() + continue + } + if (!deps.isWritable(next.id)) { + pending.shift() + continue + } + if (next.tooLarge !== false) { + next.tooLarge = await Promise.resolve(next.tooLarge).catch(() => true) + if (next.tooLarge) { + pending.shift() + continue + } + if (!deps.isWritable(next.id)) { + pending.shift() + continue + } + } + // Why: dense input streams (SGR wheel reports during trackpad momentum, + // key auto-repeat) enqueue one tiny item per event. Writing one item per + // macrotask turn lets Chromium's nested-timer clamp pace the drain at + // ≥4ms per item, so a fast gesture's reports reach the PTY seconds after + // the gesture ended and the TUI visibly replays them one by one. + // Coalescing consecutive validated small items into a single write keeps + // the PTY byte stream identical while draining the backlog in one turn. + if (next.chunks === undefined && isCoalescibleText(next.text)) { + let payload = next.text + pending.shift() + while (pending.length > 0) { + const peek = pending[0] + if ( + !peek || + peek.id !== next.id || + peek.tooLarge !== false || + peek.chunks !== undefined || + !isCoalescibleText(peek.text) || + payload.length + peek.text.length > TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS + ) { + break + } + payload += peek.text + pending.shift() + } + deps.write(next.id, payload) + if (pending.length > 0) { + await yieldBetweenWrites() + } + continue + } + next.chunks ??= iterateTerminalInputChunks(next.text) + const chunk = + next.nextChunk === undefined ? next.chunks.next() : { done: false, value: next.nextChunk } + next.nextChunk = undefined + if (chunk.done) { + pending.shift() + continue + } + deps.write(next.id, chunk.value) + const following = next.chunks.next() + if (following.done) { + pending.shift() + } else { + next.nextChunk = following.value + } + if (pending.length > 0) { + await yieldBetweenWrites() + } + } + } + + function scheduleDrain(): void { + if (drainPromise) { + return + } + drainPromise = drain().finally(() => { + drainPromise = null + if (pending.length > 0) { + scheduleDrain() + } + }) + } + + return { + enqueue(id: string, data: string): boolean { + try { + const tooLarge = isTerminalInputTooLargeWithDeferredMeasurement(data) + if (tooLarge === true) { + return false + } + pending.push({ id, text: data, tooLarge }) + scheduleDrain() + return true + } catch { + return false + } + }, + + async waitForDrain(): Promise { + while (drainPromise) { + await drainPromise + } + }, + + clear(): void { + pending = [] + } + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 474ee267832..f15f6ea990b 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -23,6 +23,7 @@ import { getEagerPtyBufferHandle } from './pty-dispatcher' import { drainPreHandlerPtyData, drainPreHandlerPtyExit } from './pty-pre-handler-buffer' +import { createPtyInputWriteQueue } from './pty-input-write-queue' import type { PtyDataMeta } from './pty-dispatcher' import type { IpcPtyTransportOptions, PtyConnectResult, PtyTransport } from './pty-transport-types' import { createBellDetector } from './bell-detector' @@ -59,14 +60,6 @@ const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED' const STALE_TITLE_TIMEOUT = 3000 // ms before stale working title is cleared const MAX_PTY_SIDE_EFFECTS_PER_DRAIN = 64 -type PendingPtyInputWrite = { - id: string - text: string - tooLarge: boolean | Promise - chunks?: Iterator - nextChunk?: string -} - // Why: onAgentStatus callback added to IpcPtyTransportOptions in pty-dispatcher // so the OSC 9999 status payloads can be forwarded to the store. @@ -472,8 +465,10 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra // unread marks, or notifications for unrelated worktrees just because Orca // is reconnecting background terminals on launch. let suppressAttentionEvents = false - let pendingInputWrites: PendingPtyInputWrite[] = [] - let inputWriteDrainPromise: Promise | null = null + const inputWriteQueue = createPtyInputWriteQueue({ + isWritable: (id) => connected && ptyId === id, + write: (id, data) => window.api.pty.write(id, data) + }) const outputProcessor = createPtyOutputProcessor({ onTitleChange, onBell, @@ -541,84 +536,6 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra return new Promise((resolve) => setTimeout(resolve, 0)) } - async function drainPendingInputWrites(): Promise { - while (pendingInputWrites.length > 0) { - const next = pendingInputWrites[0] - if (!next) { - continue - } - if (!connected || ptyId !== next.id) { - pendingInputWrites.shift() - continue - } - if (next.tooLarge !== false) { - next.tooLarge = await Promise.resolve(next.tooLarge).catch(() => true) - if (next.tooLarge) { - pendingInputWrites.shift() - continue - } - if (!connected || ptyId !== next.id) { - pendingInputWrites.shift() - continue - } - } - next.chunks ??= iterateTerminalInputChunks(next.text) - const chunk = - next.nextChunk === undefined ? next.chunks.next() : { done: false, value: next.nextChunk } - next.nextChunk = undefined - if (chunk.done) { - pendingInputWrites.shift() - continue - } - window.api.pty.write(next.id, chunk.value) - const following = next.chunks.next() - if (following.done) { - pendingInputWrites.shift() - } else { - next.nextChunk = following.value - } - if (pendingInputWrites.length > 0) { - await yieldToInputWriteDrain() - } - } - } - - function schedulePendingInputWriteDrain(): void { - if (inputWriteDrainPromise) { - return - } - inputWriteDrainPromise = drainPendingInputWrites().finally(() => { - inputWriteDrainPromise = null - if (pendingInputWrites.length > 0) { - schedulePendingInputWriteDrain() - } - }) - } - - function clearPendingInputWrites(): void { - pendingInputWrites = [] - } - - function enqueuePtyInputWrite(id: string, data: string): boolean { - try { - const tooLarge = isTerminalInputTooLargeWithDeferredMeasurement(data) - if (tooLarge === true) { - return false - } - pendingInputWrites.push({ id, text: data, tooLarge }) - schedulePendingInputWriteDrain() - return true - } catch { - return false - } - } - - async function waitForPendingInputWrites(): Promise { - while (inputWriteDrainPromise) { - await inputWriteDrainPromise - } - } - async function writeAcceptedPtyInput(id: string, data: string): Promise { try { const tooLarge = isTerminalInputTooLargeWithDeferredMeasurement(data) @@ -887,7 +804,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra disconnect() { clearAccumulatedState() - clearPendingInputWrites() + inputWriteQueue.clear() if (ptyId) { const id = ptyId window.api.pty.kill(id) @@ -900,7 +817,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra detach() { clearAccumulatedState() - clearPendingInputWrites() + inputWriteQueue.clear() if (ptyId) { // Why: detach() is used for in-session remounts such as moving a tab // between split groups. Stop delivering data/title events into the @@ -918,7 +835,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra if (!connected || !ptyId) { return false } - return enqueuePtyInputWrite(ptyId, data) + return inputWriteQueue.enqueue(ptyId, data) }, ...(connectionId @@ -929,7 +846,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra return false } const id = ptyId - await waitForPendingInputWrites() + await inputWriteQueue.waitForDrain() if (!connected || ptyId !== id) { return false } diff --git a/src/renderer/src/components/terminal-pane/terminal-alternate-screen-parse.test.ts b/src/renderer/src/components/terminal-pane/terminal-alternate-screen-parse.test.ts new file mode 100644 index 00000000000..7ce6e3854bd --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-alternate-screen-parse.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { Terminal } from '@xterm/headless' + +function writeChunk(term: Terminal, data: string): Promise { + return new Promise((resolve) => term.write(data, resolve)) +} + +// Pins the xterm contract the alternate-screen atlas recovery relies on: by the +// time a chunk's write callback runs, buffer.active.type reflects any +// alternate-screen enter/exit parsed from that chunk — even when the sequence +// splits across PTY chunk boundaries. +describe('alternate-screen buffer state at write-callback time', () => { + it('reflects an enter sequence split across two chunks', async () => { + const term = new Terminal({ cols: 120, rows: 34, allowProposedApi: true }) + await writeChunk(term, '\x1b[?104') + expect(term.buffer.active.type).toBe('normal') + await writeChunk(term, '9h\x1b[2J\x1b[H~\x1b[K') + expect(term.buffer.active.type).toBe('alternate') + term.dispose() + }) + + it('fires onBufferChange for each switch when one chunk enters and exits', async () => { + const term = new Terminal({ cols: 120, rows: 34, allowProposedApi: true }) + let switches = 0 + const disposable = term.buffer.onBufferChange(() => { + switches += 1 + }) + await writeChunk(term, '\x1b[?1049h\x1b[2J\x1b[Hpager frame\x1b[K\x1b[?1049l') + expect(term.buffer.active.type).toBe('normal') + expect(switches).toBe(2) + disposable.dispose() + term.dispose() + }) + + it('tracks enter, split redraw, and exit from a real captured vim session', async () => { + const term = new Terminal({ cols: 120, rows: 34, allowProposedApi: true }) + // Captured from `vim package.json` (macOS, TERM=xterm-256color): startup chunk. + await writeChunk( + term, + '\x1b[?1049h\x1b[>4;2m\x1b[?1h\x1b=\x1b[?2004h\x1b[?1004h\x1b[1;34r\x1b[?12h\x1b[?12l\x1b[22;2t\x1b[22;1t' + ) + expect(term.buffer.active.type).toBe('alternate') + // Mid-session redraw where a 1024-byte PTY read split \x1b[30;5H in two. + await writeChunk(term, '"rules": {\x1b[29;15H\x1b[K\x1b[30') + await writeChunk( + term, + ';5H "js-combine-iterations": "off"\r\n }\x1b[31;6H\x1b[K\x1b[33;1H\x1b[?25h' + ) + expect(term.buffer.active.type).toBe('alternate') + // Vim quit: erase the status line and restore the normal buffer in one chunk. + await writeChunk( + term, + '\x1b[23;2t\x1b[23;1t\x1b[34;1H\x1b[K\x1b[34;1H\x1b[?1004l\x1b[?2004l\x1b[?1l\x1b>\x1b[?1049l\x1b[?25h\x1b[>4;m' + ) + expect(term.buffer.active.type).toBe('normal') + term.dispose() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.test.ts b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.test.ts new file mode 100644 index 00000000000..782113e1641 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest' +import type { TerminalLayoutSnapshot } from '../../../../shared/types' +import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach' + +const LEAF_1 = '11111111-1111-4111-8111-111111111111' +const LEAF_2 = '22222222-2222-4222-8222-222222222222' +const LEAF_3 = '33333333-3333-4333-8333-333333333333' + +function splitLayout(): TerminalLayoutSnapshot { + return { + root: { + type: 'split', + direction: 'vertical', + ratio: 0.25, + first: { type: 'leaf', leafId: LEAF_1 }, + second: { + type: 'split', + direction: 'horizontal', + ratio: 0.7, + first: { type: 'leaf', leafId: LEAF_2 }, + second: { type: 'leaf', leafId: LEAF_3 } + } + }, + activeLeafId: LEAF_2, + expandedLeafId: LEAF_3, + ptyIdsByLeafId: { + [LEAF_1]: 'pty-1', + [LEAF_2]: 'remote:env-1@@terminal-1', + [LEAF_3]: 'pty-3' + }, + buffersByLeafId: { + [LEAF_1]: 'buffer-1', + [LEAF_2]: 'buffer-2', + [LEAF_3]: 'buffer-3' + }, + scrollbackRefsByLeafId: { + [LEAF_1]: 'scrollback-1', + [LEAF_2]: 'scrollback-2', + [LEAF_3]: 'scrollback-3' + }, + titlesByLeafId: { + [LEAF_1]: 'one', + [LEAF_2]: 'remote shell', + [LEAF_3]: 'three' + } + } +} + +describe('detachTerminalLayoutLeaf', () => { + it('extracts a nested leaf into a single-pane layout while preserving SSH PTY state', () => { + const detached = detachTerminalLayoutLeaf(splitLayout(), LEAF_2) + + expect(detached?.ptyId).toBe('remote:env-1@@terminal-1') + expect(detached?.detachedLayout).toEqual({ + root: { type: 'leaf', leafId: LEAF_2 }, + activeLeafId: LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_2]: 'remote:env-1@@terminal-1' }, + buffersByLeafId: { [LEAF_2]: 'buffer-2' }, + scrollbackRefsByLeafId: { [LEAF_2]: 'scrollback-2' }, + titlesByLeafId: { [LEAF_2]: 'remote shell' } + }) + expect(detached?.sourceLayout).toEqual({ + root: { + type: 'split', + direction: 'vertical', + ratio: 0.25, + first: { type: 'leaf', leafId: LEAF_1 }, + second: { type: 'leaf', leafId: LEAF_3 } + }, + activeLeafId: LEAF_1, + expandedLeafId: LEAF_3, + ptyIdsByLeafId: { + [LEAF_1]: 'pty-1', + [LEAF_3]: 'pty-3' + }, + buffersByLeafId: { + [LEAF_1]: 'buffer-1', + [LEAF_3]: 'buffer-3' + }, + scrollbackRefsByLeafId: { + [LEAF_1]: 'scrollback-1', + [LEAF_3]: 'scrollback-3' + }, + titlesByLeafId: { + [LEAF_1]: 'one', + [LEAF_3]: 'three' + } + }) + }) + + it('clears source expanded selection when the expanded leaf is detached', () => { + const detached = detachTerminalLayoutLeaf(splitLayout(), LEAF_3) + + expect(detached?.sourceLayout.expandedLeafId).toBeNull() + expect(detached?.sourceLayout.root).toEqual({ + type: 'split', + direction: 'vertical', + ratio: 0.25, + first: { type: 'leaf', leafId: LEAF_1 }, + second: { type: 'leaf', leafId: LEAF_2 } + }) + }) + + it('returns null for missing or only leaf layouts', () => { + expect(detachTerminalLayoutLeaf(splitLayout(), 'missing')).toBeNull() + expect( + detachTerminalLayoutLeaf( + { + root: { type: 'leaf', leafId: LEAF_1 }, + activeLeafId: LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_1]: 'pty-1' } + }, + LEAF_1 + ) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.ts b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.ts new file mode 100644 index 00000000000..d43702057f1 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.ts @@ -0,0 +1,119 @@ +import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode } from '../../../../shared/types' +import { + collectLeafIdsInOrder, + normalizeTerminalLayoutSnapshot, + resolveTerminalLayoutActiveLeafId +} from './terminal-layout-leaf-ids' + +export type DetachedTerminalLayoutLeaf = { + sourceLayout: TerminalLayoutSnapshot + detachedLayout: TerminalLayoutSnapshot + ptyId: string | null +} + +function removeLeafFromTree( + node: TerminalPaneLayoutNode, + leafId: string +): { node: TerminalPaneLayoutNode | null; removed: boolean } { + if (node.type === 'leaf') { + return node.leafId === leafId ? { node: null, removed: true } : { node, removed: false } + } + + const first = removeLeafFromTree(node.first, leafId) + const second = removeLeafFromTree(node.second, leafId) + if (!first.removed && !second.removed) { + return { node, removed: false } + } + if (!first.node) { + return { node: second.node, removed: true } + } + if (!second.node) { + return { node: first.node, removed: true } + } + return { + node: { + ...node, + first: first.node, + second: second.node + }, + removed: true + } +} + +function omitLeafRecord( + source: Record | undefined, + leafId: string +): Record | undefined { + if (!source || !Object.prototype.hasOwnProperty.call(source, leafId)) { + return source + } + const next = { ...source } + delete next[leafId] + return Object.keys(next).length > 0 ? next : undefined +} + +function singleLeafRecord( + source: Record | undefined, + leafId: string +): Record | undefined { + const value = source?.[leafId] + return value ? { [leafId]: value } : undefined +} + +export function detachTerminalLayoutLeaf( + snapshot: TerminalLayoutSnapshot | null | undefined, + leafId: string +): DetachedTerminalLayoutLeaf | null { + const layout = normalizeTerminalLayoutSnapshot(snapshot).snapshot + if (!layout.root) { + return null + } + + const originalLeafIds = collectLeafIdsInOrder(layout.root) + if (!originalLeafIds.includes(leafId) || originalLeafIds.length <= 1) { + return null + } + + const removal = removeLeafFromTree(layout.root, leafId) + if (!removal.removed || !removal.node) { + return null + } + + const ptyIdsByLeafId = omitLeafRecord(layout.ptyIdsByLeafId, leafId) + const buffersByLeafId = omitLeafRecord(layout.buffersByLeafId, leafId) + const scrollbackRefsByLeafId = omitLeafRecord(layout.scrollbackRefsByLeafId, leafId) + const titlesByLeafId = omitLeafRecord(layout.titlesByLeafId, leafId) + const sourceLayout: TerminalLayoutSnapshot = { + root: removal.node, + activeLeafId: resolveTerminalLayoutActiveLeafId({ + root: removal.node, + activeLeafId: layout.activeLeafId === leafId ? null : layout.activeLeafId, + ptyIdsByLeafId + }), + expandedLeafId: layout.expandedLeafId === leafId ? null : layout.expandedLeafId, + ...(ptyIdsByLeafId ? { ptyIdsByLeafId } : {}), + ...(buffersByLeafId ? { buffersByLeafId } : {}), + ...(scrollbackRefsByLeafId ? { scrollbackRefsByLeafId } : {}), + ...(titlesByLeafId ? { titlesByLeafId } : {}) + } + + const detachedPtyIdsByLeafId = singleLeafRecord(layout.ptyIdsByLeafId, leafId) + const detachedBuffersByLeafId = singleLeafRecord(layout.buffersByLeafId, leafId) + const detachedScrollbackRefsByLeafId = singleLeafRecord(layout.scrollbackRefsByLeafId, leafId) + const detachedTitlesByLeafId = singleLeafRecord(layout.titlesByLeafId, leafId) + return { + sourceLayout, + detachedLayout: { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ...(detachedPtyIdsByLeafId ? { ptyIdsByLeafId: detachedPtyIdsByLeafId } : {}), + ...(detachedBuffersByLeafId ? { buffersByLeafId: detachedBuffersByLeafId } : {}), + ...(detachedScrollbackRefsByLeafId + ? { scrollbackRefsByLeafId: detachedScrollbackRefsByLeafId } + : {}), + ...(detachedTitlesByLeafId ? { titlesByLeafId: detachedTitlesByLeafId } : {}) + }, + ptyId: detachedPtyIdsByLeafId?.[leafId] ?? null + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-link-activation.ts b/src/renderer/src/components/terminal-pane/terminal-link-activation.ts new file mode 100644 index 00000000000..e85da903224 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-link-activation.ts @@ -0,0 +1,6 @@ +export function isTerminalLinkActivation( + event: Pick | undefined +): boolean { + const isMac = navigator.userAgent.includes('Mac') + return isMac ? Boolean(event?.metaKey) : Boolean(event?.ctrlKey) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts index 5cbc01a0903..c75dd3943e7 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts @@ -7,6 +7,7 @@ import { createFilePathLinkProvider, getTerminalFileOpenHint, getTerminalHtmlFileOpenHint, + getTerminalUrlOpenHint, installFilePathLinkClickFallback, isTerminalLinkActivation, openFilePathLinkAtBufferPosition, @@ -156,12 +157,28 @@ describe('isTerminalLinkActivation', () => { }) describe('handleOscLink', () => { - it('routes http links on ordinary click', () => { + it('ignores http links without the platform modifier on desktop', () => { setPlatform('Macintosh') storeState.settings = { openLinksInApp: true } const preventDefault = vi.fn() - handleOscLink('https://example.com', { metaKey: false, ctrlKey: false, preventDefault }, deps) + expect( + handleOscLink('https://example.com', { metaKey: false, ctrlKey: false, preventDefault }, deps) + ).toBe(false) + + expect(openUrlMock).not.toHaveBeenCalled() + expect(createBrowserTabMock).not.toHaveBeenCalled() + expect(preventDefault).not.toHaveBeenCalled() + }) + + it('routes http links with the platform modifier on desktop', () => { + setPlatform('Macintosh') + storeState.settings = { openLinksInApp: true } + const preventDefault = vi.fn() + + expect( + handleOscLink('https://example.com', { metaKey: true, ctrlKey: false, preventDefault }, deps) + ).toBe(true) expect(openUrlMock).not.toHaveBeenCalled() expect(createBrowserTabMock).toHaveBeenCalledWith('wt-1', 'https://example.com/', { @@ -438,18 +455,38 @@ describe('handleOscLink', () => { setPlatform('Macintosh') expect(getTerminalFileOpenHint()).toBe('⌘+click to open or ⇧⌘+click for default app') expect(getTerminalHtmlFileOpenHint()).toBe('⌘+click to open or ⇧⌘+click for default browser') + expect(getTerminalUrlOpenHint()).toBe('⌘+click to open or ⇧⌘+click for system browser') setPlatform('Windows') expect(getTerminalFileOpenHint()).toBe('Ctrl+click to open or Shift+Ctrl+click for default app') expect(getTerminalHtmlFileOpenHint()).toBe( 'Ctrl+click to open or Shift+Ctrl+click for default browser' ) + expect(getTerminalUrlOpenHint()).toBe( + 'Ctrl+click to open or Shift+Ctrl+click for system browser' + ) }) - it('opens local file URL links in Orca on ordinary click', async () => { + it('ignores local file URL links without the platform modifier on desktop', async () => { setPlatform('Windows') - handleOscLink('file:///tmp/test.txt', { metaKey: false, ctrlKey: false }, deps) + expect(handleOscLink('file:///tmp/test.txt', { metaKey: false, ctrlKey: false }, deps)).toBe( + false + ) + + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(authorizeExternalPathMock).not.toHaveBeenCalled() + expect(openFileMock).not.toHaveBeenCalled() + expect(openFilePathMock).not.toHaveBeenCalled() + }) + + it('opens local file URL links in Orca with the platform modifier on desktop', async () => { + setPlatform('Windows') + + expect(handleOscLink('file:///tmp/test.txt', { metaKey: false, ctrlKey: true }, deps)).toBe( + true + ) // openDetectedFilePath is async (fire-and-forget), so flush the microtask queue // before asserting on positive behavior. @@ -1708,7 +1745,7 @@ describe('createFilePathLinkProvider range bounds', () => { disposable.dispose() }) - it('opens regular URLs from a direct ordinary-click fallback when xterm did not handle them', async () => { + it('ignores regular URLs from a direct ordinary-click fallback on desktop', async () => { setPlatform('Macintosh') storeState.settings = { openLinksInApp: false } const rows = [ @@ -1732,6 +1769,39 @@ describe('createFilePathLinkProvider range bounds', () => { stopPropagation } as unknown as MouseEvent) + expect(openUrlMock).not.toHaveBeenCalled() + expect(preventDefault).not.toHaveBeenCalled() + expect(stopPropagation).not.toHaveBeenCalled() + expect(terminal.clearSelection).not.toHaveBeenCalled() + + disposable.dispose() + expect(element.removeEventListener).toHaveBeenCalledWith('mouseup', mouseUp) + }) + + it('opens regular URLs from a direct modifier-click fallback when xterm did not handle them', async () => { + setPlatform('Macintosh') + storeState.settings = { openLinksInApp: false } + const rows = [ + makeBufferLine('PR opened: https://github.com/stablyai/orca-marketing-website/pull/82') + ] + const { terminal, element } = makeFallbackTerminal(rows) + const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1' }) + const mouseUp = getRegisteredBubbleMouseUpHandler(element) + const preventDefault = vi.fn() + const stopPropagation = vi.fn() + + mouseUp({ + button: 0, + metaKey: true, + ctrlKey: false, + shiftKey: false, + defaultPrevented: false, + clientX: 230, + clientY: 25, + preventDefault, + stopPropagation + } as unknown as MouseEvent) + expect(openUrlMock).toHaveBeenCalledWith( 'https://github.com/stablyai/orca-marketing-website/pull/82' ) diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts index 0e111027e37..462dbdeeedc 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts @@ -33,14 +33,15 @@ import { getTerminalOrcaFileOpenHint, getTerminalWorktreePathOpenHint, getTerminalFileOpenHint, - getTerminalUrlOpenHint, - isMacPlatform + getTerminalUrlOpenHint } from './terminal-link-open-hints' import { resolveKnownWorktreeRootPathLink } from './terminal-worktree-path-link' +import { isTerminalLinkActivation } from './terminal-link-activation' export { openDetectedFilePath } from './terminal-file-open-routing' export { openFilePathLinkAtBufferPosition } from './terminal-file-link-hit-testing' export { getTerminalFileOpenHint, getTerminalHtmlFileOpenHint, getTerminalUrlOpenHint } +export { isTerminalLinkActivation } from './terminal-link-activation' export type LinkHandlerDeps = { worktreeId: string @@ -315,10 +316,3 @@ export function installFilePathLinkClickFallback( } } } - -export function isTerminalLinkActivation( - event: Pick | undefined -): boolean { - const isMac = isMacPlatform() - return isMac ? Boolean(event?.metaKey) : Boolean(event?.ctrlKey) -} diff --git a/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts index 246333c7cbd..0d51669a6c6 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts @@ -22,8 +22,8 @@ export function getTerminalHtmlFileOpenHint(): string { export function getTerminalUrlOpenHint(): string { return isMacPlatform() - ? 'click to open or ⇧+click for system browser' - : 'click to open or Shift+click for system browser' + ? '⌘+click to open or ⇧⌘+click for system browser' + : 'Ctrl+click to open or Shift+Ctrl+click for system browser' } export function getTerminalUrlSystemBrowserHint(): string { diff --git a/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts b/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts index 9d588b7857a..2b043d648cf 100644 --- a/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts @@ -3,6 +3,7 @@ import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-pat import type { LinkHandlerDeps } from './terminal-link-handlers' import { resolveTerminalFileUrlTarget } from './terminal-file-url-target' import { openDetectedFilePath } from './terminal-file-open-routing' +import { isTerminalLinkActivation } from './terminal-link-activation' import { openTerminalHttpLink, type TerminalLinkRoutingPreferenceRequester @@ -11,16 +12,16 @@ import { type TerminalLinkEvent = Pick & Partial> -function isPrimaryOscLinkActivation(event: TerminalLinkEvent | undefined): boolean { +function isDesktopOscLinkActivation(event: TerminalLinkEvent | undefined): boolean { if (!event) { return false } if ('button' in event && event.button !== undefined && event.button !== 0) { return false } - // Why: macOS Ctrl-click is a context-menu gesture even when Chromium reports - // it as button 0; ordinary OSC links should not steal that secondary action. - return !(navigator.userAgent.includes('Mac') && event.ctrlKey && !event.metaKey) + // Why: desktop xterm links must not open while the user is just placing the + // cursor or selecting text. Mobile URL taps use a separate WebView path. + return isTerminalLinkActivation(event) } export function handleOscLink( @@ -30,9 +31,9 @@ export function handleOscLink( Partial> & { requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester } -): void { - if (!isPrimaryOscLinkActivation(event)) { - return +): boolean { + if (!isDesktopOscLinkActivation(event)) { + return false } // Why: xterm renders OSC 8 links as clickable anchors. Orca must suppress // default anchor navigation so link-routing settings can choose the target. @@ -67,15 +68,14 @@ export function handleOscLink( ) { // Why: `new URL("C:\\path\\file.ts")` succeeds with protocol `c:`; // Windows OSC links need file-path routing before generic URL parsing. - return + return true } let parsed: URL try { parsed = new URL(rawText) } catch { - openDetectedPathLink() - return + return openDetectedPathLink() } if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { @@ -84,7 +84,7 @@ export function handleOscLink( forceSystemBrowser: Boolean(event?.shiftKey), requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference }) - return + return true } if (parsed.protocol === 'file:') { @@ -99,11 +99,13 @@ export function handleOscLink( !deps.runtimeEnvironmentId const resolved = resolveTerminalFileUrlTarget(parsed, { allowUncHost }) if (!resolved) { - return + return false } openDetectedFilePath(resolved.filePath, resolved.line, resolved.column, { ...deps, openWithSystemDefault: Boolean(event?.shiftKey) }) + return true } + return false } diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts new file mode 100644 index 00000000000..a92e03cce8c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts @@ -0,0 +1,383 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types' +import { + detachTerminalPaneToTab, + resolveTerminalTabStripDropTarget, + type TerminalPaneTabDetachStore +} from './terminal-pane-tab-detach' + +const WORKTREE_ID = 'repo-1::/worktree' +const SOURCE_TAB_ID = 'tab-source' +const TARGET_GROUP_ID = 'group-target' +const EXISTING_TAB_1 = 'tab-existing-1' +const EXISTING_TAB_2 = 'tab-existing-2' +const LEAF_1 = '11111111-1111-4111-8111-111111111111' +const LEAF_2 = '22222222-2222-4222-8222-222222222222' + +function rect(args: { left: number; top: number; width: number; height: number }): DOMRect { + return { + left: args.left, + top: args.top, + right: args.left + args.width, + bottom: args.top + args.height, + width: args.width, + height: args.height + } as DOMRect +} + +function splitLayout(): TerminalLayoutSnapshot { + return { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_1 }, + second: { type: 'leaf', leafId: LEAF_2 } + }, + activeLeafId: LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: { + [LEAF_1]: 'pty-left', + [LEAF_2]: 'remote:env-1@@terminal-1' + }, + buffersByLeafId: { + [LEAF_2]: 'remote-buffer' + }, + titlesByLeafId: { + [LEAF_2]: 'remote shell' + } + } +} + +function createTerminalTab(id: string, ptyId: string | null): TerminalTab { + return { + id, + ptyId, + worktreeId: WORKTREE_ID, + title: 'Terminal 2', + defaultTitle: 'Terminal 2', + customTitle: null, + color: null, + sortOrder: 1, + createdAt: 1 + } +} + +function createStore( + layout: TerminalLayoutSnapshot = splitLayout(), + targetTabOrder: string[] = [EXISTING_TAB_1, EXISTING_TAB_2] +): TerminalPaneTabDetachStore { + const store = { + createTab: vi.fn((_worktreeId, _targetGroupId, _shellOverride, options) => { + const tab = createTerminalTab('tab-detached', options?.initialPtyId ?? null) + const group = store.groupsByWorktree[WORKTREE_ID]?.find( + (candidate) => candidate.id === TARGET_GROUP_ID + ) + if (group && !group.tabOrder.includes(tab.id)) { + group.tabOrder = [...group.tabOrder, tab.id] + } + return tab + }), + groupsByWorktree: { + [WORKTREE_ID]: [ + { + id: TARGET_GROUP_ID, + worktreeId: WORKTREE_ID, + activeTabId: targetTabOrder[0] ?? null, + tabOrder: targetTabOrder, + recentTabIds: [] + } + ] + }, + reorderUnifiedTabs: vi.fn((groupId: string, tabIds: string[]) => { + const group = store.groupsByWorktree[WORKTREE_ID]?.find( + (candidate) => candidate.id === groupId + ) + if (group) { + group.tabOrder = tabIds + } + }), + setActiveTab: vi.fn(), + setActiveTabType: vi.fn(), + setTabLayout: vi.fn((tabId: string, nextLayout: TerminalLayoutSnapshot | null) => { + if (nextLayout) { + store.terminalLayoutsByTabId[tabId] = nextLayout + } else { + delete store.terminalLayoutsByTabId[tabId] + } + }), + syncPaneDetachPtyOwnership: vi.fn(), + terminalLayoutsByTabId: { + [SOURCE_TAB_ID]: layout + } + } + return store as unknown as TerminalPaneTabDetachStore +} + +describe('resolveTerminalTabStripDropTarget', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('finds a same-worktree tab strip under overlay elements', () => { + const stripRect = rect({ left: 0, top: 0, width: 300, height: 32 }) + const strip = { + dataset: { tabGroupStripId: TARGET_GROUP_ID, worktreeId: WORKTREE_ID }, + getBoundingClientRect: () => stripRect, + querySelectorAll: () => [] + } + const overlay = { closest: () => null } + const child = { closest: () => strip } + vi.stubGlobal('document', { + elementsFromPoint: vi.fn(() => [overlay, child]), + elementFromPoint: vi.fn() + }) + + expect( + resolveTerminalTabStripDropTarget({ + clientX: 10, + clientY: 10, + groupsByWorktree: { + [WORKTREE_ID]: [{ id: TARGET_GROUP_ID } as AppState['groupsByWorktree'][string][number]] + }, + worktreeId: WORKTREE_ID + }) + ).toEqual({ + id: TARGET_GROUP_ID, + groupId: TARGET_GROUP_ID, + worktreeId: WORKTREE_ID, + rect: stripRect + }) + }) + + it('resolves the insertion slot from the hovered tab side', () => { + const stripRect = rect({ left: 0, top: 0, width: 300, height: 32 }) + const firstTabRect = rect({ left: 0, top: 0, width: 80, height: 32 }) + const secondTabRect = rect({ left: 80, top: 0, width: 80, height: 32 }) + const firstTab = { + dataset: { tabId: EXISTING_TAB_1 }, + getBoundingClientRect: () => firstTabRect + } + const secondTab = { + dataset: { tabId: EXISTING_TAB_2 }, + getBoundingClientRect: () => secondTabRect + } + const strip = { + dataset: { tabGroupStripId: TARGET_GROUP_ID, worktreeId: WORKTREE_ID }, + getBoundingClientRect: () => stripRect, + querySelectorAll: () => [firstTab, secondTab] + } + vi.stubGlobal('document', { + elementsFromPoint: vi.fn(() => [{ closest: () => firstTab }, { closest: () => strip }]), + elementFromPoint: vi.fn() + }) + + expect( + resolveTerminalTabStripDropTarget({ + clientX: 60, + clientY: 10, + groupsByWorktree: { + [WORKTREE_ID]: [ + { + id: TARGET_GROUP_ID, + activeTabId: EXISTING_TAB_1, + tabOrder: [EXISTING_TAB_1, EXISTING_TAB_2], + worktreeId: WORKTREE_ID + } as AppState['groupsByWorktree'][string][number] + ] + }, + worktreeId: WORKTREE_ID + }) + ).toMatchObject({ + groupId: TARGET_GROUP_ID, + insertionIndex: 1, + overlayKind: 'insertion', + rect: rect({ left: 80, top: 0, width: 2, height: 32 }) + }) + }) + + it('ignores strips from another worktree', () => { + const strip = { + dataset: { tabGroupStripId: TARGET_GROUP_ID, worktreeId: 'other-worktree' }, + getBoundingClientRect: () => + ({ left: 0, top: 0, right: 300, bottom: 32, width: 300, height: 32 }) as DOMRect + } + vi.stubGlobal('document', { + elementsFromPoint: vi.fn(() => [{ closest: () => strip }]), + elementFromPoint: vi.fn() + }) + + expect( + resolveTerminalTabStripDropTarget({ + clientX: 10, + clientY: 10, + groupsByWorktree: { + [WORKTREE_ID]: [{ id: TARGET_GROUP_ID } as AppState['groupsByWorktree'][string][number]] + }, + worktreeId: WORKTREE_ID + }) + ).toBeNull() + }) +}) + +describe('detachTerminalPaneToTab', () => { + it('creates a new terminal tab with the detached leaf layout and PTY id', () => { + const store = createStore() + const manager = { + getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]), + getLeafId: vi.fn((paneId: number) => (paneId === 2 ? LEAF_2 : LEAF_1)), + detachPaneForExternalMove: vi.fn(() => true) + } + const persistLayoutSnapshot = vi.fn() + + const result = detachTerminalPaneToTab({ + manager, + getStore: () => store, + persistLayoutSnapshot, + sourcePaneId: 2, + sourceTabId: SOURCE_TAB_ID, + targetGroupId: TARGET_GROUP_ID, + worktreeId: WORKTREE_ID + }) + + expect(result?.ptyId).toBe('remote:env-1@@terminal-1') + expect(manager.detachPaneForExternalMove).toHaveBeenCalledWith(2) + expect(store.createTab).toHaveBeenCalledWith(WORKTREE_ID, TARGET_GROUP_ID, undefined, { + activate: true, + initialPtyId: 'remote:env-1@@terminal-1', + recordInteraction: true + }) + expect(store.setTabLayout).toHaveBeenCalledWith(SOURCE_TAB_ID, { + root: { type: 'leaf', leafId: LEAF_1 }, + activeLeafId: LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_1]: 'pty-left' } + }) + expect(store.setTabLayout).toHaveBeenCalledWith('tab-detached', { + root: { type: 'leaf', leafId: LEAF_2 }, + activeLeafId: LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_2]: 'remote:env-1@@terminal-1' }, + buffersByLeafId: { [LEAF_2]: 'remote-buffer' }, + titlesByLeafId: { [LEAF_2]: 'remote shell' } + }) + expect(store.syncPaneDetachPtyOwnership).toHaveBeenCalledWith({ + detachedPtyId: 'remote:env-1@@terminal-1', + sourceLayout: { + root: { type: 'leaf', leafId: LEAF_1 }, + activeLeafId: LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_1]: 'pty-left' } + }, + sourceTabId: SOURCE_TAB_ID, + targetTabId: 'tab-detached' + }) + expect(store.setActiveTab).toHaveBeenCalledWith('tab-detached') + expect(store.setActiveTabType).toHaveBeenCalledWith('terminal') + expect(persistLayoutSnapshot).toHaveBeenCalled() + }) + + it('syncs PTY ownership when the primary source pane is detached', () => { + const store = createStore() + const manager = { + getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]), + getLeafId: vi.fn((paneId: number) => (paneId === 1 ? LEAF_1 : LEAF_2)), + detachPaneForExternalMove: vi.fn(() => true) + } + + const result = detachTerminalPaneToTab({ + getStore: () => store, + manager, + persistLayoutSnapshot: vi.fn(), + sourcePaneId: 1, + sourceTabId: SOURCE_TAB_ID, + targetGroupId: TARGET_GROUP_ID, + worktreeId: WORKTREE_ID + }) + + expect(result?.ptyId).toBe('pty-left') + expect(store.setTabLayout).toHaveBeenCalledWith(SOURCE_TAB_ID, { + root: { type: 'leaf', leafId: LEAF_2 }, + activeLeafId: LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_2]: 'remote:env-1@@terminal-1' }, + buffersByLeafId: { [LEAF_2]: 'remote-buffer' }, + titlesByLeafId: { [LEAF_2]: 'remote shell' } + }) + expect(store.syncPaneDetachPtyOwnership).toHaveBeenCalledWith({ + detachedPtyId: 'pty-left', + sourceLayout: { + root: { type: 'leaf', leafId: LEAF_2 }, + activeLeafId: LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_2]: 'remote:env-1@@terminal-1' }, + buffersByLeafId: { [LEAF_2]: 'remote-buffer' }, + titlesByLeafId: { [LEAF_2]: 'remote shell' } + }, + sourceTabId: SOURCE_TAB_ID, + targetTabId: 'tab-detached' + }) + }) + + it('moves the detached tab into the requested group slot', () => { + const store = createStore(splitLayout(), [EXISTING_TAB_1, EXISTING_TAB_2]) + const manager = { + getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]), + getLeafId: vi.fn((paneId: number) => (paneId === 2 ? LEAF_2 : LEAF_1)), + detachPaneForExternalMove: vi.fn(() => true) + } + + detachTerminalPaneToTab({ + getStore: () => store, + manager, + persistLayoutSnapshot: vi.fn(), + sourcePaneId: 2, + sourceTabId: SOURCE_TAB_ID, + targetGroupId: TARGET_GROUP_ID, + targetIndex: 1, + worktreeId: WORKTREE_ID + }) + + expect(store.reorderUnifiedTabs).toHaveBeenCalledWith( + TARGET_GROUP_ID, + [EXISTING_TAB_1, 'tab-detached', EXISTING_TAB_2], + { recordInteraction: false } + ) + }) + + it('uses the live transport PTY id when the snapshot has not persisted it yet', () => { + const store = createStore({ + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_1 }, + second: { type: 'leaf', leafId: LEAF_2 } + }, + activeLeafId: LEAF_2, + expandedLeafId: null + }) + const manager = { + getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]), + getLeafId: vi.fn(() => LEAF_2), + detachPaneForExternalMove: vi.fn(() => true) + } + + detachTerminalPaneToTab({ + fallbackPtyId: 'remote:env-2@@terminal-9', + getStore: () => store, + manager, + persistLayoutSnapshot: vi.fn(), + sourcePaneId: 2, + sourceTabId: SOURCE_TAB_ID, + targetGroupId: TARGET_GROUP_ID, + worktreeId: WORKTREE_ID + }) + + expect(store.setTabLayout).toHaveBeenCalledWith('tab-detached', { + root: { type: 'leaf', leafId: LEAF_2 }, + activeLeafId: LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_2]: 'remote:env-2@@terminal-9' } + }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts new file mode 100644 index 00000000000..26f3d5996e3 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts @@ -0,0 +1,306 @@ +import type { PaneExternalDropTarget } from '@/lib/pane-manager/pane-manager' +import type { AppState } from '@/store' +import type { TerminalTab } from '../../../../shared/types' +import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach' + +const TAB_GROUP_STRIP_SELECTOR = '[data-tab-group-strip-id][data-worktree-id]' + +export type TerminalPaneTabDetachStore = Pick< + AppState, + | 'createTab' + | 'groupsByWorktree' + | 'reorderUnifiedTabs' + | 'setActiveTab' + | 'setActiveTabType' + | 'setTabLayout' + | 'syncPaneDetachPtyOwnership' + | 'terminalLayoutsByTabId' +> + +type TerminalPaneTabDetachManager = { + getPanes: () => readonly { id: number }[] + getLeafId: (paneId: number) => string | null + detachPaneForExternalMove: (paneId: number) => boolean +} + +export type TerminalTabStripDropTarget = PaneExternalDropTarget & { + groupId: string + insertionIndex?: number + worktreeId: string +} + +export type DetachedTerminalPaneTab = { + tab: TerminalTab + leafId: string + ptyId: string | null +} + +function pointWithinRect(clientX: number, clientY: number, rect: DOMRect): boolean { + return ( + clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom + ) +} + +function rectFromBox(args: { left: number; top: number; width: number; height: number }): DOMRect { + return { + left: args.left, + top: args.top, + right: args.left + args.width, + bottom: args.top + args.height, + width: args.width, + height: args.height + } as DOMRect +} + +function clampIndex(index: number, max: number): number { + return Math.min(Math.max(index, 0), max) +} + +function getTabElements(strip: HTMLElement): HTMLElement[] { + return Array.from(strip.querySelectorAll('[data-tab-id]')).filter( + (element) => typeof element.dataset.tabId === 'string' && element.dataset.tabId.length > 0 + ) +} + +function getInsertionMarkerRect( + tabRects: DOMRect[], + insertionIndex: number, + stripRect: DOMRect +): DOMRect { + const markerWidth = 2 + const clampedIndex = clampIndex(insertionIndex, tabRects.length) + const rawLeft = + clampedIndex < tabRects.length + ? (tabRects[clampedIndex]?.left ?? stripRect.left) + : (tabRects.at(-1)?.right ?? stripRect.left) - markerWidth + const left = Math.min(Math.max(rawLeft, stripRect.left), stripRect.right - markerWidth) + return rectFromBox({ left, top: stripRect.top, width: markerWidth, height: stripRect.height }) +} + +function resolveTabStripInsertion(args: { + clientX: number + clientY: number + groupTabOrderLength: number + strip: HTMLElement + stripRect: DOMRect +}): { index: number; rect: DOMRect } | null { + const tabs = getTabElements(args.strip) + if (tabs.length === 0) { + return null + } + const tabRects = tabs.map((tab) => tab.getBoundingClientRect()) + + for (let index = 0; index < tabRects.length; index += 1) { + const tabRect = tabRects[index] + if (!tabRect) { + continue + } + if (args.clientX < tabRect.left) { + const insertionIndex = clampIndex(index, args.groupTabOrderLength) + return { + index: insertionIndex, + rect: getInsertionMarkerRect(tabRects, insertionIndex, args.stripRect) + } + } + if (pointWithinRect(args.clientX, args.clientY, tabRect)) { + const insertionIndex = clampIndex( + index + (args.clientX < tabRect.left + tabRect.width / 2 ? 0 : 1), + args.groupTabOrderLength + ) + return { + index: insertionIndex, + rect: getInsertionMarkerRect(tabRects, insertionIndex, args.stripRect) + } + } + } + + const insertionIndex = args.groupTabOrderLength + return { + index: insertionIndex, + rect: getInsertionMarkerRect(tabRects, insertionIndex, args.stripRect) + } +} + +function getElementsFromPoint(clientX: number, clientY: number): Element[] { + if (typeof document === 'undefined') { + return [] + } + const elements = document.elementsFromPoint?.(clientX, clientY) + if (elements && elements.length > 0) { + return elements + } + const element = document.elementFromPoint?.(clientX, clientY) + return element ? [element] : [] +} + +export function resolveTerminalTabStripDropTarget(args: { + clientX: number + clientY: number + groupsByWorktree: TerminalPaneTabDetachStore['groupsByWorktree'] + worktreeId: string +}): TerminalTabStripDropTarget | null { + const groups = args.groupsByWorktree[args.worktreeId] ?? [] + const groupById = new Map(groups.map((group) => [group.id, group])) + const validGroupIds = new Set(groups.map((group) => group.id)) + if (validGroupIds.size === 0) { + return null + } + + for (const element of getElementsFromPoint(args.clientX, args.clientY)) { + const strip = element.closest(TAB_GROUP_STRIP_SELECTOR) + const groupId = strip?.dataset.tabGroupStripId + const worktreeId = strip?.dataset.worktreeId + if (!strip || !groupId || worktreeId !== args.worktreeId || !validGroupIds.has(groupId)) { + continue + } + const rect = strip.getBoundingClientRect() + if (!pointWithinRect(args.clientX, args.clientY, rect)) { + continue + } + const group = groupById.get(groupId) + const insertion = group + ? resolveTabStripInsertion({ + clientX: args.clientX, + clientY: args.clientY, + groupTabOrderLength: group.tabOrder?.length ?? 0, + strip, + stripRect: rect + }) + : null + return insertion + ? { + id: groupId, + groupId, + insertionIndex: insertion.index, + overlayKind: 'insertion', + rect: insertion.rect, + worktreeId + } + : { id: groupId, groupId, worktreeId, rect } + } + + return null +} + +function withDetachedPtyFallback(args: { + leafId: string + ptyId: string | null + detachedLayout: NonNullable>['detachedLayout'] +}): NonNullable>['detachedLayout'] { + if (!args.ptyId || args.detachedLayout.ptyIdsByLeafId?.[args.leafId]) { + return args.detachedLayout + } + return { + ...args.detachedLayout, + ptyIdsByLeafId: { + ...args.detachedLayout.ptyIdsByLeafId, + [args.leafId]: args.ptyId + } + } +} + +export function isTerminalTabStripDropTarget( + target: PaneExternalDropTarget +): target is TerminalTabStripDropTarget { + const candidate = target as Partial + return typeof candidate.groupId === 'string' && typeof candidate.worktreeId === 'string' +} + +function moveCreatedTabToIndex(args: { + groupId: string + store: TerminalPaneTabDetachStore + tabId: string + targetIndex: number | undefined + worktreeId: string +}): void { + if (args.targetIndex === undefined) { + return + } + const group = args.store.groupsByWorktree[args.worktreeId]?.find( + (candidate) => candidate.id === args.groupId + ) + if (!group) { + return + } + const orderWithoutCreatedTab = (group.tabOrder ?? []).filter((id) => id !== args.tabId) + const insertionIndex = clampIndex(args.targetIndex, orderWithoutCreatedTab.length) + const nextOrder = [...orderWithoutCreatedTab] + nextOrder.splice(insertionIndex, 0, args.tabId) + args.store.reorderUnifiedTabs(args.groupId, nextOrder, { recordInteraction: false }) +} + +export function detachTerminalPaneToTab(args: { + fallbackPtyId?: string | null + getStore: () => TerminalPaneTabDetachStore + manager: TerminalPaneTabDetachManager | null + persistLayoutSnapshot: () => void + sourcePaneId: number + sourceTabId: string + targetGroupId: string + targetIndex?: number + worktreeId: string +}): DetachedTerminalPaneTab | null { + const initialStore = args.getStore() + const targetGroupExists = + initialStore.groupsByWorktree[args.worktreeId]?.some( + (group) => group.id === args.targetGroupId + ) ?? false + if (!args.manager || !targetGroupExists || args.manager.getPanes().length <= 1) { + return null + } + + const sourceLeafId = args.manager.getLeafId(args.sourcePaneId) + if (!sourceLeafId) { + return null + } + + args.persistLayoutSnapshot() + const store = args.getStore() + const detached = detachTerminalLayoutLeaf( + store.terminalLayoutsByTabId[args.sourceTabId], + sourceLeafId + ) + if (!detached) { + return null + } + + const ptyId = detached.ptyId ?? args.fallbackPtyId ?? null + const detachedLayout = withDetachedPtyFallback({ + leafId: sourceLeafId, + ptyId, + detachedLayout: detached.detachedLayout + }) + + // Why: remove the renderer pane only after the layout/PTY handoff has been + // computed; the close callback detaches listeners but must not kill the PTY. + if (!args.manager.detachPaneForExternalMove(args.sourcePaneId)) { + return null + } + + const latestStore = args.getStore() + const tab = latestStore.createTab(args.worktreeId, args.targetGroupId, undefined, { + activate: true, + initialPtyId: ptyId ?? undefined, + recordInteraction: true + }) + const afterCreateStore = args.getStore() + moveCreatedTabToIndex({ + groupId: args.targetGroupId, + store: afterCreateStore, + tabId: tab.id, + targetIndex: args.targetIndex, + worktreeId: args.worktreeId + }) + afterCreateStore.setTabLayout(args.sourceTabId, detached.sourceLayout) + afterCreateStore.setTabLayout(tab.id, detachedLayout) + afterCreateStore.syncPaneDetachPtyOwnership({ + detachedPtyId: ptyId, + sourceLayout: detached.sourceLayout, + sourceTabId: args.sourceTabId, + targetTabId: tab.id + }) + afterCreateStore.setActiveTab(tab.id) + afterCreateStore.setActiveTabType('terminal') + + return { tab, leafId: sourceLeafId, ptyId } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts b/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts index 933de17d5fd..9e927552049 100644 --- a/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { Terminal } from '@xterm/headless' import { + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, POST_REPLAY_LIVE_SNAPSHOT_RESET, POST_REPLAY_MODE_RESET, POST_REPLAY_REATTACH_RESET, @@ -13,6 +14,7 @@ const OLD_REATTACH_RESET_WITHOUT_CURSOR_STYLE = '\x1b[?25h\x1b[?1004l' type DecPrivateCursorState = { cursorStyle?: string cursorBlink?: boolean + sendFocus?: boolean } type KittyKeyboardState = { @@ -28,10 +30,12 @@ type XtermWithCoreService = Terminal & { coreService?: { decPrivateModes?: DecPrivateCursorState kittyKeyboard?: KittyKeyboardState + isCursorHidden?: boolean } _coreService?: { decPrivateModes?: DecPrivateCursorState kittyKeyboard?: KittyKeyboardState + isCursorHidden?: boolean } } } @@ -42,6 +46,15 @@ function readDecPrivateCursorState(term: Terminal): DecPrivateCursorState { return cursorState ? { ...cursorState } : {} } +function readCursorHidden(term: Terminal): boolean | undefined { + const core = (term as XtermWithCoreService)._core + return core?.coreService?.isCursorHidden ?? core?._coreService?.isCursorHidden +} + +function readSendFocus(term: Terminal): boolean | undefined { + return readDecPrivateCursorState(term).sendFocus +} + function readKittyKeyboardState(term: Terminal): KittyKeyboardState | null { const core = (term as XtermWithCoreService)._core const keyboardState = core?.coreService?.kittyKeyboard ?? core?._coreService?.kittyKeyboard @@ -180,6 +193,104 @@ describe('terminal replay state reset', () => { } }) + it('resets hidden cursor visibility after ordinary reattach replay', async () => { + const term = new Terminal({ + cols: 80, + rows: 24, + allowProposedApi: true + }) + + try { + await writeTerminal(term, '\x1b[?25l') + expect(readCursorHidden(term)).toBe(true) + + await writeTerminal(term, POST_REPLAY_REATTACH_RESET) + + expect(readCursorHidden(term)).toBe(false) + } finally { + term.dispose() + } + }) + + it('resets focus reporting after ordinary reattach replay', async () => { + const term = new Terminal({ + cols: 80, + rows: 24, + allowProposedApi: true + }) + try { + await writeTerminal(term, '\x1b[?1004h') + expect(readSendFocus(term)).toBe(true) + + await writeTerminal(term, POST_REPLAY_REATTACH_RESET) + + expect(readSendFocus(term)).toBe(false) + } finally { + term.dispose() + } + }) + + it('resets hidden cursor visibility after live agent reattach replay', async () => { + const term = new Terminal({ + cols: 80, + rows: 24, + allowProposedApi: true + }) + + try { + await writeTerminal(term, '\x1b[?25l') + expect(readCursorHidden(term)).toBe(true) + + await writeTerminal(term, POST_REPLAY_LIVE_AGENT_REATTACH_RESET) + + // Why: agent detection can false-positive on a dead TUI's leftovers, so + // even the live-agent reset must not leave a shell cursor permanently + // invisible; a live agent re-hides it on the post-reattach repaint. + expect(readCursorHidden(term)).toBe(false) + } finally { + term.dispose() + } + }) + + it('preserves live focus reporting after live agent reattach replay', async () => { + const term = new Terminal({ + cols: 80, + rows: 24, + allowProposedApi: true + }) + try { + await writeTerminal(term, '\x1b[?1004h') + expect(readSendFocus(term)).toBe(true) + + await writeTerminal(term, POST_REPLAY_LIVE_AGENT_REATTACH_RESET) + + // Why: live TUIs such as cursor-agent can rely on focus events to repaint + // their own hidden-cursor input caret after renderer reattach. + expect(readSendFocus(term)).toBe(true) + } finally { + term.dispose() + } + }) + + it('shows the cursor after cold-restore replay reset', async () => { + const term = new Terminal({ + cols: 80, + rows: 24, + allowProposedApi: true + }) + + try { + await writeTerminal(term, '\x1b[?25l') + expect(readCursorHidden(term)).toBe(true) + + await writeTerminal(term, POST_REPLAY_MODE_RESET) + + expect(readCursorHidden(term)).toBe(false) + } finally { + term.dispose() + } + }) + it('preserves active-buffer Kitty keyboard state after hidden-output snapshot replay', async () => { const term = new Terminal({ cols: 80, diff --git a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts index 96ceafcce38..67505d5f622 100644 --- a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts @@ -1,6 +1,7 @@ import type { IBufferLine, IBufferRange, IDisposable, Terminal } from '@xterm/xterm' import { openHttpLink } from '@/lib/http-link-routing' import { buildCandidateLogicalLinesForBufferPosition } from './terminal-file-link-hit-testing' +import { isTerminalLinkActivation } from './terminal-link-activation' import { rangeForParsedFileLink } from './wrapped-terminal-link-ranges' type UrlLinkHitTestDeps = { @@ -48,13 +49,14 @@ export function extractTerminalHttpLinks(lineText: string): ParsedTerminalHttpLi return links } -function isPrimaryHttpLinkFallbackActivation(event: MouseEvent): boolean { +function isDesktopHttpLinkFallbackActivation(event: MouseEvent): boolean { if (event.defaultPrevented || event.button !== 0) { return false } - // Why: URL links now open on ordinary clicks, but macOS Ctrl-click must stay - // available for context menus even when Chromium reports it as button 0. - return !(navigator.userAgent.includes('Mac') && event.ctrlKey && !event.metaKey) + // Why: desktop terminal links require an intentional Cmd/Ctrl gesture so + // plain clicks remain available for cursor placement and selection. Mobile + // tap routing is handled separately under mobile/src/terminal. + return isTerminalLinkActivation(event) } function* iterateTerminalHttpUrlCandidates( @@ -220,7 +222,7 @@ export function installHttpLinkClickFallback( deps: UrlLinkClickFallbackDeps ): IDisposable { const handleMouseUp = (event: MouseEvent): void => { - if (!isPrimaryHttpLinkFallbackActivation(event)) { + if (!isDesktopHttpLinkFallbackActivation(event)) { return } @@ -230,8 +232,8 @@ export function installHttpLinkClickFallback( } // Why: xterm's WebLinksAddon only activates after hover state exists. This - // direct mouseup fallback preserves ordinary link clicks when the hover link - // was never established, while defaultPrevented avoids duplicate opens. + // direct mouseup fallback preserves modifier-clicks when the hover link was + // never established, while defaultPrevented avoids duplicate opens. const opened = openHttpLinkAtBufferPosition(terminal.buffer.active, position, terminal.cols, { worktreeId: deps.worktreeId, forceSystemBrowser: event.shiftKey, diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts new file mode 100644 index 00000000000..d4b9762163a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import { + recoverVisibleTerminalWindowWake, + resumeTerminalVisibility +} from './terminal-visibility-resume' + +vi.mock('@/lib/pane-manager/pane-manager-registry', () => ({ + resetAndRefreshAllTerminalWebglAtlases: vi.fn() +})) +vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({ + flushTerminalOutput: vi.fn(), + requestTerminalBacklogRecovery: vi.fn() +})) +vi.mock('@/lib/pane-manager/terminal-scroll-intent', () => ({ + enforceTerminalCurrentScrollIntent: vi.fn() +})) +vi.mock('./pane-helpers', () => ({ + fitAndFocusPanes: vi.fn(), + fitPanes: vi.fn(), + focusActivePane: vi.fn() +})) +vi.mock('./terminal-webgl-atlas-recovery', () => ({ + scheduleTerminalWebglAtlasRecovery: vi.fn() +})) + +type FakeManager = { + getPanes: ReturnType + resumeRendering: ReturnType + scheduleRevealRepaint: ReturnType +} + +function createManager(order: string[] = []): FakeManager { + return { + getPanes: vi.fn(() => []), + resumeRendering: vi.fn(() => order.push('resume-rendering')), + scheduleRevealRepaint: vi.fn(() => order.push('reveal-repaint')) + } +} + +function resumeArgs(manager: FakeManager, shouldUseLightTabResume: boolean) { + return { + manager: manager as never as PaneManager, + isActive: true, + wasVisible: false, + shouldUseLightTabResume, + captureViewportPositions: vi.fn(() => new Map()), + withSuppressedScrollTracking: (callback: () => void) => callback() + } +} + +describe('resumeTerminalVisibility reveal repaint', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('schedules a pane-scoped repaint on a light tab reveal', () => { + // The light path is the "click the tab that was not open" gesture: it has + // no rendering resume or fit, so without this repaint a hidden-while- + // working pane keeps compositing pre-hide pixels. + const manager = createManager() + resumeTerminalVisibility(resumeArgs(manager, true)) + + expect(manager.scheduleRevealRepaint).toHaveBeenCalledTimes(1) + expect(manager.resumeRendering).not.toHaveBeenCalled() + }) + + it('schedules the repaint after rendering resumes on a heavy reveal', () => { + const order: string[] = [] + const manager = createManager(order) + resumeTerminalVisibility(resumeArgs(manager, false)) + + expect(order).toEqual(['resume-rendering', 'reveal-repaint']) + }) + + it('schedules the repaint on window-wake recovery', () => { + const manager = createManager() + recoverVisibleTerminalWindowWake({ + manager: manager as never as PaneManager, + isActive: false + }) + + expect(manager.scheduleRevealRepaint).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts index 408f8afe952..fdf597c5587 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts @@ -75,6 +75,10 @@ export function resumeTerminalVisibility({ // terminals; refresh after reset so rebuilt atlases repaint from xterm. resetAndRefreshAllTerminalWebglAtlases() } + // Why: the synchronous recovery above can fire before the revealed pane is + // attached and laid out, where the WebGL renderer drops redraw requests + // without retry. Follow up with a settled-frame, pane-scoped repaint. + manager.scheduleRevealRepaint() }) } @@ -93,9 +97,8 @@ export function hideTerminalVisibility({ captureViewportPositions(false) } if (!isWorktreeActive && (wasVisible || surfaceBecameHidden)) { - // Suspend WebGL when going hidden. xterm.write() continues to land in - // the (now DOM-renderer-fallback or paused-canvas) terminal; the - // suspend is purely a GPU resource decision. + // Suspend WebGL when going hidden. xterm.write() continues to land in the + // DOM-renderer fallback terminal; the suspend is purely a GPU resource decision. manager.suspendRendering() return { hiddenReason: 'surface', renderingSuspended: true } } @@ -133,6 +136,7 @@ export function recoverVisibleTerminalWindowWake({ } enforceTerminalViewportIntents(manager) resetAndRefreshAllTerminalWebglAtlases() + manager.scheduleRevealRepaint() } function requestLightTabBacklogRecovery(manager: PaneManager): void { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts index 61dc3a1533d..0ad8f80d141 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts @@ -130,6 +130,7 @@ function useMountForFileDrop( getPanes: ReturnType resumeRendering: ReturnType resetWebglTextureAtlases: ReturnType + scheduleRevealRepaint: ReturnType suspendRendering: ReturnType getActivePane: ReturnType } @@ -146,6 +147,7 @@ function useMountForFileDrop( getPanes: vi.fn(() => []), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => null) } @@ -222,6 +224,7 @@ describe('useTerminalPaneGlobalEffects', () => { ]), resumeRendering: vi.fn(() => order.push('resume')), resetWebglTextureAtlases: vi.fn(() => order.push('reset-atlas')), + scheduleRevealRepaint: vi.fn(() => order.push('reveal-repaint')), refreshAllPanes: vi.fn(() => order.push('refresh')), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), @@ -280,7 +283,8 @@ describe('useTerminalPaneGlobalEffects', () => { 'intent:terminal-a', 'intent:terminal-b', 'reset-atlas', - 'refresh' + 'refresh', + 'reveal-repaint' ]) expect(mocks.restoreScrollStateAfterLayout).not.toHaveBeenCalled() expect(mocks.flushTerminalOutput).toHaveBeenNthCalledWith(1, terminalA, { @@ -308,6 +312,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => [{ id: 1, terminal }]), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), refreshAllPanes: vi.fn(), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), @@ -387,6 +392,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => [{ id: 1, terminal }]), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), refreshAllPanes: vi.fn(), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), @@ -448,6 +454,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => [{ id: 1, terminal }]), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), refreshAllPanes: vi.fn(), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), @@ -501,6 +508,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => [{ id: 1, terminal }]), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), refreshAllPanes: vi.fn(), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), @@ -578,6 +586,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => [{ id: 1, terminal: { name: 'terminal-a' } }]), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => ({ id: 1, terminal: { name: 'terminal-a' } })) } @@ -609,6 +618,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => [{ id: 1, terminal: terminalA }]), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), getActivePane: vi.fn(() => null), @@ -667,6 +677,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => []), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => null) } @@ -711,6 +722,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => [{ id: 1, terminal }]), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), refreshAllPanes: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => ({ id: 1, terminal })) @@ -775,6 +787,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => []), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => null) } @@ -829,6 +842,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => []), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => null) } @@ -874,6 +888,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => [pane]), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => pane) } @@ -929,6 +944,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => [pane]), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => pane) } @@ -1044,6 +1060,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => []), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), getActivePane: vi.fn(() => null) @@ -1077,6 +1094,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => []), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), getActivePane: vi.fn(() => null) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index a5d9172855b..3597f9dd28e 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -2,7 +2,11 @@ import { useEffect, useRef } from 'react' import type { IDisposable, Terminal } from '@xterm/xterm' import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' -import { PaneManager } from '@/lib/pane-manager/pane-manager' +import { + PaneManager, + type PaneExternalDropHandler, + type PaneExternalDropResolver +} from '@/lib/pane-manager/pane-manager' import { consumePendingWebRuntimeSplitMirrorTelemetry } from '@/runtime/web-runtime-session' import { normalizeTerminalFastScrollSensitivity, @@ -273,6 +277,8 @@ type UseTerminalPaneLifecycleDeps = { // Why: same pane count does not imply same geometry; drag-reorder can move // panes without resizing them, so overlay rects need a layout-change tick. setPaneLayoutRevision: React.Dispatch> + resolveExternalPaneDropTarget?: PaneExternalDropResolver + onExternalPaneDrop?: PaneExternalDropHandler } export function suppressIntentionalPaneCloseExit( @@ -532,7 +538,9 @@ export function useTerminalPaneLifecycle({ paneTitlesRef, setRenamingPaneId, setPaneCount, - setPaneLayoutRevision + setPaneLayoutRevision, + resolveExternalPaneDropTarget, + onExternalPaneDrop }: UseTerminalPaneLifecycleDeps): void { const terminalScrollbackRows = normalizeDesktopTerminalScrollbackRows( settings?.terminalScrollbackRows @@ -1017,7 +1025,7 @@ export function useTerminalPaneLifecycle({ pane.terminal.options.linkHandler = { allowNonHttpProtocols: true, activate: (event, text) => { - handleOscLink(text, event as MouseEvent | undefined, { + const handled = handleOscLink(text, event as MouseEvent | undefined, { ...linkDeps, startupCwd: getPaneLinkCwd(pane.id), runtimeEnvironmentId: linkDeps.getRuntimeEnvironmentIdForPane?.(pane.id) ?? null, @@ -1031,7 +1039,9 @@ export function useTerminalPaneLifecycle({ // moving the mouse extends a selection until the next click/Esc. // clearSelection() explicitly detaches those listeners (see // SelectionService._removeMouseDownListeners). - pane.terminal.clearSelection() + if (handled) { + pane.terminal.clearSelection() + } }, // Show bottom-left tooltip on hover for OSC 8 hyperlinks (e.g. // GitHub owner/repo#issue references emitted by CLI tools) — same @@ -1092,6 +1102,7 @@ export function useTerminalPaneLifecycle({ queueResizeAll(true) }, onPaneClosed: (paneId, closedPane) => { + const isDetachedToTab = closedPane?.reason === 'detach' const linkProviderDisposable = linkProviderDisposablesRef.current.get(paneId) if (linkProviderDisposable) { linkProviderDisposable.dispose() @@ -1167,31 +1178,37 @@ export function useTerminalPaneLifecycle({ panePtyBinding.dispose() panePtyBindings.delete(paneId) } - // Why: closing a pane is user-initiated teardown of this row — drop - // (not remove) so any retained `done` snapshot for this pane is also - // cleared and a same-frame live→gone transition cannot re-snapshot - // it via the retention sync. This is pane-keyed state, so it must - // clear even if the PTY transport was already removed. const leafId = closedPane?.leafId - if (leafId) { + if (leafId && !isDetachedToTab) { + // Why: closing a pane is user-initiated teardown of this row — drop + // (not remove) so any retained `done` snapshot for this pane is also + // cleared and a same-frame live→gone transition cannot re-snapshot + // it via the retention sync. This is pane-keyed state, so it must + // clear even if the PTY transport was already removed. const paneKey = makePaneKey(tabId, leafId) useAppStore.getState().setCacheTimerStartedAt(paneKey, null) clearTerminalPaneUnread(paneKey) useAppStore.getState().dropAgentStatus(paneKey) } if (transport) { - const ptyId = suppressIntentionalPaneCloseExit( - transport, - useAppStore.getState().suppressPtyExit - ) - if (ptyId) { - // Why: user/CLI pane closes intentionally tear down this PTY after - // PaneManager has already promoted the sibling. Suppress that exit - // so the last-surviving pane is not mistaken for an exited tab. - syncPanePtyLayoutBinding(paneId, null) - clearTabPtyId(tabId, ptyId) + if (isDetachedToTab) { + // Why: pane-to-tab detach hands the PTY to a newly-created tab; + // detach renderer listeners without sending a process teardown. + transport.detach?.() + } else { + const ptyId = suppressIntentionalPaneCloseExit( + transport, + useAppStore.getState().suppressPtyExit + ) + if (ptyId) { + // Why: user/CLI pane closes intentionally tear down this PTY after + // PaneManager has already promoted the sibling. Suppress that exit + // so the last-surviving pane is not mistaken for an exited tab. + syncPanePtyLayoutBinding(paneId, null) + clearTabPtyId(tabId, ptyId) + } + transport.destroy?.() } - transport.destroy?.() paneTransportsRef.current.delete(paneId) } clearRuntimePaneTitle(tabId, paneId) @@ -1290,6 +1307,8 @@ export function useTerminalPaneLifecycle({ releaseWebviewDragPassthrough?.() releaseWebviewDragPassthrough = null }, + resolveExternalPaneDropTarget, + onExternalPaneDrop, terminalOptions: () => { const currentSettings = settingsRef.current const terminalFontWeights = resolveTerminalFontWeights(currentSettings?.terminalFontWeight) @@ -1344,7 +1363,7 @@ export function useTerminalPaneLifecycle({ return } const activePane = managerRef.current?.getActivePane() - void handleOscLink(url, event, { + const handled = handleOscLink(url, event, { ...linkDeps, startupCwd: activePane ? getPaneLinkCwd(activePane.id) : startupCwd, runtimeEnvironmentId: activePane @@ -1359,7 +1378,9 @@ export function useTerminalPaneLifecycle({ // phantom selection until the next click/Esc. Explicitly clearing the // selection also detaches those listeners (see // SelectionService._removeMouseDownListeners). - managerRef.current?.getActivePane()?.terminal.clearSelection() + if (handled) { + managerRef.current?.getActivePane()?.terminal.clearSelection() + } }, formatLinkTooltip: (url, openLinkHint) => formatTerminalUrlTooltip(url, openLinkHint), // Why: TerminalPane instances stay mounted for hidden visited worktrees diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.test.ts b/src/renderer/src/components/terminal/terminal-tab-actions.test.ts index ba1443477bf..a070cdc9331 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.test.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.test.ts @@ -73,7 +73,7 @@ describe('createNewTerminalTab', () => { createNewTerminalTab('wt-1', 'zsh') - expect(createTab).toHaveBeenCalledWith('wt-1', undefined, 'zsh') + expect(createTab).toHaveBeenCalledWith('wt-1', undefined, 'zsh', undefined) expect(setActiveTabType).toHaveBeenCalledWith('terminal') expect(setTabBarOrder).toHaveBeenCalledWith('wt-1', ['tab-1']) expect(createWebRuntimeSessionTerminalMock).not.toHaveBeenCalled() @@ -123,6 +123,53 @@ describe('createNewTerminalTab', () => { }) expect(createTab).not.toHaveBeenCalled() }) + + it('creates local terminal tabs with a requested startup cwd', () => { + const createTab = vi.fn(() => ({ id: 'tab-1' })) + const setActiveTabType = vi.fn() + const setTabBarOrder = vi.fn() + getStateMock + .mockReturnValueOnce({ + settings: { activeRuntimeEnvironmentId: null }, + createTab, + setActiveTabType, + setTabBarOrder + }) + .mockReturnValueOnce({ + tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] }, + openFiles: [], + tabBarOrderByWorktree: {}, + setTabBarOrder + }) + + createNewTerminalTab('wt-1', undefined, { startupCwd: '/repo/packages/app' }) + + expect(createTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { + startupCwd: '/repo/packages/app' + }) + expect(setActiveTabType).toHaveBeenCalledWith('terminal') + }) + + it('delegates requested startup cwd to host runtime terminals', () => { + const createTab = vi.fn(() => ({ id: 'tab-1' })) + isWebRuntimeSessionActiveMock.mockReturnValue(true) + getStateMock.mockReturnValue({ + settings: { activeRuntimeEnvironmentId: 'web-runtime' }, + createTab, + setActiveTabType: vi.fn() + }) + + createNewTerminalTab('wt-1', undefined, { startupCwd: '/repo/packages/app' }) + + expect(createWebRuntimeSessionTerminalMock).toHaveBeenCalledWith({ + worktreeId: 'wt-1', + environmentId: 'web-runtime', + command: undefined, + cwd: '/repo/packages/app', + activate: true + }) + expect(createTab).not.toHaveBeenCalled() + }) }) describe('closeTerminalTab', () => { diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.ts b/src/renderer/src/components/terminal/terminal-tab-actions.ts index 77f604b7480..9991b7648ea 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.ts @@ -101,7 +101,8 @@ function isPinnedVisibleTab( export function createNewTerminalTab( activeWorktreeId: string | null, - shellOverride?: string + shellOverride?: string, + options?: { startupCwd?: string } ): void { if (!activeWorktreeId) { return @@ -116,11 +117,17 @@ export function createNewTerminalTab( worktreeId: activeWorktreeId, environmentId: runtimeEnvironmentId, command: shellOverride, + ...(options?.startupCwd ? { cwd: options.startupCwd } : {}), activate: true }) return } - const newTab = state.createTab(activeWorktreeId, undefined, shellOverride) + const newTab = state.createTab( + activeWorktreeId, + undefined, + shellOverride, + options?.startupCwd ? { startupCwd: options.startupCwd } : undefined + ) state.setActiveTabType('terminal') // Why: persist the tab bar order with the new terminal at the end of the // current visual order. Without this, reconcileTabOrder falls back to diff --git a/src/renderer/src/hooks/terminal-command-finished-event.ts b/src/renderer/src/hooks/terminal-command-finished-event.ts index 79bf8a978a8..5a304b41846 100644 --- a/src/renderer/src/hooks/terminal-command-finished-event.ts +++ b/src/renderer/src/hooks/terminal-command-finished-event.ts @@ -8,6 +8,11 @@ export type TerminalCommandFinishedEventDetail = { // decoupled consumers (e.g. git status refresh) react to shell commands // finishing without reaching into terminal internals. export function dispatchTerminalCommandFinishedEvent(worktreeId: string): void { + // Why: unit tests and non-DOM renderer shims may expose only the preload API. + if (typeof window.dispatchEvent !== 'function') { + return + } + window.dispatchEvent( new CustomEvent(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, { detail: { worktreeId } diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index d21df967484..b86a258bdc6 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -1738,6 +1738,7 @@ describe('useIpcEvents updater integration', () => { afterTabId?: string targetGroupId?: string command?: string + cwd?: string launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent title?: string @@ -1883,6 +1884,7 @@ describe('useIpcEvents updater integration', () => { afterTabId?: string targetGroupId?: string command?: string + cwd?: string launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent title?: string @@ -2144,6 +2146,7 @@ describe('useIpcEvents updater integration', () => { targetGroupId: 'group-left', title: 'Codex', command: 'codex', + cwd: '/repo/packages/app', launchConfig: { agentArgs: '--model gpt-5', agentEnv: { CODEX_PROFILE: 'request' } @@ -2156,7 +2159,8 @@ describe('useIpcEvents updater integration', () => { activate: false, recordInteraction: false, launchAgent: 'codex', - viewMode: 'chat' + viewMode: 'chat', + startupCwd: '/repo/packages/app' }) expect(setActiveView).not.toHaveBeenCalled() expect(setActiveWorktree).not.toHaveBeenCalled() diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index c0ab880a9e8..f5b8f67a57e 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -1351,6 +1351,7 @@ export function useIpcEvents(): void { requestId, worktreeId, command, + cwd, env, launchConfig, launchToken, @@ -1426,6 +1427,7 @@ export function useIpcEvents(): void { ...initialAgentTabViewModeProps(store.settings) } : {}), + ...(cwd ? { startupCwd: cwd } : {}), // Why: tabId hint comes from CLI-spawned PTYs whose env // already has the pane key baked in. Adopting the tab under // the same id keeps hook-event attribution working. @@ -1435,7 +1437,15 @@ export function useIpcEvents(): void { worktreeId, undefined, undefined, - shouldActivate ? undefined : { activate: false, recordInteraction: false } + shouldActivate + ? cwd + ? { startupCwd: cwd } + : undefined + : { + activate: false, + recordInteraction: false, + ...(cwd ? { startupCwd: cwd } : {}) + } )) // Why: when an existing tab already owns this ptyId, we reuse it instead of // minting a new one — but the PTY env already carries a paneKey from main. @@ -1602,11 +1612,18 @@ export function useIpcEvents(): void { ? { ...(shouldActivate ? {} : { activate: false, recordInteraction: false }), launchAgent: data.launchAgent, - ...initialAgentTabViewModeProps(store.settings) + ...initialAgentTabViewModeProps(store.settings), + ...(data.cwd ? { startupCwd: data.cwd } : {}) } : shouldActivate - ? undefined - : { activate: false, recordInteraction: false } + ? data.cwd + ? { startupCwd: data.cwd } + : undefined + : { + activate: false, + recordInteraction: false, + ...(data.cwd ? { startupCwd: data.cwd } : {}) + } const tab = store.createTab(worktreeId, data.targetGroupId, undefined, tabOptions) if (data.afterTabId) { const createdUnifiedTab = useAppStore diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 66b74f5c0b8..b9113f064cd 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -4118,7 +4118,8 @@ "groupDeleteFailed": "Failed to delete group", "groupDeleteFailedDesc": "Something went wrong while deleting the group. No projects were removed.", "failedNestWorkspace": "Failed to nest workspace", - "sidebarRowMissing": "Target no longer exists" + "sidebarRowMissing": "Target no longer exists", + "failedUnnestWorkspace": "Failed to unnest workspace" }, "WorktreeMetaDialog": { "3db0a2a593": "Cancel", @@ -5162,7 +5163,9 @@ "8f1afdfbd8": "Diff Word Wrap", "4aa4d9fb73": "Wrap long lines in diff editors instead of requiring horizontal scrolling.", "bf16ef0af2": "Off", - "3f6892f307": "On" + "3f6892f307": "On", + "b82f86d7d2": "Rich Markdown Spellcheck", + "5195f0b9ef": "Show browser spelling underlines and suggestions while editing rich Markdown." }, "AdvancedNetworkSettingsSection": { "3e431564b5": "localhost, 127.0.0.1, *.internal", @@ -6404,8 +6407,6 @@ "a62b4cb39a": "Save Changes", "29af933cd5": "New SSH Target", "f2331ce599": "Edit SSH Target", - "7c13f58c91": "Until reset", - "55c56cf2c7": "Timeout after disconnect (seconds)", "4a342f44c1": "Advanced Connection", "e9609ddca6": "Proxy, jump host, and connection reuse", "8c922dffba": "Reuse SSH connection for faster setup", @@ -7454,7 +7455,9 @@ "161a86a9da": "Confirm before closing pinned tabs", "8e593f04fc": "Show a confirmation dialog before a pinned tab is closed.", "defaultProjectRuntime": "Default Project Runtime", - "defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects." + "defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects.", + "d2d2d929c0": "Rich Markdown Spellcheck", + "4497e2e2bb": "Show browser spelling underlines and suggestions while editing rich Markdown." } }, "git": { @@ -8821,7 +8824,8 @@ "98a79948b3": "Copy", "b234ab25b4": "Could not copy the file to the clipboard", "f9d7ca753d": "Copy Paths", - "3161c4e425": "folder" + "3161c4e425": "folder", + "e887fa4b2e": "Open in Terminal" }, "FileExplorerToolbar": { "d238264654": "Show Git Ignored Files", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 0981dc1e963..ea71f964f4b 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -4095,7 +4095,8 @@ "hostAuthNeeded": "Autenticación requerida", "hostDisconnected": "Desconectado", "failedNestWorkspace": "No se pudo anidar el espacio de trabajo", - "sidebarRowMissing": "El destino ya no existe" + "sidebarRowMissing": "El destino ya no existe", + "failedUnnestWorkspace": "Failed to unnest workspace" }, "WorktreeMetaDialog": { "3db0a2a593": "Cancelar", @@ -5162,7 +5163,9 @@ "8f1afdfbd8": "Ajuste de palabras diferencial", "4aa4d9fb73": "Ajuste líneas largas en editores de diferencias en lugar de requerir desplazamiento horizontal.", "bf16ef0af2": "Apagado", - "3f6892f307": "En" + "3f6892f307": "En", + "b82f86d7d2": "Rich Markdown Spellcheck", + "5195f0b9ef": "Show browser spelling underlines and suggestions while editing rich Markdown." }, "AdvancedNetworkSettingsSection": { "3e431564b5": "host local, 127.0.0.1, *.interno", @@ -7415,7 +7418,9 @@ "161a86a9da": "Confirmar antes de cerrar pestañas fijadas", "8e593f04fc": "Muestra un diálogo de confirmación antes de cerrar una pestaña fijada.", "defaultProjectRuntime": "Default Project Runtime", - "defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects." + "defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects.", + "d2d2d929c0": "Rich Markdown Spellcheck", + "4497e2e2bb": "Show browser spelling underlines and suggestions while editing rich Markdown." } }, "git": { @@ -8819,7 +8824,8 @@ "98a79948b3": "Copiar", "b234ab25b4": "No se pudo copiar el archivo al portapapeles", "f9d7ca753d": "Copiar rutas", - "3161c4e425": "carpeta" + "3161c4e425": "carpeta", + "e887fa4b2e": "Abrir en terminal" }, "FileExplorerToolbar": { "d238264654": "Mostrar archivos ignorados de Git", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index b2e321fc659..177d52f50b2 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -4076,7 +4076,8 @@ "hostAuthNeeded": "認証が必要です", "hostDisconnected": "切断済み", "failedNestWorkspace": "ワークスペースをネストできませんでした", - "sidebarRowMissing": "対象はもう存在しません" + "sidebarRowMissing": "対象はもう存在しません", + "failedUnnestWorkspace": "Failed to unnest workspace" }, "WorktreeMetaDialog": { "3db0a2a593": "キャンセル", @@ -5147,7 +5148,9 @@ "8f1afdfbd8": "差分ワードラップ", "4aa4d9fb73": "水平スクロールを必要とせずに、差分エディターで長い行を折り返します。", "bf16ef0af2": "オフ", - "3f6892f307": "オン" + "3f6892f307": "オン", + "b82f86d7d2": "Rich Markdown Spellcheck", + "5195f0b9ef": "Show browser spelling underlines and suggestions while editing rich Markdown." }, "AdvancedNetworkSettingsSection": { "3e431564b5": "ローカルホスト、127.0.0.1、*.internal", @@ -7437,7 +7440,9 @@ "161a86a9da": "ピン留めしたタブを閉じる前に確認", "8e593f04fc": "ピン留めしたタブを閉じる前に確認ダイアログを表示します。", "defaultProjectRuntime": "Default Project Runtime", - "defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects." + "defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects.", + "d2d2d929c0": "Rich Markdown Spellcheck", + "4497e2e2bb": "Show browser spelling underlines and suggestions while editing rich Markdown." } }, "git": { @@ -8819,7 +8824,8 @@ "98a79948b3": "コピー", "b234ab25b4": "ファイルをクリップボードにコピーできませんでした", "f9d7ca753d": "パスのコピー", - "3161c4e425": "フォルダ" + "3161c4e425": "フォルダ", + "e887fa4b2e": "ターミナルで開く" }, "FileExplorerToolbar": { "d238264654": "Git で無視されたファイルを表示", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index eb7378ea9fa..ab7493ba20b 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -4076,7 +4076,8 @@ "hostAuthNeeded": "인증 필요", "hostDisconnected": "연결 끊김", "failedNestWorkspace": "워크스페이스를 중첩하지 못했습니다", - "sidebarRowMissing": "대상이 더 이상 존재하지 않습니다" + "sidebarRowMissing": "대상이 더 이상 존재하지 않습니다", + "failedUnnestWorkspace": "Failed to unnest workspace" }, "WorktreeMetaDialog": { "3db0a2a593": "취소", @@ -5147,7 +5148,9 @@ "8f1afdfbd8": "차이점 단어 줄 바꿈", "4aa4d9fb73": "가로 스크롤을 요구하는 대신 diff 편집기에서 긴 줄을 줄 바꿈합니다.", "bf16ef0af2": "끄다", - "3f6892f307": "~에" + "3f6892f307": "~에", + "b82f86d7d2": "Rich Markdown Spellcheck", + "5195f0b9ef": "Show browser spelling underlines and suggestions while editing rich Markdown." }, "AdvancedNetworkSettingsSection": { "3e431564b5": "로컬호스트, 127.0.0.1, *.internal", @@ -7400,7 +7403,9 @@ "161a86a9da": "고정된 탭을 닫기 전에 확인", "8e593f04fc": "고정된 탭을 닫기 전에 확인 대화상자를 표시합니다.", "defaultProjectRuntime": "기본 프로젝트 런타임", - "defaultProjectRuntimeDescription": "로컬 Windows 프로젝트가 상속할 런타임을 선택합니다." + "defaultProjectRuntimeDescription": "로컬 Windows 프로젝트가 상속할 런타임을 선택합니다.", + "d2d2d929c0": "Rich Markdown Spellcheck", + "4497e2e2bb": "Show browser spelling underlines and suggestions while editing rich Markdown." } }, "git": { @@ -8819,7 +8824,8 @@ "98a79948b3": "복사", "b234ab25b4": "파일을 클립보드에 복사하지 못했습니다", "f9d7ca753d": "경로 복사", - "3161c4e425": "폴더" + "3161c4e425": "폴더", + "e887fa4b2e": "터미널에서 열기" }, "FileExplorerToolbar": { "d238264654": "Git이 무시한 파일 표시", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 595230d7805..78f552eef4a 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -4076,7 +4076,8 @@ "hostAuthNeeded": "需要身份验证", "hostDisconnected": "已断开连接", "failedNestWorkspace": "无法嵌套工作区", - "sidebarRowMissing": "目标不再存在" + "sidebarRowMissing": "目标不再存在", + "failedUnnestWorkspace": "Failed to unnest workspace" }, "WorktreeMetaDialog": { "3db0a2a593": "取消", @@ -5147,7 +5148,9 @@ "8f1afdfbd8": "差异自动换行", "4aa4d9fb73": "在差异编辑器中换行显示长行,而不是要求水平滚动。", "bf16ef0af2": "关闭", - "3f6892f307": "开启" + "3f6892f307": "开启", + "b82f86d7d2": "Rich Markdown Spellcheck", + "5195f0b9ef": "Show browser spelling underlines and suggestions while editing rich Markdown." }, "AdvancedNetworkSettingsSection": { "3e431564b5": "本地主机,127.0.0.1,*.internal", @@ -7400,7 +7403,9 @@ "161a86a9da": "关闭固定标签页前确认", "8e593f04fc": "关闭固定标签页前显示确认对话框。", "defaultProjectRuntime": "默认项目运行时", - "defaultProjectRuntimeDescription": "选择本地 Windows 项目继承的运行时。" + "defaultProjectRuntimeDescription": "选择本地 Windows 项目继承的运行时。", + "d2d2d929c0": "Rich Markdown Spellcheck", + "4497e2e2bb": "Show browser spelling underlines and suggestions while editing rich Markdown." } }, "git": { @@ -8819,7 +8824,8 @@ "98a79948b3": "复制", "b234ab25b4": "无法将文件复制到剪贴板", "f9d7ca753d": "复制路径", - "3161c4e425": "文件夹" + "3161c4e425": "文件夹", + "e887fa4b2e": "在终端中打开" }, "FileExplorerToolbar": { "d238264654": "显示 Git 忽略的文件", diff --git a/src/renderer/src/lib/github-work-item-background-create.test.ts b/src/renderer/src/lib/github-work-item-background-create.test.ts index 17a70124d38..a4e6d8f0adf 100644 --- a/src/renderer/src/lib/github-work-item-background-create.test.ts +++ b/src/renderer/src/lib/github-work-item-background-create.test.ts @@ -97,6 +97,13 @@ function makeDeps(store = makeStore()) { resolveSetupDecision: vi.fn().mockResolvedValue({ kind: 'decided', decision: 'inherit' }), resolvePrStartPoint: vi.fn(), confirmHooks: vi.fn().mockResolvedValue('run'), + readIssueCommand: vi.fn().mockResolvedValue({ + effectiveContent: null, + localContent: null, + sharedContent: null, + localFilePath: '', + source: 'none' + }), beginBackgroundCreate: vi.fn(() => 'creation-1'), continueBackgroundCreate: vi.fn(() => true), activatePendingCreate: vi.fn(), @@ -607,4 +614,206 @@ describe('createGitHubWorkItemWorkspaceInBackground', () => { expect(request.startup?.command).toBe('codex --prompt-file') expect(buildAgentStartupPlan).not.toHaveBeenCalled() }) + + it('attaches the rendered issue command when the repo configured one and trust runs', async () => { + const deps = makeDeps() + deps.readIssueCommand.mockResolvedValueOnce({ + effectiveContent: 'gh issue view {{issue}} --repo {{artifact_url}}', + localContent: null, + sharedContent: 'gh issue view {{issue}} --repo {{artifact_url}}', + localFilePath: '', + source: 'shared' + }) + + await createGitHubWorkItemWorkspaceInBackground( + { + item: makeIssue(), + repoId: 'repo-1', + openModalFallback: vi.fn() + }, + deps + ) + + expect(deps.confirmHooks).toHaveBeenCalledWith(expect.anything(), 'repo-1', 'setup') + expect(deps.confirmHooks).toHaveBeenCalledWith(expect.anything(), 'repo-1', 'issueCommand') + const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined + expect(continueCall).toBeDefined() + const request = continueCall?.[1] as WorktreeCreationRequest + expect(request.issueCommand?.command).toBe( + 'gh issue view 42 --repo https://github.com/stablyai/orca/issues/42' + ) + }) + + it('omits the issue command when the repo configured none', async () => { + const deps = makeDeps() + + await createGitHubWorkItemWorkspaceInBackground( + { + item: makeIssue(), + repoId: 'repo-1', + openModalFallback: vi.fn() + }, + deps + ) + + const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined + expect(continueCall).toBeDefined() + const request = continueCall?.[1] as WorktreeCreationRequest + expect(request.issueCommand).toBeUndefined() + expect(deps.confirmHooks).not.toHaveBeenCalledWith(expect.anything(), 'repo-1', 'issueCommand') + }) + + it('skips the issue command when setup trust was declined', async () => { + const deps = makeDeps() + deps.readIssueCommand.mockResolvedValueOnce({ + effectiveContent: 'echo {{issue}}', + localContent: 'echo {{issue}}', + sharedContent: null, + localFilePath: '/repo/.orca/issue-command', + source: 'local' + }) + // Why: the setup confirmHooks call resolves 'skip', which mirrors the + // composer suppressing the issue command when setup trust is declined. + deps.confirmHooks.mockResolvedValue('skip') + + await createGitHubWorkItemWorkspaceInBackground( + { + item: makeIssue(), + repoId: 'repo-1', + openModalFallback: vi.fn() + }, + deps + ) + + const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined + expect(continueCall).toBeDefined() + const request = continueCall?.[1] as WorktreeCreationRequest + expect(request.issueCommand).toBeUndefined() + expect(deps.confirmHooks).not.toHaveBeenCalledWith(expect.anything(), 'repo-1', 'issueCommand') + // Why: a declined setup trust short-circuits before the (up-to-15s) read, + // so workspace creation is never stalled to fetch a command we will drop. + expect(deps.readIssueCommand).not.toHaveBeenCalled() + }) + + it('does not run the issue command for PR items', async () => { + const deps = makeDeps() + deps.resolvePrStartPoint.mockResolvedValueOnce({ + baseBranch: 'feature/from-pr', + pushTarget: { remote: 'origin', branch: 'feature/from-pr' }, + branchNameOverride: 'feature/from-pr', + compareBaseRef: 'main' + }) + deps.readIssueCommand.mockResolvedValueOnce({ + effectiveContent: 'echo {{issue}}', + localContent: 'echo {{issue}}', + sharedContent: null, + localFilePath: '/repo/.orca/issue-command', + source: 'local' + }) + + await createGitHubWorkItemWorkspaceInBackground( + { + item: makeIssue({ type: 'pr', number: 7, url: 'https://github.com/stablyai/orca/pull/7' }), + repoId: 'repo-1', + openModalFallback: vi.fn() + }, + deps + ) + + const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined + expect(continueCall).toBeDefined() + const request = continueCall?.[1] as WorktreeCreationRequest + expect(request.issueCommand).toBeUndefined() + expect(deps.confirmHooks).not.toHaveBeenCalledWith(expect.anything(), 'repo-1', 'issueCommand') + }) + + it('fails closed when reading the issue command rejects', async () => { + const deps = makeDeps() + deps.readIssueCommand.mockRejectedValueOnce(new Error('runtime offline')) + + const result = await createGitHubWorkItemWorkspaceInBackground( + { + item: makeIssue(), + repoId: 'repo-1', + openModalFallback: vi.fn() + }, + deps + ) + + expect(result).toEqual({ kind: 'background-started' }) + const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined + expect(continueCall).toBeDefined() + const request = continueCall?.[1] as WorktreeCreationRequest + expect(request.issueCommand).toBeUndefined() + }) + + it('re-reads the store for each trust check so mid-flow trust updates are honored', async () => { + const snapshots = [makeStore(), makeStore(), makeStore()] + let getStoreCall = 0 + const deps = makeDeps(snapshots[0]) + deps.getStore = vi.fn( + () => snapshots[Math.min(getStoreCall++, snapshots.length - 1)] ?? snapshots[0] + ) + deps.readIssueCommand.mockResolvedValueOnce({ + effectiveContent: 'echo {{issue}}', + localContent: null, + sharedContent: 'echo {{issue}}', + localFilePath: '', + source: 'shared' + }) + + await createGitHubWorkItemWorkspaceInBackground( + { + item: makeIssue(), + repoId: 'repo-1', + openModalFallback: vi.fn() + }, + deps + ) + + // Why: an "Always trust" stamped by the setup prompt only exists in a fresh + // snapshot. Each trust check must read the store at call time instead of + // reusing the snapshot captured when the flow began. + const setupCall = deps.confirmHooks.mock.calls.find((call) => call[2] === 'setup') + const issueCall = deps.confirmHooks.mock.calls.find((call) => call[2] === 'issueCommand') + expect(setupCall?.[0]).toBeDefined() + expect(issueCall?.[0]).toBeDefined() + expect(setupCall?.[0]).not.toBe(snapshots[0]) + expect(issueCall?.[0]).not.toBe(snapshots[0]) + expect(issueCall?.[0]).not.toBe(setupCall?.[0]) + }) + + it('keeps the seeded agent prompt as the bare issue URL (not Complete )', async () => { + const store = makeStore({ + ensureDetectedAgents: vi.fn().mockResolvedValue(['codex']) + }) + const deps = makeDeps(store) + deps.readIssueCommand.mockResolvedValueOnce({ + effectiveContent: 'echo {{issue}}', + localContent: 'echo {{issue}}', + sharedContent: null, + localFilePath: '/repo/.orca/issue-command', + source: 'local' + }) + + await createGitHubWorkItemWorkspaceInBackground( + { + item: makeIssue(), + repoId: 'repo-1', + openModalFallback: vi.fn() + }, + deps + ) + + const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined + expect(continueCall).toBeDefined() + const request = continueCall?.[1] as WorktreeCreationRequest + // Why: Brennan's confirmed scope keeps the quick-start prompt the bare link; + // the issue command runs as a side-pane split, not as the agent prompt. + expect(request.startupPlan?.draftPrompt).toBe('https://github.com/stablyai/orca/issues/42') + expect(request.startupPlan?.draftPrompt ?? '').not.toContain('Complete') + expect(request.quickPrompt).not.toContain('Complete') + // The issue command itself still threads through as a side-pane split. + expect(request.issueCommand?.command).toBe('echo 42') + }) }) diff --git a/src/renderer/src/lib/github-work-item-background-create.ts b/src/renderer/src/lib/github-work-item-background-create.ts index edaaa92bb92..7fa94fd9d2b 100644 --- a/src/renderer/src/lib/github-work-item-background-create.ts +++ b/src/renderer/src/lib/github-work-item-background-create.ts @@ -19,7 +19,10 @@ import { } from '@/lib/launch-work-item-direct-preflight' import { agentLaunchCommandErrorMessage } from '@/lib/launch-work-item-direct-messages' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' +import { renderIssueCommandTemplate } from '@/lib/new-workspace' import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import { readRuntimeIssueCommand } from '@/runtime/runtime-hooks-client' +import { isGitRepoKind } from '../../../shared/repo-kind' import { getRepoExecutionHostId, parseExecutionHostId } from '../../../shared/execution-host' import { evaluateRuntimeCompat } from '../../../shared/protocol-compat' import { @@ -50,8 +53,9 @@ type BackgroundGitHubWorkItemCreateDeps = { confirmHooks: ( store: GitHubWorkItemBackgroundStoreSnapshot, repoId: string, - scope: 'setup' + scope: 'setup' | 'issueCommand' ) => ReturnType + readIssueCommand: typeof readRuntimeIssueCommand beginBackgroundCreate: typeof beginBackgroundWorktreePreparation continueBackgroundCreate: typeof continueBackgroundWorktreeCreation activatePendingCreate: (creationId: string) => void @@ -78,8 +82,9 @@ const DEFAULT_DEPS: BackgroundGitHubWorkItemCreateDeps = { useAppStore.getState().activePendingCreationId === creationId, resolveSetupDecision: resolveDirectSetupDecision, resolvePrStartPoint: resolveDirectPrStartPoint, - confirmHooks: (store, repoId, scope) => + confirmHooks: (store, repoId, scope: 'setup' | 'issueCommand') => ensureHooksConfirmed(store as ReturnType, repoId, scope), + readIssueCommand: readRuntimeIssueCommand, beginBackgroundCreate: beginBackgroundWorktreePreparation, continueBackgroundCreate: continueBackgroundWorktreeCreation, activatePendingCreate: (creationId) => { @@ -222,7 +227,10 @@ export async function createGitHubWorkItemWorkspaceInBackground( } } - const trustDecision = await deps.confirmHooks(store, args.repoId, 'setup') + // Why: trust prompts are serialized app-wide, so read the store fresh at + // each check — an "Always trust" stamped by an earlier prompt (including + // this flow's own setup prompt) must short-circuit instead of re-prompting. + const trustDecision = await deps.confirmHooks(deps.getStore(), args.repoId, 'setup') if (!deps.hasPendingCreate(creationId)) { return { kind: 'background-started' } } @@ -246,6 +254,49 @@ export async function createGitHubWorkItemWorkspaceInBackground( } const backendStartup = buildGitHubWorkItemBackendStartup(agent, startupPlan, quickTelemetry) + // Why: mirror the composer's trust-gated issue-command split. Only GitHub + // issues (numeric issue number) on git repos run it; PRs/Linear/folders never + // do. Reuse the setup trust decision: a 'skip' there also skips the command. + let issueCommand: WorktreeCreationRequest['issueCommand'] + // Why: a declined setup trust also skips the issue command, so short-circuit + // before the (up-to-15s) read rather than reading just to drop the result. + if ( + trustDecision !== 'skip' && + isGitRepoKind(repo) && + args.item.type === 'issue' && + typeof args.item.number === 'number' + ) { + // Why: read failures fail closed (no command), so create still proceeds. + let effectiveContent = '' + try { + const issueCommandRead = await deps.readIssueCommand(repoOwnerSettings, args.repoId) + effectiveContent = (issueCommandRead.effectiveContent ?? '').trim() + } catch { + effectiveContent = '' + } + if (!deps.hasPendingCreate(creationId)) { + return { kind: 'background-started' } + } + if (effectiveContent.length > 0) { + const issueCommandTrust = await deps.confirmHooks( + deps.getStore(), + args.repoId, + 'issueCommand' + ) + if (!deps.hasPendingCreate(creationId)) { + return { kind: 'background-started' } + } + if (issueCommandTrust === 'run') { + issueCommand = { + command: renderIssueCommandTemplate(effectiveContent, { + issueNumber: args.item.number, + artifactUrl: args.item.url ?? null + }) + } + } + } + } + const request: WorktreeCreationRequest = { ...initialRequest, ...(baseBranch ? { baseBranch } : {}), @@ -255,6 +306,7 @@ export async function createGitHubWorkItemWorkspaceInBackground( agent, ...(branchNameOverride ? { branchNameOverride } : {}), ...(backendStartup ? { startup: backendStartup } : {}), + ...(issueCommand ? { issueCommand } : {}), startupPlan, quickPrompt, quickTelemetry diff --git a/src/renderer/src/lib/hook-command-delayed-delivery.test.ts b/src/renderer/src/lib/hook-command-delayed-delivery.test.ts new file mode 100644 index 00000000000..63fafaaae55 --- /dev/null +++ b/src/renderer/src/lib/hook-command-delayed-delivery.test.ts @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { + queueHookCommandsForFirstWorktreeTab, + resetHookCommandDelayedDeliveryForTests +} from './hook-command-delayed-delivery' + +type AppState = ReturnType + +const initialTabsByWorktree = useAppStore.getState().tabsByWorktree +const initialGetKnownWorktreeById = useAppStore.getState().getKnownWorktreeById + +function setStorePartial(partial: Record): void { + useAppStore.setState(partial as Partial) +} + +function markWorktreeKnown(worktreeId: string): void { + setStorePartial({ + getKnownWorktreeById: ((id: string) => + id === worktreeId ? { id } : undefined) as unknown as AppState['getKnownWorktreeById'] + }) +} + +afterEach(() => { + resetHookCommandDelayedDeliveryForTests() + setStorePartial({ + tabsByWorktree: initialTabsByWorktree, + getKnownWorktreeById: initialGetKnownWorktreeById + }) +}) + +describe('queueHookCommandsForFirstWorktreeTab', () => { + it('holds the delivery until the first worktree tab lands, then delivers exactly once', () => { + markWorktreeKnown('wt-1') + setStorePartial({ tabsByWorktree: {} }) + const deliver = vi.fn() + + queueHookCommandsForFirstWorktreeTab({ worktreeId: 'wt-1', deliver }) + expect(deliver).not.toHaveBeenCalled() + + setStorePartial({ tabsByWorktree: { 'wt-1': [{ id: 'mirror-tab-1' }] } }) + + expect(deliver).toHaveBeenCalledTimes(1) + expect(deliver).toHaveBeenCalledWith(expect.anything(), 'mirror-tab-1') + + // Later tab churn must not re-deliver the consumed entry. + setStorePartial({ tabsByWorktree: { 'wt-1': [{ id: 'mirror-tab-1' }, { id: 'tab-2' }] } }) + expect(deliver).toHaveBeenCalledTimes(1) + }) + + it('delivers immediately when the worktree already has a tab at queue time', () => { + markWorktreeKnown('wt-1') + setStorePartial({ tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] } }) + const deliver = vi.fn() + + queueHookCommandsForFirstWorktreeTab({ worktreeId: 'wt-1', deliver }) + + expect(deliver).toHaveBeenCalledTimes(1) + expect(deliver).toHaveBeenCalledWith(expect.anything(), 'tab-1') + }) + + it('drops the pending delivery when the worktree is no longer known', () => { + setStorePartial({ + tabsByWorktree: {}, + getKnownWorktreeById: (() => undefined) as unknown as AppState['getKnownWorktreeById'] + }) + const deliver = vi.fn() + + queueHookCommandsForFirstWorktreeTab({ worktreeId: 'wt-gone', deliver }) + + // A tab appearing later (e.g. an id reused by mirror churn) must not + // deliver commands for a worktree that was dropped while unknown. + markWorktreeKnown('wt-gone') + setStorePartial({ tabsByWorktree: { 'wt-gone': [{ id: 'tab-1' }] } }) + expect(deliver).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/hook-command-delayed-delivery.ts b/src/renderer/src/lib/hook-command-delayed-delivery.ts new file mode 100644 index 00000000000..234126f73ad --- /dev/null +++ b/src/renderer/src/lib/hook-command-delayed-delivery.ts @@ -0,0 +1,67 @@ +import { useAppStore } from '@/store' + +type AppStoreSnapshot = ReturnType + +type PendingWorktreeHookCommandDelivery = { + worktreeId: string + deliver: (state: AppStoreSnapshot, firstTerminalTabId: string) => void +} + +// Why: runtime-owned worktrees mirror their session tabs asynchronously, so a +// fresh create usually has no tab to queue setup/issue commands on yet. Hold +// the delivery until the first mirrored terminal tab lands instead of +// dropping it. Mirrors agent-startup-delayed-delivery's lazy-subscription +// shape: subscribed only while something is pending. +const pendingHookCommandDeliveries = new Map() +let unsubscribePendingHookCommandDeliveries: (() => void) | null = null + +export function queueHookCommandsForFirstWorktreeTab( + delivery: PendingWorktreeHookCommandDelivery +): void { + pendingHookCommandDeliveries.set(delivery.worktreeId, delivery) + ensurePendingHookCommandSubscription() + flushPendingHookCommandDeliveries() +} + +export function resetHookCommandDelayedDeliveryForTests(): void { + pendingHookCommandDeliveries.clear() + unsubscribePendingHookCommandDeliveries?.() + unsubscribePendingHookCommandDeliveries = null +} + +function ensurePendingHookCommandSubscription(): void { + if (unsubscribePendingHookCommandDeliveries) { + return + } + unsubscribePendingHookCommandDeliveries = useAppStore.subscribe(() => { + flushPendingHookCommandDeliveries() + }) +} + +function stopPendingHookCommandSubscriptionIfIdle(): void { + if (pendingHookCommandDeliveries.size > 0 || !unsubscribePendingHookCommandDeliveries) { + return + } + unsubscribePendingHookCommandDeliveries() + unsubscribePendingHookCommandDeliveries = null +} + +function flushPendingHookCommandDeliveries(): void { + const state = useAppStore.getState() + for (const [worktreeId, delivery] of pendingHookCommandDeliveries) { + const firstTerminalTabId = state.tabsByWorktree[worktreeId]?.[0]?.id + if (!firstTerminalTabId) { + // Why: a worktree can be removed before its tabs ever mirror; drop the + // entry so the subscription does not stay armed forever. + if (!state.getKnownWorktreeById(worktreeId)) { + pendingHookCommandDeliveries.delete(worktreeId) + } + continue + } + // Delete before delivering so store writes inside deliver cannot re-enter + // this entry through the subscription. + pendingHookCommandDeliveries.delete(worktreeId) + delivery.deliver(state, firstTerminalTabId) + } + stopPendingHookCommandSubscriptionIfIdle() +} diff --git a/src/renderer/src/lib/ime-composition-keyboard-event.test.ts b/src/renderer/src/lib/ime-composition-keyboard-event.test.ts new file mode 100644 index 00000000000..daa02c5411e --- /dev/null +++ b/src/renderer/src/lib/ime-composition-keyboard-event.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import type { KeyboardEvent as ReactKeyboardEvent } from 'react' +import { isImeCompositionKeyDown } from './ime-composition-keyboard-event' + +function keyEvent(nativeEvent: { isComposing?: boolean; keyCode?: number }): ReactKeyboardEvent { + return { + nativeEvent: { + isComposing: nativeEvent.isComposing ?? false, + keyCode: nativeEvent.keyCode ?? 13 + } + } as unknown as ReactKeyboardEvent +} + +describe('isImeCompositionKeyDown', () => { + it('is true while the IME is composing', () => { + expect(isImeCompositionKeyDown(keyEvent({ isComposing: true }))).toBe(true) + }) + + it('is true for the keyCode 229 fallback when isComposing is not set', () => { + expect(isImeCompositionKeyDown(keyEvent({ isComposing: false, keyCode: 229 }))).toBe(true) + }) + + it('is false for a plain Enter outside of composition', () => { + expect(isImeCompositionKeyDown(keyEvent({ isComposing: false, keyCode: 13 }))).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/ime-composition-keyboard-event.ts b/src/renderer/src/lib/ime-composition-keyboard-event.ts new file mode 100644 index 00000000000..c5faed78d16 --- /dev/null +++ b/src/renderer/src/lib/ime-composition-keyboard-event.ts @@ -0,0 +1,13 @@ +import type { KeyboardEvent as ReactKeyboardEvent } from 'react' + +/** + * Why: CJK IMEs (Japanese/Chinese/Korean) fire a keydown for the Enter that + * only confirms a conversion candidate. Rename/title inputs that commit on + * `Enter` must ignore that keydown, otherwise they submit mid-composition with a + * half-converted value. `isComposing` covers most browsers; `keyCode === 229` is + * a defensive fallback for IMEs that don't set `isComposing` on keydown. + */ +export function isImeCompositionKeyDown(event: ReactKeyboardEvent): boolean { + const nativeEvent = event.nativeEvent + return nativeEvent.isComposing || nativeEvent.keyCode === 229 +} diff --git a/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts b/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts index ec1783654d5..3165e86c04e 100644 --- a/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts @@ -1,5 +1,5 @@ // @vitest-environment happy-dom -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { TerminalLeafId } from '../../../../shared/stable-pane-id' import { createPaneDOM } from './pane-dom-creation' @@ -48,7 +48,65 @@ vi.mock('@xterm/xterm', () => ({ }) })) +function setPlatform(userAgent: string): void { + vi.stubGlobal('navigator', { userAgent }) +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + describe('createPaneDOM link tooltips', () => { + it('anchors WebLinks hover text to the unpadded terminal window corner', () => { + const leafId = '11111111-1111-4111-8111-111111111111' as TerminalLeafId + const pane = createPaneDOM( + 1, + leafId, + {}, + { active: null } as never, + {} as never, + vi.fn(), + vi.fn() + ) + + expect(pane.linkTooltip.style.left).toBe('0px') + expect(pane.linkTooltip.style.bottom).toBe('0px') + }) + + it('uses desktop modifier-click text for WebLinks hover hints', () => { + const leafId = '11111111-1111-4111-8111-111111111111' as TerminalLeafId + + setPlatform('Macintosh') + const macPane = createPaneDOM( + 1, + leafId, + {}, + { active: null } as never, + {} as never, + vi.fn(), + vi.fn() + ) + webLinksAddonMock.options?.hover?.({} as MouseEvent, 'http://localhost:5180/') + expect(macPane.linkTooltip.textContent).toBe( + 'http://localhost:5180/ (⌘+click to open or ⇧⌘+click for system browser)' + ) + + setPlatform('Windows') + const windowsPane = createPaneDOM( + 2, + leafId, + {}, + { active: null } as never, + {} as never, + vi.fn(), + vi.fn() + ) + webLinksAddonMock.options?.hover?.({} as MouseEvent, 'http://localhost:5180/') + expect(windowsPane.linkTooltip.textContent).toBe( + 'http://localhost:5180/ (Ctrl+click to open or Shift+Ctrl+click for system browser)' + ) + }) + it('lets callers replace WebLinks hover text for display-only labels', async () => { const labeledText = 'http://main.orca.localhost:60016/ (localhost:5180; click to open)' const leafId = '11111111-1111-4111-8111-111111111111' as TerminalLeafId diff --git a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts index 8c1c1e289ff..43b628ffbd5 100644 --- a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts +++ b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts @@ -16,8 +16,8 @@ import { installGuardedLinkProviderRegistration } from './terminal-link-provider function getTerminalUrlOpenHint(): string { return navigator.userAgent.includes('Mac') - ? 'click to open or ⇧+click for system browser' - : 'click to open or Shift+click for system browser' + ? '⌘+click to open or ⇧⌘+click for system browser' + : 'Ctrl+click to open or Shift+Ctrl+click for system browser' } function defaultLinkTooltipText(uri: string, openLinkHint: string): string { @@ -66,8 +66,10 @@ export function createPaneDOM( const linkTooltip = document.createElement('div') linkTooltip.className = 'pane-link-tooltip' linkTooltip.classList.add('xterm-hover') + // Why: Ghostty-style URL hover belongs to the terminal window corner; do not + // let terminal content padding shift it inward. linkTooltip.style.cssText = - 'display:none;position:absolute;bottom:4px;left:8px;z-index:40;' + + 'display:none;position:absolute;bottom:0;left:0;z-index:40;' + 'padding:5px 8px;border-radius:4px;font-size:11px;font-family:inherit;' + 'color:#a1a1aa;background:rgba(24,24,27,0.85);border:1px solid rgba(63,63,70,0.6);' + 'pointer-events:none;max-width:80%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;' diff --git a/src/renderer/src/lib/pane-manager/pane-drag-pointer.ts b/src/renderer/src/lib/pane-manager/pane-drag-pointer.ts index 45d71f151e0..3be4db71d72 100644 --- a/src/renderer/src/lib/pane-manager/pane-drag-pointer.ts +++ b/src/renderer/src/lib/pane-manager/pane-drag-pointer.ts @@ -1,4 +1,4 @@ -import type { DropZone, ManagedPaneInternal } from './pane-manager-types' +import type { DropZone, ManagedPaneInternal, PaneExternalDropTarget } from './pane-manager-types' import type { DragReorderCallbacks, DragReorderState } from './pane-drag-reorder' import { handlePaneDrop, @@ -62,14 +62,18 @@ export function beginPaneDragFromPointerDown( callbacks.getRoot().classList.remove('is-pane-dragging') callbacks.getPanes().get(paneId)?.container.classList.remove('is-drag-source') try { - if (commitDrop && state.currentDropTarget && state.dragSourcePaneId !== null) { - handlePaneDrop( - state.dragSourcePaneId, - state.currentDropTarget.paneId, - state.currentDropTarget.zone, - state, - callbacks - ) + if (commitDrop && state.dragSourcePaneId !== null) { + if (state.currentDropTarget) { + handlePaneDrop( + state.dragSourcePaneId, + state.currentDropTarget.paneId, + state.currentDropTarget.zone, + state, + callbacks + ) + } else if (state.currentExternalDropTarget) { + callbacks.onExternalPaneDrop?.(state.dragSourcePaneId, state.currentExternalDropTarget) + } } } finally { // Why: pointer capture can be lost crossing Electron webviews; always @@ -78,6 +82,7 @@ export function beginPaneDragFromPointerDown( hideDropOverlay(state) state.dragSourcePaneId = null state.currentDropTarget = null + state.currentExternalDropTarget = null } } @@ -166,8 +171,20 @@ function updateDropTarget( } const targetPane = findDropTargetPane(clientX, clientY, state, callbacks) if (!targetPane) { - overlay.style.display = 'none' + const sourcePaneId = state.dragSourcePaneId + const externalTarget = + sourcePaneId === null + ? null + : (callbacks.resolveExternalDropTarget?.({ sourcePaneId, clientX, clientY }) ?? null) + if (!externalTarget) { + overlay.style.display = 'none' + state.currentDropTarget = null + state.currentExternalDropTarget = null + return + } state.currentDropTarget = null + state.currentExternalDropTarget = externalTarget + positionExternalDropOverlay(overlay, externalTarget) return } @@ -180,9 +197,11 @@ function updateDropTarget( ) { overlay.style.display = 'none' state.currentDropTarget = null + state.currentExternalDropTarget = null return } state.currentDropTarget = { paneId: targetPane.id, zone } + state.currentExternalDropTarget = null positionDropOverlay(overlay, rect, zone) } @@ -223,6 +242,7 @@ function resolveDropZone(clientX: number, clientY: number, rect: DOMRect): DropZ function positionDropOverlay(overlay: HTMLElement, rect: DOMRect, zone: DropZone): void { overlay.style.display = '' + overlay.dataset.paneDropOverlayKind = 'area' const scrollX = window.scrollX const scrollY = window.scrollY const halfWidth = rect.width / 2 @@ -233,3 +253,13 @@ function positionDropOverlay(overlay: HTMLElement, rect: DOMRect, zone: DropZone overlay.style.width = `${zone === 'left' || zone === 'right' ? halfWidth : rect.width}px` overlay.style.height = `${zone === 'top' || zone === 'bottom' ? halfHeight : rect.height}px` } + +function positionExternalDropOverlay(overlay: HTMLElement, target: PaneExternalDropTarget): void { + const rect = target.rect + overlay.style.display = '' + overlay.dataset.paneDropOverlayKind = target.overlayKind ?? 'area' + overlay.style.left = `${rect.left + window.scrollX}px` + overlay.style.top = `${rect.top + window.scrollY}px` + overlay.style.width = `${rect.width}px` + overlay.style.height = `${rect.height}px` +} diff --git a/src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts b/src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts index dd81b2063fe..1f5996526cd 100644 --- a/src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts @@ -229,6 +229,7 @@ describe('attachPaneDrag', () => { expect(appendedElements[0].removed).toBe(true) expect(state.dragSourcePaneId).toBeNull() expect(state.currentDropTarget).toBeNull() + expect(state.currentExternalDropTarget).toBeNull() expect(state.cleanupActiveDrag).toBeNull() expect(onDragActiveChange).toHaveBeenLastCalledWith(false) expect(detachPaneFromTree).not.toHaveBeenCalled() @@ -288,10 +289,83 @@ describe('attachPaneDrag', () => { expect(appendedElements[0]?.removed).toBe(true) expect(state.dragSourcePaneId).toBeNull() expect(state.currentDropTarget).toBeNull() + expect(state.currentExternalDropTarget).toBeNull() expect(state.cleanupActiveDrag).toBeNull() expect(onDragActiveChange).toHaveBeenLastCalledWith(false) }) + it('drops onto an external target when no pane target is under the pointer', () => { + const handle = new FakeElement() + const root = new FakeElement(['pane-manager-root']) + const sourcePane = createPane( + 1, + new FakeElement(['pane'], { + left: 0, + top: 80, + right: 100, + bottom: 180, + width: 100, + height: 100 + }) + ) + const siblingPane = createPane( + 2, + new FakeElement(['pane'], { + left: 120, + top: 80, + right: 220, + bottom: 180, + width: 100, + height: 100 + }) + ) + const panes = new Map([ + [sourcePane.id, sourcePane], + [siblingPane.id, siblingPane] + ]) + const externalTarget = { + id: 'group-1', + overlayKind: 'insertion' as const, + rect: { left: 0, top: 0, right: 300, bottom: 32, width: 300, height: 32 } as DOMRect + } + const onExternalPaneDrop = vi.fn(() => true) + const state = createDragReorderState() + + attachPaneDrag(handle as unknown as HTMLElement, sourcePane.id, state, { + getPanes: () => panes, + getRoot: () => root as unknown as HTMLElement, + getStyleOptions: () => ({}), + isDestroyed: () => false, + safeFit: vi.fn(), + applyPaneOpacity: vi.fn(), + applyDividerStyles: vi.fn(), + refitPanesUnder: vi.fn(), + resolveExternalDropTarget: ({ clientX, clientY }) => + clientX === 10 && clientY === 10 ? externalTarget : null, + onExternalPaneDrop + }) + + handle.dispatchPointer('pointerdown', pointerEvent({ clientX: 10, clientY: 90 })) + handle.dispatchPointer('pointermove', pointerEvent({ clientX: 10, clientY: 10 })) + + expect(state.currentDropTarget).toBeNull() + expect(state.currentExternalDropTarget).toBe(externalTarget) + expect(appendedElements[0].style).toMatchObject({ + left: '0px', + top: '0px', + width: '300px', + height: '32px' + }) + expect(appendedElements[0].dataset.paneDropOverlayKind).toBe('insertion') + + handle.dispatchPointer('pointerup', pointerEvent({ pointerId: 1 })) + + expect(onExternalPaneDrop).toHaveBeenCalledWith(sourcePane.id, externalTarget) + expect(detachPaneFromTree).not.toHaveBeenCalled() + expect(insertPaneNextTo).not.toHaveBeenCalled() + expect(state.currentExternalDropTarget).toBeNull() + }) + it('returns cleanup that removes handle listeners and cancels active drag capture', () => { const handle = new FakeElement() const root = new FakeElement(['pane-manager-root']) diff --git a/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts b/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts index 6b08dc4b48e..7c1803227c1 100644 --- a/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts +++ b/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts @@ -1,5 +1,12 @@ -import type { DropZone, ManagedPane, ManagedPaneInternal } from './pane-manager-types' -import type { PaneStyleOptions } from './pane-manager-types' +import type { + DropZone, + ManagedPane, + ManagedPaneInternal, + PaneExternalDropHandler, + PaneExternalDropResolver, + PaneExternalDropTarget, + PaneStyleOptions +} from './pane-manager-types' import { detachPaneFromTree, findPaneChildren, insertPaneNextTo } from './pane-tree-ops' // --------------------------------------------------------------------------- @@ -10,6 +17,7 @@ export type DragReorderState = { dragSourcePaneId: number | null dropOverlay: HTMLElement | null currentDropTarget: { paneId: number; zone: DropZone } | null + currentExternalDropTarget: PaneExternalDropTarget | null cleanupActiveDrag: ((commitDrop: boolean) => void) | null } @@ -25,6 +33,8 @@ export type DragReorderCallbacks = { requestPaneReparentFrame?: (callback: FrameRequestCallback) => void onLayoutChanged?: () => void onDragActiveChange?: (active: boolean) => void + resolveExternalDropTarget?: PaneExternalDropResolver + onExternalPaneDrop?: PaneExternalDropHandler } export function createDragReorderState(): DragReorderState { @@ -32,6 +42,7 @@ export function createDragReorderState(): DragReorderState { dragSourcePaneId: null, dropOverlay: null, currentDropTarget: null, + currentExternalDropTarget: null, cleanupActiveDrag: null } } @@ -44,6 +55,7 @@ export function cancelActivePaneDrag(state: DragReorderState): void { hideDropOverlay(state) state.dragSourcePaneId = null state.currentDropTarget = null + state.currentExternalDropTarget = null } /** True when dropping source onto target in zone would leave pane order unchanged. */ diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts index cb41bca09b8..16791a485b4 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts @@ -444,14 +444,32 @@ describe('openTerminal — Unicode 11 ordering', () => { } } - const fakeContainer = { + const fakePaneContainer = { appendChild: vi.fn(), addEventListener: vi.fn() } as unknown as HTMLDivElement + const fakeXtermContainer = { + appendChild: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn() + } as unknown as HTMLDivElement const fakeTooltip = {} as unknown as HTMLDivElement + const fakeTerminalElement = { + appendChild: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + querySelector: vi.fn(() => null), + classList: { contains: vi.fn(() => false) } + } as unknown as HTMLElement + vi.stubGlobal( + 'MutationObserver', + vi.fn(function MutationObserver() { + return { observe: vi.fn(), disconnect: vi.fn() } + }) + ) const terminal = { - element: null as HTMLElement | null, + element: fakeTerminalElement, textarea: null, cols: 80, rows: 24, @@ -485,8 +503,8 @@ describe('openTerminal — Unicode 11 ordering', () => { leafId, stablePaneId: leafId, terminal, - container: fakeContainer, - xtermContainer: fakeContainer, + container: fakePaneContainer, + xtermContainer: fakeXtermContainer, linkTooltip: fakeTooltip, terminalGpuAcceleration: 'off', gpuRenderingEnabled: false, @@ -507,19 +525,26 @@ describe('openTerminal — Unicode 11 ordering', () => { debugLabel: null } - openTerminal(pane) + try { + openTerminal(pane) - expect(events).toContain('loadAddon:unicode11') - expect(events).toContain('activeVersion=11') + expect(fakePaneContainer.appendChild).toHaveBeenCalledWith(fakeTooltip) + expect(fakeXtermContainer.appendChild).not.toHaveBeenCalled() + expect(fakeTerminalElement.appendChild).not.toHaveBeenCalled() + expect(events).toContain('loadAddon:unicode11') + expect(events).toContain('activeVersion=11') - const unicodeIdx = events.indexOf('activeVersion=11') - const writeIdx = events.indexOf('write') - if (writeIdx !== -1) { - expect(unicodeIdx).toBeLessThan(writeIdx) + const unicodeIdx = events.indexOf('activeVersion=11') + const writeIdx = events.indexOf('write') + if (writeIdx !== -1) { + expect(unicodeIdx).toBeLessThan(writeIdx) + } + + const loadUnicodeIdx = events.indexOf('loadAddon:unicode11') + expect(loadUnicodeIdx).toBeLessThan(unicodeIdx) + expect(events.indexOf('open')).toBeLessThan(loadUnicodeIdx) + } finally { + vi.unstubAllGlobals() } - - const loadUnicodeIdx = events.indexOf('loadAddon:unicode11') - expect(loadUnicodeIdx).toBeLessThan(unicodeIdx) - expect(events.indexOf('open')).toBeLessThan(loadUnicodeIdx) }) }) diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index eb296a1d675..c6727c3c1c1 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -12,11 +12,12 @@ import { detachPaneFitResizeObserver } from './pane-fit-resize-observer' import { clearPendingSplitScrollRestore } from './pane-split-scroll' -import { activateOrcaTerminalUnicodeProvider } from './pane-terminal-unicode-provider' +import { activateOrcaTerminalUnicodeProvider } from '../../../../shared/terminal-unicode-provider' import { attachTerminalMouseWheelMultiplier } from './pane-terminal-mouse-wheel' import { attachTerminalScrollIntentTracking } from './terminal-scroll-intent' import { attachDomRendererFocusClassSync } from './pane-dom-focus-class-sync' import { attachWebgl, cancelPendingWebglRefresh, disposeWebgl } from './pane-webgl-renderer' +import { resolveCursorAgentImeAnchor } from './terminal-ime-anchor' // --------------------------------------------------------------------------- // Pane creation, terminal open/close, addon management @@ -28,6 +29,7 @@ export { createPaneDOM } from './pane-dom-creation' export function openTerminal(pane: ManagedPaneInternal): void { const { terminal, + container, xtermContainer, linkTooltip, terminalTuiScrollSensitivity, @@ -40,8 +42,9 @@ export function openTerminal(pane: ManagedPaneInternal): void { // Open terminal into DOM terminal.open(xtermContainer) - const linkTooltipContainer = terminal.element ?? xtermContainer - linkTooltipContainer.appendChild(linkTooltip) + // Why: terminal.element sits under the padded xterm container. Pane-level + // placement keeps the hover URL on the true bottom-left window corner. + container.appendChild(linkTooltip) // Load addons (order matters: WebGL must be after open()) terminal.loadAddon(fitAddon) @@ -70,11 +73,10 @@ export function openTerminal(pane: ManagedPaneInternal): void { activateOrcaTerminalUnicodeProvider(terminal) // Why: the OS reads the focused textarea's screen rect at compositionstart to - // decide where to display the IME candidate window. xterm.js only repositions - // the textarea on compositionupdate (via updateCompositionElements), not on - // compositionstart, so the window can appear at a stale cursor position. We - // force-sync the textarea position in a capture-phase listener so the OS sees - // the correct location before it opens the candidate window. + // decide where to display the IME candidate window. xterm positions that + // textarea from its own cursor, which can be stale or intentionally hidden by + // TUIs. We force-sync after xterm's own composition handlers so the OS sees + // the corrected location before it opens the candidate window. // // Cell dimensions are derived from the public .xterm-screen element's bounds // (xterm sizes that element to cols*cellWidth × rows*cellHeight) rather than @@ -94,11 +96,34 @@ export function openTerminal(pane: ManagedPaneInternal): void { return } const buf = terminal.buffer.active - const x = Math.min(buf.cursorX, terminal.cols - 1) - textarea.style.top = `${buf.cursorY * cellHeight}px` - textarea.style.left = `${x * cellWidth}px` + // Why: Cursor Agent draws its prompt UI while leaving xterm's public cursor + // on a blank row, so the OS IME anchor needs the rendered prompt row instead. + const cursorAgentAnchor = resolveCursorAgentImeAnchor({ + buffer: buf, + rows: terminal.rows, + cols: terminal.cols, + cursorX: buf.cursorX, + cursorY: buf.cursorY + }) + const anchor = cursorAgentAnchor ?? { + row: buf.cursorY, + column: Math.min(buf.cursorX, terminal.cols - 1) + } + const applyAnchor = (): void => { + textarea.style.top = `${anchor.row * cellHeight}px` + textarea.style.left = `${anchor.column * cellWidth}px` + } + applyAnchor() + if (cursorAgentAnchor) { + window.setTimeout(() => { + if (textarea.isConnected) { + applyAnchor() + } + }, 0) + } } - terminal.element.addEventListener('compositionstart', handler, true) + terminal.element.addEventListener('compositionstart', handler) + terminal.element.addEventListener('compositionupdate', handler) // Store so disposePane() can remove it and avoid a memory leak. pane.compositionHandler = handler } @@ -200,7 +225,8 @@ export function disposePane( pane.terminalScrollIntentDisposable?.dispose() pane.terminalScrollIntentDisposable = null if (pane.compositionHandler) { - pane.terminal.element?.removeEventListener('compositionstart', pane.compositionHandler, true) + pane.terminal.element?.removeEventListener('compositionstart', pane.compositionHandler) + pane.terminal.element?.removeEventListener('compositionupdate', pane.compositionHandler) pane.compositionHandler = null } try { @@ -213,11 +239,7 @@ export function disposePane( } catch { /* ignore */ } - try { - pane.webglAddon?.dispose() - } catch { - /* ignore */ - } + disposeWebgl(pane) try { pane.searchAddon.dispose() } catch { diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts index e4e002a5351..1ab542bf787 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -27,8 +27,26 @@ export type PaneSpawnHints = { export type ClosedPaneInfo = { paneId: number leafId: TerminalLeafId + reason?: 'close' | 'detach' } +export type PaneExternalDropTarget = { + id: string + rect: DOMRect + overlayKind?: 'area' | 'insertion' +} + +export type PaneExternalDropResolver = (args: { + sourcePaneId: number + clientX: number + clientY: number +}) => PaneExternalDropTarget | null + +export type PaneExternalDropHandler = ( + sourcePaneId: number, + target: PaneExternalDropTarget +) => boolean + export type PaneManagerOptions = { onPaneCreated?: (pane: ManagedPane, spawnHints?: PaneSpawnHints) => void | Promise onPaneClosed?: (paneId: number, closedPane?: ClosedPaneInfo) => void @@ -37,6 +55,8 @@ export type PaneManagerOptions = { /** Why: Electron webviews can steal pointer streams from renderer-owned * pane drags unless callers temporarily put them in pointer passthrough. */ onPaneDragActiveChange?: (active: boolean) => void + resolveExternalPaneDropTarget?: PaneExternalDropResolver + onExternalPaneDrop?: PaneExternalDropHandler terminalOptions?: (paneId: number) => Partial terminalTuiScrollSensitivity?: () => number | undefined onLinkClick?: (event: MouseEvent | undefined, url: string) => void diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts index 4824af7cf57..77ad5398890 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager.ts @@ -5,7 +5,10 @@ import type { ManagedPane, ManagedPaneInternal, PaneRenderingDiagnostics, - DropZone + DropZone, + PaneExternalDropHandler, + PaneExternalDropResolver, + PaneExternalDropTarget } from './pane-manager-types' import type { SplitPaneAroundLeafIdsOptions } from './pane-subtree-split' import { @@ -38,12 +41,25 @@ import { } from './pane-rendering-control' import type { TerminalLeafId } from '../../../../shared/stable-pane-id' import { registerLivePaneManager, unregisterLivePaneManager } from './pane-manager-registry' +import { schedulePaneRevealRepaint } from './pane-reveal-repaint' import { PaneIdentityRegistry } from './pane-identity-registry' -import { closeManagedPane, splitManagedPane } from './pane-split-close' +import { + closeManagedPane, + detachManagedPaneForExternalMove, + splitManagedPane +} from './pane-split-close' import { FIRST_PANE_ID } from '../../../../shared/pane-key' import { splitPaneAroundMountedSubtree } from './pane-subtree-split' -export type { PaneManagerOptions, PaneStyleOptions, ManagedPane, DropZone } +export type { + PaneManagerOptions, + PaneStyleOptions, + ManagedPane, + DropZone, + PaneExternalDropTarget, + PaneExternalDropResolver, + PaneExternalDropHandler +} export class PaneManager { private root: HTMLElement @@ -157,6 +173,22 @@ export class PaneManager { }) } + detachPaneForExternalMove(paneId: number): boolean { + return detachManagedPaneForExternalMove({ + paneId, + activePaneId: this.activePaneId, + panes: this.panes, + root: this.root, + styleOptions: this.styleOptions, + managerOptions: this.options, + getDragCallbacks: () => this.getDragCallbacks(), + releasePaneIdentity: (numericPaneId) => this.identities.release(numericPaneId), + setActivePaneId: (id) => { + this.activePaneId = id + } + }) + } + getPanes(): ManagedPane[] { return Array.from(this.panes.values()).map(toPublicPane) } @@ -290,6 +322,13 @@ export class PaneManager { resetPaneWebglTextureAtlases(this.panes.values()) } + scheduleRevealRepaint(): void { + // Why: the settled-frame callback can fire after destroy(); repainting + // disposed panes could throw in attach and latch the global WebGL + // attach backoff, downgrading unrelated new panes to the DOM renderer. + schedulePaneRevealRepaint(() => (this.destroyed ? [] : this.panes.values())) + } + suspendRendering(): void { this.renderingSuspended = true suspendPaneRendering(this.panes.values()) @@ -396,7 +435,9 @@ export class PaneManager { this.requestPaneReparentFrame(callback) }, onLayoutChanged: this.options.onLayoutChanged, - onDragActiveChange: this.options.onPaneDragActiveChange + onDragActiveChange: this.options.onPaneDragActiveChange, + resolveExternalDropTarget: this.options.resolveExternalPaneDropTarget, + onExternalPaneDrop: this.options.onExternalPaneDrop } } diff --git a/src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts new file mode 100644 index 00000000000..564a57e9875 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts @@ -0,0 +1,147 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ManagedPaneInternal } from './pane-manager-types' +import { schedulePaneRevealRepaint } from './pane-reveal-repaint' +import { resetTerminalWebglSuggestion } from './pane-webgl-renderer' + +type FakeWebglAddon = { clearTextureAtlas: ReturnType } + +function createPane(options: { webglAddon?: FakeWebglAddon | null } = {}): ManagedPaneInternal { + const leafId = '33333333-3333-4333-8333-333333333333' as never + return { + id: 1, + leafId, + stablePaneId: leafId, + terminal: { + cols: 80, + rows: 24, + refresh: vi.fn(), + loadAddon: vi.fn() + } as never, + container: {} as never, + xtermContainer: {} as never, + linkTooltip: {} as never, + terminalGpuAcceleration: 'on', + gpuRenderingEnabled: true, + webglAttachmentDeferred: false, + webglDisabledAfterContextLoss: false, + hasComplexScriptOutput: false, + webglAddon: (options.webglAddon ?? null) as never, + ligaturesAddon: null, + fitResizeObserver: null, + pendingObservedFitRafId: null, + pendingWebglRefreshRafId: null, + fitAddon: { + proposeDimensions: vi.fn(() => ({ cols: 80, rows: 23 })), + fit: vi.fn() + } as never, + searchAddon: {} as never, + serializeAddon: {} as never, + unicode11Addon: {} as never, + webLinksAddon: {} as never, + compositionHandler: null, + pendingSplitScrollState: null, + debugLabel: null + } +} + +describe('schedulePaneRevealRepaint', () => { + let rafQueue: FrameRequestCallback[] + + function flushFrame(): void { + const queue = rafQueue + rafQueue = [] + for (const callback of queue) { + callback(16) + } + } + + beforeEach(() => { + resetTerminalWebglSuggestion() + rafQueue = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + rafQueue.push(callback) + return rafQueue.length + }) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('repaints only after the post-reveal frame has settled', () => { + const webglAddon = { clearTextureAtlas: vi.fn() } + const pane = createPane({ webglAddon }) + schedulePaneRevealRepaint(() => [pane]) + + // First frame: reveal layout may still be in flight; redraw requests fired + // here can be dropped by the renderer without retry. + flushFrame() + expect(webglAddon.clearTextureAtlas).not.toHaveBeenCalled() + expect(pane.terminal.refresh).not.toHaveBeenCalled() + + flushFrame() + expect(webglAddon.clearTextureAtlas).toHaveBeenCalledTimes(1) + expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23) + }) + + it('reattaches a missing WebGL addon before repainting', () => { + const pane = createPane() + schedulePaneRevealRepaint(() => [pane]) + + flushFrame() + flushFrame() + + expect(pane.webglAddon).not.toBeNull() + expect(pane.terminal.refresh).toHaveBeenCalled() + }) + + it('resolves the pane list at repaint time, not at scheduling time', () => { + const stalePane = createPane({ webglAddon: { clearTextureAtlas: vi.fn() } }) + const livePane = createPane({ webglAddon: { clearTextureAtlas: vi.fn() } }) + const panes = [stalePane] + schedulePaneRevealRepaint(() => panes) + panes.splice(0, panes.length, livePane) + + flushFrame() + flushFrame() + + expect( + (stalePane.webglAddon as never as FakeWebglAddon).clearTextureAtlas + ).not.toHaveBeenCalled() + expect( + (livePane.webglAddon as never as FakeWebglAddon).clearTextureAtlas + ).toHaveBeenCalledTimes(1) + }) + + it('keeps repainting remaining panes when one pane throws', () => { + const explosivePane = { + get gpuRenderingEnabled(): boolean { + throw new Error('pane torn down mid-frame') + } + } as never as ManagedPaneInternal + const webglAddon = { clearTextureAtlas: vi.fn() } + const livePane = createPane({ webglAddon }) + schedulePaneRevealRepaint(() => [explosivePane, livePane]) + + flushFrame() + flushFrame() + + expect(webglAddon.clearTextureAtlas).toHaveBeenCalledTimes(1) + expect(livePane.terminal.refresh).toHaveBeenCalled() + }) + + it('falls back to a timeout when animation frames are unavailable', () => { + vi.useFakeTimers() + vi.stubGlobal('requestAnimationFrame', undefined) + const webglAddon = { clearTextureAtlas: vi.fn() } + const pane = createPane({ webglAddon }) + + schedulePaneRevealRepaint(() => [pane]) + vi.runAllTimers() + + expect(webglAddon.clearTextureAtlas).toHaveBeenCalledTimes(1) + vi.useRealTimers() + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts new file mode 100644 index 00000000000..48df07b9b0c --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts @@ -0,0 +1,39 @@ +import type { ManagedPaneInternal } from './pane-manager-types' +import { reattachWebglIfNeeded } from './pane-webgl-reattach' +import { resetWebglTextureAtlas } from './pane-webgl-renderer' + +function scheduleSettledFrame(callback: () => void): void { + if (typeof globalThis.requestAnimationFrame !== 'function') { + globalThis.setTimeout(callback, 0) + return + } + // Why: the first frame after a reveal can still be laying out the tab + // overlay; the WebGL renderer silently drops redraw requests until the pane + // is attached and measured, so repaint on the frame after layout settles. + globalThis.requestAnimationFrame(() => { + globalThis.requestAnimationFrame(callback) + }) +} + +/** + * Repaints a revealed tab's panes from their xterm buffers. + * + * Why: while a pane is hidden, parsed output can update the WebGL renderer's + * per-cell model without ever presenting a frame. At reveal the model diff + * reports those cells unchanged, so plain refreshes skip them and the canvas + * keeps compositing pre-hide pixels until a selection or resize rebuilds the + * model. Clearing the model per pane — after (re)attach, once layout has + * settled — forces a full rebuild from the buffer without any PTY resize. + */ +export function schedulePaneRevealRepaint(getPanes: () => Iterable): void { + scheduleSettledFrame(() => { + for (const pane of getPanes()) { + try { + reattachWebglIfNeeded(pane) + resetWebglTextureAtlas(pane) + } catch { + /* ignore — one pane's failure must not block repaint of the rest */ + } + } + }) +} diff --git a/src/renderer/src/lib/pane-manager/pane-split-close.ts b/src/renderer/src/lib/pane-manager/pane-split-close.ts index 2d7c50a62f0..b7d8356e0a5 100644 --- a/src/renderer/src/lib/pane-manager/pane-split-close.ts +++ b/src/renderer/src/lib/pane-manager/pane-split-close.ts @@ -180,10 +180,39 @@ export function closeManagedPane(args: CloseManagedPaneArgs): void { safeFit(p) } updateMultiPaneState(args.getDragCallbacks()) - args.managerOptions.onPaneClosed?.(args.paneId, { paneId: args.paneId, leafId: closedLeafId }) + args.managerOptions.onPaneClosed?.(args.paneId, { + paneId: args.paneId, + leafId: closedLeafId, + reason: 'close' + }) args.managerOptions.onLayoutChanged?.() } +export function detachManagedPaneForExternalMove(args: CloseManagedPaneArgs): boolean { + const pane = args.panes.get(args.paneId) + if (!pane || args.panes.size <= 1) { + return false + } + const closedLeafId = pane.leafId + args.releasePaneIdentity(args.paneId) + removePaneContainer(args, pane) + const nextActivePaneId = activateReplacementPane(args) + applyPaneOpacity(args.panes.values(), nextActivePaneId, args.styleOptions) + for (const p of args.panes.values()) { + safeFit(p) + } + updateMultiPaneState(args.getDragCallbacks()) + // Why: pane-to-tab detach tears down only this renderer pane; the PTY is + // adopted by the new tab, so TerminalPane must skip process-close cleanup. + args.managerOptions.onPaneClosed?.(args.paneId, { + paneId: args.paneId, + leafId: closedLeafId, + reason: 'detach' + }) + args.managerOptions.onLayoutChanged?.() + return true +} + function removePaneContainer(args: CloseManagedPaneArgs, pane: ManagedPaneInternal): void { const paneContainer = pane.container const parent = paneContainer.parentElement diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts b/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts index 0d515ce8ec1..05190661d5d 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts @@ -2,7 +2,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER, attachTerminalMouseWheelMultiplier, + createTerminalTuiMouseWheelDistanceState, normalizeTerminalTuiMouseWheelMultiplier, + resolveTerminalTuiMouseWheelReportCount, shouldMultiplyTerminalMouseWheel } from './pane-terminal-mouse-wheel' @@ -88,6 +90,237 @@ describe('terminal mouse wheel multiplier', () => { expect(normalizeTerminalTuiMouseWheelMultiplier(20)).toBe(10) }) + it('keeps deliberate TUI wheel ticks precise at the one-report setting', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [0, 200, 400, 600].map(() => + resolveTerminalTuiMouseWheelReportCount( + { deltaY: 12, deltaMode: DOM_DELTA_PIXEL, wheelDeltaY: -120 }, + 1, + state, + { cellHeight: 16 } + ) + ) + + expect(reports).toEqual([1, 1, 1, 1]) + }) + + it('scales notched TUI wheel ticks by the configured multiplier', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [0, 50, 100, 150].map(() => + resolveTerminalTuiMouseWheelReportCount( + { deltaY: 12, deltaMode: DOM_DELTA_PIXEL, wheelDeltaY: -120 }, + 5, + state, + { cellHeight: 16 } + ) + ) + + expect(reports).toEqual([5, 5, 5, 5]) + }) + + it('keeps paced 1x TUI wheel ticks precise', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [0, 80, 160, 240].map((timeStamp) => + resolveTerminalTuiMouseWheelReportCount( + { deltaY: 12, deltaMode: DOM_DELTA_PIXEL, wheelDeltaY: -120, timeStamp }, + 1, + state, + { cellHeight: 16 } + ) + ) + + expect(reports).toEqual([1, 1, 1, 1]) + }) + + it('adds a burst boost for very fast 1x TUI wheel scrolling', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [0, 16, 32, 48, 64].map((timeStamp) => + resolveTerminalTuiMouseWheelReportCount( + { deltaY: 12, deltaMode: DOM_DELTA_PIXEL, wheelDeltaY: -120, timeStamp }, + 1, + state, + { cellHeight: 16 } + ) + ) + + expect(reports).toEqual([1, 1, 3, 3, 4]) + }) + + it('uses a hotter compressed wheel distance curve for larger TUI wheel movements', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [ + resolveTerminalTuiMouseWheelReportCount( + { deltaY: 16, deltaMode: DOM_DELTA_PIXEL, wheelDeltaY: -120 }, + 1, + state, + { cellHeight: 16 } + ), + resolveTerminalTuiMouseWheelReportCount( + { deltaY: 16 * 12, deltaMode: DOM_DELTA_PIXEL, wheelDeltaY: -120 * 12 }, + 1, + state, + { cellHeight: 16 } + ) + ] + + expect(reports).toEqual([1, 6]) + }) + + it('caps a single accelerated TUI wheel event before it becomes a huge jump', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = resolveTerminalTuiMouseWheelReportCount( + { deltaY: 16 * 200, deltaMode: DOM_DELTA_PIXEL, wheelDeltaY: -120 * 200 }, + 1, + state, + { cellHeight: 16 } + ) + + expect(reports).toBe(6) + }) + + it('lets aggressive repeated TUI wheel events exceed the single-event cap', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [0, 16, 32, 48, 64].map((timeStamp) => + resolveTerminalTuiMouseWheelReportCount( + { + deltaY: 16 * 200, + deltaMode: DOM_DELTA_PIXEL, + timeStamp, + wheelDeltaY: -120 * 200 + }, + 1, + state, + { cellHeight: 16 } + ) + ) + + expect(reports).toEqual([6, 6, 8, 8, 9]) + }) + + it('does not carry burst boost into a decaying momentum tail', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [ + [200, 0], + [200, 16], + [200, 32], + [80, 48], + [20, 64], + [5, 80] + ].map(([rows, timeStamp]) => + resolveTerminalTuiMouseWheelReportCount( + { + deltaY: 16 * rows, + deltaMode: DOM_DELTA_PIXEL, + timeStamp, + wheelDeltaY: -120 * rows + }, + 1, + state, + { cellHeight: 16 } + ) + ) + + expect(reports).toEqual([6, 6, 8, 6, 6, 4]) + }) + + it('retains fractional trackpad distance until it reaches a full row', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [4, 4, 4, 4].map((deltaY) => + resolveTerminalTuiMouseWheelReportCount({ deltaY, deltaMode: DOM_DELTA_PIXEL }, 1, state, { + cellHeight: 16 + }) + ) + + expect(reports).toEqual([0, 0, 0, 1]) + }) + + it('does not burst-boost rapid trackpad-like pixel deltas', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [0, 16, 32, 48].map((timeStamp) => + resolveTerminalTuiMouseWheelReportCount( + { deltaY: 4, deltaMode: DOM_DELTA_PIXEL, timeStamp }, + 1, + state, + { cellHeight: 16 } + ) + ) + + expect(reports).toEqual([0, 0, 0, 1]) + }) + + it('tracks a decaying trackpad-like momentum tail with linear row distance', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [16, 20, 24, 28, 20, 12, 6, 3].map((deltaY, index) => + resolveTerminalTuiMouseWheelReportCount( + { deltaY, deltaMode: DOM_DELTA_PIXEL, timeStamp: index * 16 }, + 1, + state, + { cellHeight: 16 } + ) + ) + + expect(reports).toEqual([1, 1, 1, 2, 1, 1, 0, 1]) + }) + + it('drops pending trackpad distance on each direction change', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [16, 20, 24, -16, -20, -24, -18, -10, 6, -4].map((deltaY, index) => + resolveTerminalTuiMouseWheelReportCount( + { deltaY, deltaMode: DOM_DELTA_PIXEL, timeStamp: index * 16 }, + 1, + state, + { cellHeight: 16 } + ) + ) + + expect(reports).toEqual([1, 1, 1, 1, 1, 1, 1, 1, 0, 0]) + }) + + it('emits the full linear distance for a fast trackpad-like flick event', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [16 * 12, 16 * 12, 16 * 12].map((deltaY, index) => + resolveTerminalTuiMouseWheelReportCount( + { deltaY, deltaMode: DOM_DELTA_PIXEL, timeStamp: index * 16 }, + 1, + state, + { cellHeight: 16 } + ) + ) + + expect(reports).toEqual([12, 12, 12]) + }) + + it('resets pending fractional distance when the user changes direction', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [ + resolveTerminalTuiMouseWheelReportCount({ deltaY: 4, deltaMode: DOM_DELTA_PIXEL }, 1, state, { + cellHeight: 16 + }), + resolveTerminalTuiMouseWheelReportCount( + { deltaY: -12, deltaMode: DOM_DELTA_PIXEL }, + 1, + state, + { cellHeight: 16 } + ) + ] + + expect(reports).toEqual([0, 0]) + }) + it('multiplies discrete wheel events when mouse reporting is active', () => { expect(shouldMultiplyTerminalMouseWheel(wheelEvent(), terminalElement())).toBe(true) }) @@ -96,7 +329,7 @@ describe('terminal mouse wheel multiplier', () => { expect(shouldMultiplyTerminalMouseWheel(wheelEvent(), terminalElement(false))).toBe(false) }) - it('leaves trackpad-like pixel scrolling one-to-one', () => { + it('handles trackpad-like TUI pixel scrolling while mouse reporting is active', () => { expect( shouldMultiplyTerminalMouseWheel( wheelEvent({ @@ -105,7 +338,7 @@ describe('terminal mouse wheel multiplier', () => { }), terminalElement() ) - ).toBe(false) + ).toBe(true) }) it('multiplies notched mouse wheel ticks even when Chromium exposes a small pixel delta', () => { @@ -159,7 +392,8 @@ describe('terminal mouse wheel multiplier', () => { attachCustomWheelEventHandler: (handler) => { handlers.push(handler) }, - element: target + element: target, + rows: 24 }, { getTuiMouseWheelMultiplier: () => 1 } ) @@ -183,4 +417,150 @@ describe('terminal mouse wheel multiplier', () => { expect(dispatched.map((entry) => entry.deltaY)).toEqual([1]) expect(shouldMultiplyTerminalMouseWheel(dispatched[0]!, target)).toBe(false) }) + + it('replays trackpad-like TUI pixel scrolling with responsive direction reversal', async () => { + vi.stubGlobal('WheelEvent', TestWheelEvent) + const handlers: ((event: WheelEvent) => boolean)[] = [] + const target = Object.assign(new EventTarget(), { + classList: { + contains: (className: string) => className === 'enable-mouse-events' + } + }) as unknown as EventTarget & HTMLElement + const dispatched: WheelEvent[] = [] + target.addEventListener('wheel', (event) => dispatched.push(event as WheelEvent)) + attachTerminalMouseWheelMultiplier( + { + attachCustomWheelEventHandler: (handler) => { + handlers.push(handler) + }, + element: target, + rows: 24 + }, + { getTuiMouseWheelMultiplier: () => 1 } + ) + + const events = [ + [4, 0], + [4, 16], + [4, 32], + [4, 48], + [-4, 64], + [-4, 80], + [-4, 96], + [-4, 112] + ].map(([deltaY, timeStamp]) => { + const event = new TestWheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaMode: DOM_DELTA_PIXEL, + deltaY + }) as WheelEvent + Object.defineProperty(event, 'timeStamp', { + configurable: true, + value: timeStamp + }) + return event + }) + + for (const event of events) { + expect(handlers[0]?.(event)).toBe(false) + await Promise.resolve() + } + + expect(dispatched.map((entry) => entry.deltaY)).toEqual([1, -1]) + }) + + it('replays a fast trackpad-like flick as one full synthetic report batch', async () => { + vi.stubGlobal('WheelEvent', TestWheelEvent) + const handlers: ((event: WheelEvent) => boolean)[] = [] + const target = Object.assign(new EventTarget(), { + classList: { + contains: (className: string) => className === 'enable-mouse-events' + } + }) as unknown as EventTarget & HTMLElement + const dispatched: WheelEvent[] = [] + target.addEventListener('wheel', (event) => dispatched.push(event as WheelEvent)) + attachTerminalMouseWheelMultiplier( + { + attachCustomWheelEventHandler: (handler) => { + handlers.push(handler) + }, + element: target, + rows: 24 + }, + { getTuiMouseWheelMultiplier: () => 1 } + ) + + const event = new TestWheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaMode: DOM_DELTA_PIXEL, + deltaY: 16 * 12 + }) as WheelEvent + + expect(handlers[0]?.(event)).toBe(false) + await Promise.resolve() + + expect(dispatched.map((entry) => entry.deltaY)).toEqual(Array(12).fill(1)) + }) + + it('drains resolved TUI wheel reports without a frame-rate cap', async () => { + vi.stubGlobal('WheelEvent', TestWheelEvent) + const handlers: ((event: WheelEvent) => boolean)[] = [] + const target = Object.assign(new EventTarget(), { + classList: { + contains: (className: string) => className === 'enable-mouse-events' + } + }) as unknown as EventTarget & HTMLElement + const dispatched: WheelEvent[] = [] + target.addEventListener('wheel', (event) => dispatched.push(event as WheelEvent)) + attachTerminalMouseWheelMultiplier( + { + attachCustomWheelEventHandler: (handler) => { + handlers.push(handler) + }, + element: target, + rows: 24 + }, + { + getTuiMouseWheelMultiplier: () => 3 + } + ) + const firstEvent = new TestWheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaMode: DOM_DELTA_PIXEL, + deltaY: 12 + }) as WheelEvent + Object.defineProperty(firstEvent, 'wheelDeltaY', { + configurable: true, + value: -120 + }) + Object.defineProperty(firstEvent, 'timeStamp', { + configurable: true, + value: 0 + }) + const secondEvent = new TestWheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaMode: DOM_DELTA_PIXEL, + deltaY: 12 + }) as WheelEvent + Object.defineProperty(secondEvent, 'wheelDeltaY', { + configurable: true, + value: -120 + }) + Object.defineProperty(secondEvent, 'timeStamp', { + configurable: true, + value: 50 + }) + + expect(handlers[0]?.(firstEvent)).toBe(false) + await Promise.resolve() + expect(dispatched).toHaveLength(3) + + expect(handlers[0]?.(secondEvent)).toBe(false) + await Promise.resolve() + expect(dispatched).toHaveLength(6) + }) }) diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.ts b/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.ts index b8654f7c3ce..f35c31efba7 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.ts @@ -1,17 +1,27 @@ import type { Terminal } from '@xterm/xterm' +import { + createTerminalTuiMouseWheelDistanceState, + normalizeTerminalTuiMouseWheelMultiplier, + resolveTerminalTuiMouseWheelReportCount, + resolveTerminalWheelDirection +} from './pane-terminal-tui-wheel-reports' +import type { TerminalTuiMouseWheelDistanceState } from './pane-terminal-tui-wheel-reports' + +export { + TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER, + TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MAX, + TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MIN, + createTerminalTuiMouseWheelDistanceState, + normalizeTerminalTuiMouseWheelMultiplier, + resolveTerminalTuiMouseWheelReportCount +} from './pane-terminal-tui-wheel-reports' +export type { TerminalTuiMouseWheelDistanceState } from './pane-terminal-tui-wheel-reports' const XTERM_MOUSE_REPORTING_CLASS = 'enable-mouse-events' const REPLAYED_WHEEL_EVENT_PROPERTY = '__orcaReplayedTerminalWheelEvent' -const DOM_DELTA_PIXEL = 0 const DOM_DELTA_LINE = 1 -const DISCRETE_PIXEL_WHEEL_DELTA_MIN = 50 -const LEGACY_MOUSE_WHEEL_DELTA_MIN = 100 -export const TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER = 1 -export const TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MIN = 1 -export const TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MAX = 10 - -type TerminalWheelTarget = Pick +type TerminalWheelTarget = Pick type TerminalMouseWheelMultiplierOptions = { getTuiMouseWheelMultiplier?: () => number | undefined @@ -21,9 +31,24 @@ type ReplayedWheelEvent = WheelEvent & { [REPLAYED_WHEEL_EVENT_PROPERTY]?: boolean } -type WheelEventWithLegacyDelta = WheelEvent & { - wheelDelta?: number - wheelDeltaY?: number +type TerminalTuiMouseWheelReplayState = { + distance: TerminalTuiMouseWheelDistanceState + drainScheduled: boolean + pendingDirection: -1 | 0 | 1 + pendingEvent: WheelEvent | null + pendingReports: number + pendingTarget: EventTarget | null +} + +function createTerminalTuiMouseWheelReplayState(): TerminalTuiMouseWheelReplayState { + return { + distance: createTerminalTuiMouseWheelDistanceState(), + drainScheduled: false, + pendingDirection: 0, + pendingEvent: null, + pendingReports: 0, + pendingTarget: null + } } function isReplayedWheelEvent(event: WheelEvent): boolean { @@ -37,30 +62,6 @@ function markReplayedWheelEvent(event: WheelEvent): void { }) } -function legacyVerticalWheelDelta(event: WheelEvent): number | null { - const wheelEvent = event as WheelEventWithLegacyDelta - if (typeof wheelEvent.wheelDeltaY === 'number' && Number.isFinite(wheelEvent.wheelDeltaY)) { - return wheelEvent.wheelDeltaY - } - if (typeof wheelEvent.wheelDelta === 'number' && Number.isFinite(wheelEvent.wheelDelta)) { - return wheelEvent.wheelDelta - } - return null -} - -function isDiscreteWheelEvent(event: WheelEvent): boolean { - if (event.deltaMode !== DOM_DELTA_PIXEL) { - return true - } - - if (Math.abs(event.deltaY) >= DISCRETE_PIXEL_WHEEL_DELTA_MIN) { - return true - } - - const legacyDelta = legacyVerticalWheelDelta(event) - return legacyDelta !== null && Math.abs(legacyDelta) >= LEGACY_MOUSE_WHEEL_DELTA_MIN -} - function cloneWheelReportEvent(event: WheelEvent): WheelEvent { const clone = new WheelEvent(event.type, { bubbles: event.bubbles, @@ -88,6 +89,18 @@ function cloneWheelReportEvent(event: WheelEvent): WheelEvent { return clone } +function resolveTerminalWheelCellHeight(terminal: TerminalWheelTarget): number | undefined { + if (typeof terminal.element?.querySelector !== 'function') { + return undefined + } + const screen = terminal.element?.querySelector('.xterm-screen') + const rect = screen?.getBoundingClientRect() + if (!rect || rect.height <= 0 || terminal.rows <= 0) { + return undefined + } + return rect.height / terminal.rows +} + export function shouldMultiplyTerminalMouseWheel( event: WheelEvent, terminalElement: HTMLElement | null | undefined @@ -96,8 +109,7 @@ export function shouldMultiplyTerminalMouseWheel( isReplayedWheelEvent(event) || !terminalElement?.classList.contains(XTERM_MOUSE_REPORTING_CLASS) || event.deltaY === 0 || - event.shiftKey || - !isDiscreteWheelEvent(event) + event.shiftKey ) { return false } @@ -105,22 +117,62 @@ export function shouldMultiplyTerminalMouseWheel( return true } -export function normalizeTerminalTuiMouseWheelMultiplier(value: number | undefined): number { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER +function drainTerminalTuiWheelReports(state: TerminalTuiMouseWheelReplayState): void { + const target = state.pendingTarget + const event = state.pendingEvent + if (!target || !event || state.pendingReports <= 0) { + state.drainScheduled = false + return } - return Math.round( - Math.min( - TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MAX, - Math.max(TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MIN, value) - ) - ) + + const reportsToDispatch = state.pendingReports + for (let i = 0; i < reportsToDispatch; i += 1) { + target.dispatchEvent(cloneWheelReportEvent(event)) + } + state.pendingReports = 0 + state.drainScheduled = false + state.pendingDirection = 0 + state.pendingEvent = null + state.pendingTarget = null +} + +function queueTerminalTuiWheelReports( + state: TerminalTuiMouseWheelReplayState, + target: EventTarget, + event: WheelEvent, + reportCount: number +): void { + if (reportCount <= 0) { + return + } + + const direction = resolveTerminalWheelDirection(event) + if (state.pendingDirection !== 0 && state.pendingDirection !== direction) { + state.pendingReports = 0 + } + + state.pendingDirection = direction + state.pendingEvent = event + state.pendingTarget = target + state.pendingReports += reportCount + + if (state.drainScheduled) { + return + } + + state.drainScheduled = true + // Why: dispatch after xterm returns from the original wheel handler, but do + // not frame-cap reports; fullscreen TUIs need the full wheel distance. + queueMicrotask(() => { + drainTerminalTuiWheelReports(state) + }) } export function attachTerminalMouseWheelMultiplier( terminal: TerminalWheelTarget, options: TerminalMouseWheelMultiplierOptions = {} ): void { + const replayState = createTerminalTuiMouseWheelReplayState() terminal.attachCustomWheelEventHandler((event) => { if (!shouldMultiplyTerminalMouseWheel(event, terminal.element)) { return true @@ -133,15 +185,17 @@ export function attachTerminalMouseWheelMultiplier( } // Why: xterm dampens small pixel deltas before emitting mouse reports; - // line-mode replays make each notched wheel tick produce immediate reports. - queueMicrotask(() => { - const multiplier = normalizeTerminalTuiMouseWheelMultiplier( - options.getTuiMouseWheelMultiplier?.() - ) - for (let i = 0; i < multiplier; i++) { - target.dispatchEvent(cloneWheelReportEvent(event)) + // line-mode replays let fullscreen TUIs receive one report per resolved row. + const reportCount = resolveTerminalTuiMouseWheelReportCount( + event, + normalizeTerminalTuiMouseWheelMultiplier(options.getTuiMouseWheelMultiplier?.()), + replayState.distance, + { + cellHeight: resolveTerminalWheelCellHeight(terminal), + rows: terminal.rows } - }) + ) + queueTerminalTuiWheelReports(replayState, target, event, reportCount) return false }) diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-options.ts b/src/renderer/src/lib/pane-manager/pane-terminal-options.ts index 89bbaf4b118..911d3fa7545 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-options.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-options.ts @@ -59,7 +59,7 @@ export function buildDefaultTerminalOptions(): ITerminalOptions { // this as a gutter, costing ~1 column per pane — accepted tradeoff so the // scrollbar never covers content (evidence in PR #5051). The v1.4.51 // table corruption #4877 fixed by zeroing this was actually the ZWJ - // width bug; it stays fixed by pane-terminal-unicode-provider.ts. Width + // width bug; it stays fixed by shared/terminal-unicode-provider.ts. Width // also enables the overview ruler, whose border is hidden in // composeActiveTerminalTheme. width: 7 diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts index 2e107d9b218..bca02b5fcc5 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts @@ -898,10 +898,66 @@ describe('pane terminal output scheduler', () => { expect(terminal.write).not.toHaveBeenCalled() vi.advanceTimersByTime(0) - expect(terminal.write).toHaveBeenCalledTimes(16) + expect(terminal.write).toHaveBeenCalledTimes(2) - vi.advanceTimersByTime(1) - expect(terminal.write).toHaveBeenCalledTimes(32) + vi.advanceTimersByTime(4) + expect(terminal.write).toHaveBeenCalledTimes(4) + }) + + it('yields high-priority backlog drains when writes spend the frame budget', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + const chunk = 'x'.repeat(16 * 1024) + let now = 0 + const nowSpy = vi.spyOn(performance, 'now').mockImplementation(() => now) + terminal.write.mockImplementation((_data: string, callback?: () => void) => { + now += 9 + callback?.() + }) + + try { + for (let i = 0; i < 64; i++) { + writeTerminalOutput(terminal, chunk, { foreground: false }) + } + + vi.advanceTimersByTime(0) + expect(terminal.write).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(4) + expect(terminal.write).toHaveBeenCalledTimes(2) + } finally { + nowSpy.mockRestore() + } + }) + + it('uses Date.now for drain budgeting when performance is unavailable', async () => { + vi.useFakeTimers() + vi.stubGlobal('performance', undefined) + const { writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + const chunk = 'x'.repeat(16 * 1024) + let now = 0 + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now) + terminal.write.mockImplementation((_data: string, callback?: () => void) => { + now += 9 + callback?.() + }) + + try { + for (let i = 0; i < 64; i++) { + writeTerminalOutput(terminal, chunk, { foreground: false }) + } + + vi.advanceTimersByTime(0) + expect(terminal.write).toHaveBeenCalledTimes(1) + expect(nowSpy).toHaveBeenCalled() + + vi.advanceTimersByTime(4) + expect(terminal.write).toHaveBeenCalledTimes(2) + } finally { + nowSpy.mockRestore() + } }) it('caps hidden backlog memory and writes a warning instead of retaining all output', async () => { diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts index c43fc8cf161..20a3b8138ca 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts @@ -68,10 +68,11 @@ type QueueEntry = { const BACKGROUND_FLUSH_DELAY_MS = 50 const BACKGROUND_DRAIN_INTERVAL_MS = 16 -const HIGH_PRIORITY_DRAIN_INTERVAL_MS = 1 +const HIGH_PRIORITY_DRAIN_INTERVAL_MS = 4 const BACKGROUND_CHUNK_CHARS = 16 * 1024 const MAX_WRITES_PER_DRAIN = 2 -const HIGH_PRIORITY_MAX_WRITES_PER_DRAIN = 16 +const HIGH_PRIORITY_MAX_WRITES_PER_DRAIN = 2 +const DRAIN_TIME_BUDGET_MS = 8 const LARGE_BACKLOG_CHARS = 512 * 1024 const SYNC_FOREGROUND_FLUSH_CHARS = 256 * 1024 const MAX_BACKGROUND_QUEUE_CHARS = 2 * 1024 * 1024 @@ -728,10 +729,18 @@ function writeQueuedChunk(entry: QueueEntry): 'foreground' | 'background' | null return queuedWrite.foreground ? 'foreground' : 'background' } +function getDrainNow(): number { + if (typeof performance !== 'undefined') { + return performance.now() + } + return Date.now() +} + function drainQueuedOutput(): void { drainTimer = null drainTimerDelayMs = null let writes = 0 + const startedAt = getDrainNow() const maxWrites = hasHighPriorityBacklog() ? HIGH_PRIORITY_MAX_WRITES_PER_DRAIN : MAX_WRITES_PER_DRAIN @@ -760,6 +769,11 @@ function drainQueuedOutput(): void { clearForegroundCoalesce(entry) clearForegroundHoldSafety(entry) } + // Why: xterm parsing and DOM work share the renderer thread with input. + // Keep backlog draining cooperative so WSL/agent output cannot pin the UI. + if (writes > 0 && getDrainNow() - startedAt >= DRAIN_TIME_BUDGET_MS) { + break + } } if (debugEnabled && writes > 0) { diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-tui-wheel-reports.ts b/src/renderer/src/lib/pane-manager/pane-terminal-tui-wheel-reports.ts new file mode 100644 index 00000000000..8736dc6504e --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-terminal-tui-wheel-reports.ts @@ -0,0 +1,256 @@ +const DOM_DELTA_PIXEL = 0 +const DOM_DELTA_LINE = 1 +const DOM_DELTA_PAGE = 2 +const DISCRETE_PIXEL_WHEEL_DELTA_MIN = 50 +const LEGACY_MOUSE_WHEEL_DELTA_MIN = 100 +const LEGACY_MOUSE_WHEEL_DELTA_UNIT = 120 +const DEFAULT_TERMINAL_CELL_HEIGHT = 16 +const TUI_WHEEL_ACCELERATED_DISTANCE_GAIN = 1.6 +const TUI_WHEEL_BURST_FULL_INTERVAL_MS = 16 +const TUI_WHEEL_BURST_MAX_INTERVAL_MS = 45 +const TUI_WHEEL_BURST_MAX_BONUS_ROWS = 3 +const TUI_WHEEL_BURST_RAMP_EVENTS = 4 +const TUI_WHEEL_MOMENTUM_TAIL_DECAY_RATIO = 0.85 +const TUI_WHEEL_COMPRESSED_MAX_DISTANCE_ROWS_PER_EVENT = 6 +const TUI_WHEEL_BURST_MAX_DISTANCE_ROWS_PER_EVENT = 9 + +export const TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER = 1 +export const TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MIN = 1 +export const TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MAX = 10 + +type WheelEventWithLegacyDelta = WheelEvent & { + wheelDelta?: number + wheelDeltaY?: number +} + +type TerminalTuiWheelEventInput = Pick & + Partial> & { + wheelDelta?: number + wheelDeltaY?: number + } + +type TerminalTuiMouseWheelMetrics = { + cellHeight?: number + rows?: number +} + +export type TerminalTuiMouseWheelDistanceState = { + fastStreak: number + lastDistanceRows: number | null + lastInputAt: number | null + pendingDirection: -1 | 0 | 1 + pendingRows: number +} + +export function createTerminalTuiMouseWheelDistanceState(): TerminalTuiMouseWheelDistanceState { + return { + fastStreak: 0, + lastDistanceRows: null, + lastInputAt: null, + pendingDirection: 0, + pendingRows: 0 + } +} + +export function resolveTerminalWheelDirection(event: Pick): -1 | 1 { + return event.deltaY < 0 ? -1 : 1 +} + +function legacyVerticalWheelDelta(event: TerminalTuiWheelEventInput): number | null { + const wheelEvent = event as WheelEventWithLegacyDelta + if (typeof wheelEvent.wheelDeltaY === 'number' && Number.isFinite(wheelEvent.wheelDeltaY)) { + return wheelEvent.wheelDeltaY + } + if (typeof wheelEvent.wheelDelta === 'number' && Number.isFinite(wheelEvent.wheelDelta)) { + return wheelEvent.wheelDelta + } + return null +} + +function hasDiscreteLegacyWheelDelta(event: TerminalTuiWheelEventInput): boolean { + const legacyDelta = legacyVerticalWheelDelta(event) + return legacyDelta !== null && Math.abs(legacyDelta) >= LEGACY_MOUSE_WHEEL_DELTA_MIN +} + +export function isDiscreteTerminalTuiWheelEvent(event: TerminalTuiWheelEventInput): boolean { + if ((event.deltaMode ?? DOM_DELTA_PIXEL) !== DOM_DELTA_PIXEL) { + return true + } + + if (Math.abs(event.deltaY) >= DISCRETE_PIXEL_WHEEL_DELTA_MIN) { + return true + } + + return hasDiscreteLegacyWheelDelta(event) +} + +function canBurstBoostWheelEvent(event: TerminalTuiWheelEventInput): boolean { + if ((event.deltaMode ?? DOM_DELTA_PIXEL) !== DOM_DELTA_PIXEL) { + return true + } + + return hasDiscreteLegacyWheelDelta(event) +} + +function isTrackpadLikePixelWheelEvent(event: TerminalTuiWheelEventInput): boolean { + return ( + (event.deltaMode ?? DOM_DELTA_PIXEL) === DOM_DELTA_PIXEL && !hasDiscreteLegacyWheelDelta(event) + ) +} + +function wheelInputTime(event: TerminalTuiWheelEventInput): number | null { + if (typeof event.timeStamp === 'number' && Number.isFinite(event.timeStamp)) { + return event.timeStamp + } + return null +} + +function normalizeCellHeight(cellHeight: number | undefined): number { + if (typeof cellHeight === 'number' && Number.isFinite(cellHeight) && cellHeight > 0) { + return cellHeight + } + return DEFAULT_TERMINAL_CELL_HEIGHT +} + +function resolveWheelDistanceRows( + event: TerminalTuiWheelEventInput, + metrics: TerminalTuiMouseWheelMetrics +): number { + const deltaMode = event.deltaMode ?? DOM_DELTA_PIXEL + const deltaY = Math.abs(event.deltaY) + const rowsFromDelta = + deltaMode === DOM_DELTA_LINE + ? deltaY + : deltaMode === DOM_DELTA_PAGE + ? deltaY * Math.max(1, metrics.rows ?? 1) + : deltaY / normalizeCellHeight(metrics.cellHeight) + const legacyDelta = legacyVerticalWheelDelta(event) + const rowsFromLegacy = + legacyDelta === null ? 0 : Math.abs(legacyDelta) / LEGACY_MOUSE_WHEEL_DELTA_UNIT + const rows = Math.max(rowsFromDelta, rowsFromLegacy) + + return isDiscreteTerminalTuiWheelEvent(event) ? Math.max(1, rows) : rows +} + +function compressWheelDistanceRows(rows: number): number { + if (rows <= 1) { + return rows + } + + return Math.min( + TUI_WHEEL_COMPRESSED_MAX_DISTANCE_ROWS_PER_EVENT, + 1 + Math.log2(rows) * TUI_WHEEL_ACCELERATED_DISTANCE_GAIN + ) +} + +function resolveBurstWheelDistanceRows( + event: TerminalTuiWheelEventInput, + state: TerminalTuiMouseWheelDistanceState, + distanceRows: number +): number { + if (!canBurstBoostWheelEvent(event)) { + state.fastStreak = 0 + state.lastDistanceRows = null + state.lastInputAt = null + return 0 + } + + const currentInputAt = wheelInputTime(event) + if (currentInputAt === null) { + state.fastStreak = 0 + state.lastDistanceRows = null + state.lastInputAt = null + return 0 + } + + const elapsedMs = state.lastInputAt === null ? null : currentInputAt - state.lastInputAt + const isMomentumTail = + state.lastDistanceRows !== null && + distanceRows < state.lastDistanceRows * TUI_WHEEL_MOMENTUM_TAIL_DECAY_RATIO + state.lastDistanceRows = distanceRows + state.lastInputAt = currentInputAt + + if ( + isMomentumTail || + elapsedMs === null || + elapsedMs < 0 || + elapsedMs > TUI_WHEEL_BURST_MAX_INTERVAL_MS + ) { + state.fastStreak = 0 + return 0 + } + + const cadence = + elapsedMs <= TUI_WHEEL_BURST_FULL_INTERVAL_MS + ? 1 + : (TUI_WHEEL_BURST_MAX_INTERVAL_MS - elapsedMs) / + (TUI_WHEEL_BURST_MAX_INTERVAL_MS - TUI_WHEEL_BURST_FULL_INTERVAL_MS) + state.fastStreak = Math.min(TUI_WHEEL_BURST_RAMP_EVENTS, state.fastStreak + 1) + + return TUI_WHEEL_BURST_MAX_BONUS_ROWS * cadence * (state.fastStreak / TUI_WHEEL_BURST_RAMP_EVENTS) +} + +function resolveTrackpadPixelWheelReportCount( + event: TerminalTuiWheelEventInput, + state: TerminalTuiMouseWheelDistanceState, + distanceRows: number +): number | null { + if (!isTrackpadLikePixelWheelEvent(event)) { + return null + } + + // Why: trackpad pixel streams map 1:1 to physical distance: one report per + // terminal row scrolled, fractional remainder carried. No per-event cap and + // no momentum-tail suppression: the input write queue batches whatever a + // busy frame accumulates into a single PTY write, so the TUI applies it at + // once instead of replaying it, and inertial scrolling stays real-time. + const totalRows = state.pendingRows + distanceRows + const reports = Math.trunc(totalRows) + state.pendingRows = totalRows - reports + return reports +} + +export function normalizeTerminalTuiMouseWheelMultiplier(value: number | undefined): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER + } + return Math.round( + Math.min( + TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MAX, + Math.max(TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MIN, value) + ) + ) +} + +export function resolveTerminalTuiMouseWheelReportCount( + event: TerminalTuiWheelEventInput, + multiplier: number, + state: TerminalTuiMouseWheelDistanceState, + metrics: TerminalTuiMouseWheelMetrics = {} +): number { + const direction = resolveTerminalWheelDirection(event) + if (state.pendingDirection !== 0 && state.pendingDirection !== direction) { + state.fastStreak = 0 + state.lastDistanceRows = null + state.lastInputAt = null + state.pendingRows = 0 + } + state.pendingDirection = direction + + const distanceRows = resolveWheelDistanceRows(event, metrics) + const trackpadReportCount = resolveTrackpadPixelWheelReportCount(event, state, distanceRows) + if (trackpadReportCount !== null) { + return trackpadReportCount + } + + const rows = + Math.min( + TUI_WHEEL_BURST_MAX_DISTANCE_ROWS_PER_EVENT, + compressWheelDistanceRows(distanceRows) + + resolveBurstWheelDistanceRows(event, state, distanceRows) + ) * normalizeTerminalTuiMouseWheelMultiplier(multiplier) + const totalRows = state.pendingRows + rows + const reports = Math.trunc(totalRows) + state.pendingRows = totalRows - reports + return reports +} diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts index 6cff7287564..91b95f2caf2 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { ManagedPaneInternal } from './pane-manager-types' import { disposePane } from './pane-lifecycle' +import { suspendPaneRendering } from './pane-rendering-control' import { disposeWebgl } from './pane-webgl-renderer' function createPane( @@ -65,6 +66,41 @@ describe('pane WebGL refresh lifecycle', () => { expect(pane.pendingWebglRefreshRafId).toBe(29) }) + it('actively releases the xterm WebGL context before disposing the addon', () => { + const loseContext = vi.fn() + const canvas = { width: 120, height: 40 } + const dispose = vi.fn() + const pane = createPane({ + webglAddon: { + dispose, + _renderer: { + _gl: { + getExtension: vi.fn(() => ({ loseContext })) + }, + _canvas: canvas + } + } as never + }) + + disposeWebgl(pane) + + expect(loseContext).toHaveBeenCalledTimes(1) + expect(dispose).toHaveBeenCalledTimes(1) + expect(canvas).toEqual({ width: 0, height: 0 }) + expect(pane.webglAddon).toBeNull() + }) + + it('disposes WebGL when rendering is suspended', () => { + const dispose = vi.fn() + const pane = createPane({ webglAddon: { dispose } as never }) + + suspendPaneRendering([pane]) + + expect(pane.webglAttachmentDeferred).toBe(true) + expect(dispose).toHaveBeenCalledTimes(1) + expect(pane.webglAddon).toBeNull() + }) + it('cancels a pending WebGL refresh when the pane is disposed', () => { const cancelAnimationFrame = vi.fn() vi.stubGlobal('cancelAnimationFrame', cancelAnimationFrame) diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts new file mode 100644 index 00000000000..26919ed4d2d --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WebglAddon } from '@xterm/addon-webgl' +import type { ManagedPaneInternal } from './pane-manager-types' +import { + attachWebgl, + resetTerminalWebglSuggestion, + resetWebglTextureAtlas +} from './pane-webgl-renderer' + +function createPane(options: { loadAddon?: () => void } = {}): ManagedPaneInternal { + const leafId = '22222222-2222-4222-8222-222222222222' as never + return { + id: 1, + leafId, + stablePaneId: leafId, + terminal: { + cols: 80, + rows: 24, + refresh: vi.fn(), + loadAddon: vi.fn(options.loadAddon) + } as never, + container: {} as never, + xtermContainer: {} as never, + linkTooltip: {} as never, + terminalGpuAcceleration: 'on', + gpuRenderingEnabled: true, + webglAttachmentDeferred: false, + webglDisabledAfterContextLoss: false, + hasComplexScriptOutput: false, + webglAddon: null, + ligaturesAddon: null, + fitResizeObserver: null, + pendingObservedFitRafId: null, + pendingWebglRefreshRafId: null, + fitAddon: { + proposeDimensions: vi.fn(() => ({ cols: 80, rows: 23 })), + fit: vi.fn() + } as never, + searchAddon: {} as never, + serializeAddon: {} as never, + unicode11Addon: {} as never, + webLinksAddon: {} as never, + compositionHandler: null, + pendingSplitScrollState: null, + debugLabel: null + } +} + +describe('terminal WebGL addon lifecycle', () => { + beforeEach(() => { + resetTerminalWebglSuggestion() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(16) + return 1 + }) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('disposes a live addon when attach bails instead of orphaning it', () => { + const pane = createPane() + attachWebgl(pane) + const liveAddon = pane.webglAddon + expect(liveAddon).not.toBeNull() + const disposeSpy = vi.spyOn(liveAddon as WebglAddon, 'dispose') + + // An undisposed addon here kept painting stale frames while atlas resets, + // reattach checks, and diagnostics all treated the pane as DOM-rendered. + pane.webglAttachmentDeferred = true + attachWebgl(pane) + + expect(disposeSpy).toHaveBeenCalledTimes(1) + expect(pane.webglAddon).toBeNull() + }) + + it('disposes the previous addon before attaching a replacement', () => { + const pane = createPane() + attachWebgl(pane) + const firstAddon = pane.webglAddon + expect(firstAddon).not.toBeNull() + const disposeSpy = vi.spyOn(firstAddon as WebglAddon, 'dispose') + + attachWebgl(pane) + + expect(disposeSpy).toHaveBeenCalledTimes(1) + expect(pane.webglAddon).not.toBeNull() + expect(pane.webglAddon).not.toBe(firstAddon) + }) + + it('disposes the constructed addon when loading it fails', () => { + const disposeSpy = vi.spyOn(WebglAddon.prototype, 'dispose') + const pane = createPane({ + loadAddon: () => { + throw new Error('WebGL2 not supported null') + } + }) + + attachWebgl(pane) + + expect(pane.webglAddon).toBeNull() + expect(disposeSpy).toHaveBeenCalledTimes(1) + }) + + it('still refreshes the terminal when resetting a pane without a WebGL addon', () => { + const pane = createPane() + expect(pane.webglAddon).toBeNull() + + resetWebglTextureAtlas(pane) + + expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23) + }) + + it('skips the reset while WebGL is latched off after a context loss', () => { + const pane = createPane() + pane.webglDisabledAfterContextLoss = true + + resetWebglTextureAtlas(pane) + + expect(pane.terminal.refresh).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts index b84ba23590b..d7c80e76220 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts @@ -14,6 +14,17 @@ let suggestedRendererType: 'dom' | undefined // until the next recovery boundary (rendering resume or GPU-setting change). let webglAttachFailedSinceRecovery = false +type ReleasableWebglContext = { + getExtension(name: 'WEBGL_lose_context'): WEBGL_lose_context | null +} + +type XtermWebglAddonInternals = { + _renderer?: { + _gl?: ReleasableWebglContext + _canvas?: HTMLCanvasElement + } +} + export function resetTerminalWebglSuggestion(): void { // Why: toggling GPU settings should let "auto" retry WebGL after an earlier // attach failure suggested DOM rendering for this app session. @@ -47,10 +58,13 @@ function refreshTerminalAfterWebglAttach(pane: ManagedPaneInternal): void { } export function cancelPendingWebglRefresh(pane: ManagedPaneInternal): void { - if (pane.pendingWebglRefreshRafId != null) { - cancelAnimationFrame(pane.pendingWebglRefreshRafId) - pane.pendingWebglRefreshRafId = null + if (pane.pendingWebglRefreshRafId == null) { + return } + if (typeof globalThis.cancelAnimationFrame === 'function') { + globalThis.cancelAnimationFrame(pane.pendingWebglRefreshRafId) + } + pane.pendingWebglRefreshRafId = null } export function disposeWebgl( @@ -61,6 +75,7 @@ export function disposeWebgl( if (!pane.webglAddon) { return } + releaseXtermWebglContext(pane.webglAddon) try { pane.webglAddon.dispose() } catch { @@ -82,19 +97,37 @@ export function disposeWebgl( } } +function releaseXtermWebglContext(webglAddon: ManagedPaneInternal['webglAddon']): void { + try { + // Why: xterm removes the canvas on dispose, but Windows/ANGLE can keep the + // driver context alive long enough for rapid terminal activation to hit + // Chromium's active WebGL context budget (#6874). + const renderer = (webglAddon as unknown as XtermWebglAddonInternals | null)?._renderer + renderer?._gl?.getExtension('WEBGL_lose_context')?.loseContext() + if (renderer?._canvas) { + renderer._canvas.width = 0 + renderer._canvas.height = 0 + } + } catch { + /* ignore - WebGL teardown must not block fallback to the DOM renderer */ + } +} + export function markComplexScriptOutput(pane: ManagedPaneInternal): void { pane.hasComplexScriptOutput = true } export function resetWebglTextureAtlas(pane: ManagedPaneInternal): void { - if (!pane.webglAddon || pane.webglDisabledAfterContextLoss) { + if (pane.webglDisabledAfterContextLoss) { return } try { // Why: rapid TUI redraws can corrupt xterm's WebGL glyph atlas without a // context-loss event. Clearing the atlas preserves GPU rendering and forces // a fresh paint when the pane becomes visible/focused again. - pane.webglAddon.clearTextureAtlas() + pane.webglAddon?.clearTextureAtlas() + // Why: refresh even without a WebGL addon so recovery never silently + // no-ops — a DOM-rendered pane can hold stale pixels after reveal too. pane.terminal.refresh(0, pane.terminal.rows - 1) } catch { /* ignore — pane may have been disposed in the meantime */ @@ -110,12 +143,20 @@ export function attachWebgl(pane: ManagedPaneInternal): void { pane.webglDisabledAfterContextLoss || webglAttachFailedSinceRecovery ) { - pane.webglAddon = null + // Why: nulling the reference here used to leak a still-loaded addon that + // kept painting stale frames while every recovery path (atlas reset, + // reattach, diagnostics) treated the pane as DOM-rendered. Dispose so the + // pane genuinely falls back to the DOM renderer. + disposeWebgl(pane, { refreshDimensions: true }) return } + // Single-addon invariant: never stack a second addon on a live one. + disposeWebgl(pane) + let webglAddon: WebglAddon | null = null try { - const webglAddon = new WebglAddon() - webglAddon.onContextLoss(() => { + webglAddon = new WebglAddon() + const addon = webglAddon + addon.onContextLoss(() => { console.warn( '[terminal] WebGL context lost for pane', pane.id, @@ -128,8 +169,8 @@ export function attachWebgl(pane: ManagedPaneInternal): void { pane.webglDisabledAfterContextLoss = true disposeWebgl(pane, { refreshDimensions: true }) }) - pane.terminal.loadAddon(webglAddon) - pane.webglAddon = webglAddon + pane.terminal.loadAddon(addon) + pane.webglAddon = addon refreshTerminalAfterWebglAttach(pane) } catch (err) { if (pane.terminalGpuAcceleration === 'auto') { @@ -140,6 +181,11 @@ export function attachWebgl(pane: ManagedPaneInternal): void { webglAttachFailedSinceRecovery = true // WebGL not available — default DOM renderer is fine, but log it for debugging console.warn('[terminal] WebGL unavailable for pane', pane.id, '— using DOM renderer:', err) + try { + webglAddon?.dispose() + } catch { + /* ignore — a half-constructed addon may throw on dispose */ + } pane.webglAddon = null } } diff --git a/src/renderer/src/lib/pane-manager/terminal-ime-anchor.test.ts b/src/renderer/src/lib/pane-manager/terminal-ime-anchor.test.ts new file mode 100644 index 00000000000..19de8afef2c --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-ime-anchor.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from 'vitest' +import type { IBuffer, IBufferCell, IBufferLine } from '@xterm/xterm' +import { resolveCursorAgentImeAnchor } from './terminal-ime-anchor' + +type FakeCell = { + chars: string + width: number +} + +function makeCell(cell: FakeCell): IBufferCell { + return { + getWidth: () => cell.width, + getChars: () => cell.chars + } as IBufferCell +} + +function makeLine(text: string, cols = 80): IBufferLine { + const cells: FakeCell[] = [] + for (const char of Array.from(text)) { + const width = char === '你' ? 2 : 1 + cells.push({ chars: char, width }) + if (width === 2) { + cells.push({ chars: '', width: 0 }) + } + } + while (cells.length < cols) { + cells.push({ chars: '', width: 1 }) + } + + return { + isWrapped: false, + length: cells.length, + getCell: (column: number) => { + const cell = cells[column] + return cell ? makeCell(cell) : undefined + }, + translateToString: (trimRight = false, startColumn = 0, endColumn = cells.length) => { + let result = '' + for (let column = startColumn; column < endColumn; column++) { + const cell = cells[column] + if (!cell || cell.width === 0) { + continue + } + result += cell.chars || ' ' + } + return trimRight ? result.replace(/\s+$/, '') : result + } + } as IBufferLine +} + +function makeBuffer(lines: string[], cols = 80): IBuffer { + const bufferLines = lines.map((line) => makeLine(line, cols)) + return { + baseY: 0, + cursorX: 0, + cursorY: 0, + length: bufferLines.length, + getLine: (row: number) => bufferLines[row], + getNullCell: () => makeCell({ chars: '', width: 1 }) + } as IBuffer +} + +describe('resolveCursorAgentImeAnchor', () => { + it('anchors an empty Cursor Agent prompt at the visible prompt caret', () => { + const buffer = makeBuffer([ + '', + ' Cursor Agent', + ' v2026.06.29-2ad2186', + ' Tip: Use /config to customize Cursor settings and behavior.', + '', + '', + '', + '', + ' → Plan, search, build anything', + '', + '', + ' Composer 2.5', + ' ~/development/code/xinyue/app_android · develop/app6.5.1', + '' + ]) + + expect( + resolveCursorAgentImeAnchor({ + buffer, + rows: 14, + cols: 80, + cursorX: 0, + cursorY: 13 + }) + ).toEqual({ row: 8, column: 4 }) + }) + + it('does not override normal terminal cursor positioning', () => { + const buffer = makeBuffer(['', ' → Plan, search, build anything', '', '']) + + expect( + resolveCursorAgentImeAnchor({ + buffer, + rows: 4, + cols: 80, + cursorX: 0, + cursorY: 3 + }) + ).toBeNull() + }) + + it('does not override when xterm already exposes a non-stale cursor position', () => { + const buffer = makeBuffer(['', ' Cursor Agent', '', ' → Plan, search, build anything']) + + expect( + resolveCursorAgentImeAnchor({ + buffer, + rows: 4, + cols: 80, + cursorX: 4, + cursorY: 3 + }) + ).toBeNull() + }) + + it('anchors the input row, not a transcript line containing an arrow', () => { + const buffer = makeBuffer([ + '', + ' Cursor Agent', + '', + ' Renamed a.ts → b.ts', + '', + ' → Plan, search, build anything', + '' + ]) + + expect( + resolveCursorAgentImeAnchor({ + buffer, + rows: 7, + cols: 80, + cursorX: 0, + cursorY: 6 + }) + ).toEqual({ row: 5, column: 4 }) + }) + + it('uses cell width when anchoring after typed Cursor Agent input', () => { + const buffer = makeBuffer(['', ' Cursor Agent', '', ' → hi你', '']) + + expect( + resolveCursorAgentImeAnchor({ + buffer, + rows: 5, + cols: 80, + cursorX: 0, + cursorY: 4 + }) + ).toEqual({ row: 3, column: 8 }) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-ime-anchor.ts b/src/renderer/src/lib/pane-manager/terminal-ime-anchor.ts new file mode 100644 index 00000000000..bda4c751a88 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-ime-anchor.ts @@ -0,0 +1,122 @@ +import type { IBuffer, IBufferCell, IBufferLine } from '@xterm/xterm' + +export type TerminalImeAnchor = { + row: number + column: number +} + +const CURSOR_AGENT_HEADER = 'Cursor Agent' +const CURSOR_AGENT_INPUT_MARKER = '→' +const CURSOR_AGENT_EMPTY_PROMPT = 'Plan, search, build anything' +const CURSOR_AGENT_HEADER_SCAN_ROWS = 6 + +export function resolveCursorAgentImeAnchor(args: { + buffer: IBuffer + rows: number + cols: number + cursorX: number + cursorY: number +}): TerminalImeAnchor | null { + const cursorLine = getVisibleLine(args.buffer, args.cursorY) + if (args.cursorX !== 0 || !isBlankLine(cursorLine)) { + return null + } + return findCursorAgentScreenInputAnchor(args) +} + +function findCursorAgentScreenInputAnchor(args: { + buffer: IBuffer + rows: number + cols: number +}): TerminalImeAnchor | null { + if (!hasCursorAgentHeader(args.buffer, args.rows)) { + return null + } + + // Why: the input box sits below the transcript, so scan bottom-up — a + // transcript line containing "→ " (e.g. a rename diff) must not win. + for (let row = args.rows - 1; row >= 0; row--) { + const line = getVisibleLine(args.buffer, row) + if (!line) { + continue + } + const column = resolveCursorAgentInputColumn(line, args.cols) + if (column !== null) { + return { row, column: Math.min(column, Math.max(args.cols - 1, 0)) } + } + } + + return null +} + +function getVisibleLine(buffer: IBuffer, row: number): IBufferLine | undefined { + return buffer.getLine(buffer.baseY + row) +} + +function hasCursorAgentHeader(buffer: IBuffer, rows: number): boolean { + const scanRows = Math.min(rows, CURSOR_AGENT_HEADER_SCAN_ROWS) + for (let row = 0; row < scanRows; row++) { + if (getVisibleLine(buffer, row)?.translateToString(true).trim() === CURSOR_AGENT_HEADER) { + return true + } + } + return false +} + +function resolveCursorAgentInputColumn(line: IBufferLine, cols: number): number | null { + const inputColumn = findCursorAgentInputStartColumn(line, cols) + if (inputColumn === null) { + return null + } + + const inputText = line.translateToString(true, inputColumn, cols) + if (!inputText.trim() || inputText.startsWith(CURSOR_AGENT_EMPTY_PROMPT)) { + return inputColumn + } + + return findLineContentEndColumn(line, inputColumn, cols) ?? inputColumn +} + +function findCursorAgentInputStartColumn(line: IBufferLine, cols: number): number | null { + const maxColumn = Math.min(line.length, cols) + for (let column = 0; column < maxColumn - 1; column++) { + const markerCell = line.getCell(column) + if (!isCellChar(markerCell, CURSOR_AGENT_INPUT_MARKER)) { + continue + } + + const nextColumn = column + Math.max(markerCell.getWidth(), 1) + if (nextColumn < maxColumn && isCellChar(line.getCell(nextColumn), ' ')) { + return nextColumn + 1 + } + } + return null +} + +function findLineContentEndColumn( + line: IBufferLine, + startColumn: number, + cols: number +): number | null { + const maxColumn = Math.min(line.length, cols) + for (let column = maxColumn - 1; column >= startColumn; column--) { + const cell = line.getCell(column) + if (!cell || cell.getWidth() === 0 || getCellChars(cell) === ' ') { + continue + } + return column + Math.max(cell.getWidth(), 1) + } + return null +} + +function isBlankLine(line: IBufferLine | undefined): boolean { + return !line || line.translateToString(true).trim() === '' +} + +function isCellChar(cell: IBufferCell | undefined, expected: string): cell is IBufferCell { + return !!cell && cell.getWidth() > 0 && getCellChars(cell) === expected +} + +function getCellChars(cell: IBufferCell): string { + return cell.getChars() || ' ' +} diff --git a/src/renderer/src/lib/pending-worktree-creation.ts b/src/renderer/src/lib/pending-worktree-creation.ts index cad46eab62d..b7f042a7b36 100644 --- a/src/renderer/src/lib/pending-worktree-creation.ts +++ b/src/renderer/src/lib/pending-worktree-creation.ts @@ -77,6 +77,9 @@ export type WorktreeCreationRequest = { * agent launch is self-contained; otherwise the renderer drives startup via * `startupPlan`. */ startup?: WorktreeStartupLaunch + /** Repo Custom GitHub Issue Command to run in a side-pane split after the + * workspace's first terminal is created. Mirrors the composer's trust-gated issueCommand. */ + issueCommand?: { command: string; env?: Record } pendingFirstAgentMessageRename: boolean /** Post-create note persisted as the worktree comment. */ note: string diff --git a/src/renderer/src/lib/terminal-links.test.ts b/src/renderer/src/lib/terminal-links.test.ts index 887f87ee19e..f6aa3501a0a 100644 --- a/src/renderer/src/lib/terminal-links.test.ts +++ b/src/renderer/src/lib/terminal-links.test.ts @@ -1,5 +1,9 @@ import { performance } from 'node:perf_hooks' import { describe, expect, it } from 'vitest' +import { + TERMINAL_FILE_LINK_TAP_CONFORMANCE_CASES, + columnForTerminalFileLinkTap +} from '../../../shared/terminal-file-link-conformance' import { extractTerminalFileLinks, isPathInsideWorktree, @@ -8,7 +12,27 @@ import { toWorktreeRelativePath } from './terminal-links' +function extractTerminalFileLinkAtColumn(lineText: string, column: number) { + return ( + extractTerminalFileLinks(lineText).find( + (link) => column >= link.startIndex && column < link.endIndex + ) ?? null + ) +} + describe('terminal path helpers', () => { + describe('shared terminal file-link tap conformance', () => { + it.each(TERMINAL_FILE_LINK_TAP_CONFORMANCE_CASES)('$name', (testCase) => { + const link = extractTerminalFileLinkAtColumn( + testCase.lineText, + columnForTerminalFileLinkTap(testCase) + ) + expect( + link ? { pathText: link.pathText, line: link.line, column: link.column } : null + ).toEqual(testCase.expected) + }) + }) + it('keeps worktree-relative paths on Windows external files', () => { expect(isPathInsideWorktree('C:\\repo\\src\\file.ts', 'C:\\repo')).toBe(true) expect(toWorktreeRelativePath('C:\\repo\\src\\file.ts', 'C:\\repo')).toBe('src/file.ts') diff --git a/src/renderer/src/lib/terminal-links.ts b/src/renderer/src/lib/terminal-links.ts index a882e71099e..605360e4245 100644 --- a/src/renderer/src/lib/terminal-links.ts +++ b/src/renderer/src/lib/terminal-links.ts @@ -255,21 +255,47 @@ function insertClaimedRange(claimedRanges: [number, number][], range: [number, n } function trimSpacedPathTrailingProse(range: DetectedRange): DetectedRange { - const filenameBeforeProseMatch = /^(.+\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?)(?:\s+.+)$/.exec( - range.text - ) - if (!filenameBeforeProseMatch) { + // Why: keep one extension-terminated path, but drop trailing prose or a + // second unrelated path that the broad spaced-path scan also captured. 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) { return range } - - const text = filenameBeforeProseMatch[1] return { - text, + text: selected, startIndex: range.startIndex, - endIndex: range.startIndex + text.length + 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 trimTrailingWhitespace(range: DetectedRange): DetectedRange { const text = range.text.trimEnd() return { diff --git a/src/renderer/src/lib/worktree-activation.test.ts b/src/renderer/src/lib/worktree-activation.test.ts index a1937bbe8e2..fb7e201123d 100644 --- a/src/renderer/src/lib/worktree-activation.test.ts +++ b/src/renderer/src/lib/worktree-activation.test.ts @@ -2,8 +2,16 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { SetupScriptLaunchMode } from '../../../shared/types' import { activateAndRevealWorktree, ensureWorktreeHasInitialTerminal } from './worktree-activation' +import { resetHookCommandDelayedDeliveryForTests } from './hook-command-delayed-delivery' import { useAppStore } from '@/store' +type AppStoreState = ReturnType + +const initialTabsByWorktree = useAppStore.getState().tabsByWorktree +const initialGetKnownWorktreeById = useAppStore.getState().getKnownWorktreeById +const initialPendingIssueCommandSplitByTabId = + useAppStore.getState().pendingIssueCommandSplitByTabId + function setSetupScriptLaunchMode(mode: SetupScriptLaunchMode | null): void { useAppStore.setState((state) => ({ settings: state.settings @@ -22,6 +30,12 @@ afterEach(() => { : ({ activeRuntimeEnvironmentId: null } as unknown as typeof state.settings) })) setSetupScriptLaunchMode('new-tab') + resetHookCommandDelayedDeliveryForTests() + useAppStore.setState({ + tabsByWorktree: initialTabsByWorktree, + getKnownWorktreeById: initialGetKnownWorktreeById, + pendingIssueCommandSplitByTabId: initialPendingIssueCommandSplitByTabId + } as Partial) }) function createMockStore(overrides: Record = {}) { @@ -266,6 +280,40 @@ describe('ensureWorktreeHasInitialTerminal', () => { ) }) + it('holds the issue command for the first mirrored web runtime tab when none exists yet', () => { + ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true + useAppStore.setState((state) => ({ + settings: state.settings + ? { ...state.settings, activeRuntimeEnvironmentId: 'web-runtime-1' } + : ({ activeRuntimeEnvironmentId: 'web-runtime-1' } as unknown as typeof state.settings) + })) + useAppStore.setState({ + tabsByWorktree: {}, + getKnownWorktreeById: ((id: string) => + id === 'wt-1' + ? { id: 'wt-1' } + : undefined) as unknown as AppStoreState['getKnownWorktreeById'] + } as Partial) + const store = createMockStore() + + const result = ensureWorktreeHasInitialTerminal(store, 'wt-1', undefined, undefined, { + command: 'gh issue view 42' + }) + + // Why: runtime session tabs mirror in asynchronously — the command must be + // held for the first mirrored tab rather than silently dropped. + expect(result).toBeNull() + expect(useAppStore.getState().pendingIssueCommandSplitByTabId).toEqual({}) + + useAppStore.setState({ + tabsByWorktree: { 'wt-1': [{ id: 'mirror-tab-1' }] } + } as unknown as Partial) + + expect(useAppStore.getState().pendingIssueCommandSplitByTabId['mirror-tab-1']).toEqual({ + command: 'gh issue view 42' + }) + }) + it('creates a local initial terminal for explicitly local worktrees while a runtime is focused', () => { useAppStore.setState((state) => ({ settings: state.settings diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index 840361c651b..894e9d38ad9 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -49,6 +49,7 @@ import { import { isTuiAgent } from '../../../shared/tui-agent-config' import { repoIsRemote } from '../../../shared/agent-launch-remote' import { resumeSleepingAgentSessionsForWorktree } from '@/lib/resume-sleeping-agent-session' +import { queueHookCommandsForFirstWorktreeTab } from '@/lib/hook-command-delayed-delivery' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { getRuntimeEnvironmentIdForWorktree, @@ -506,6 +507,24 @@ export function ensureWorktreeHasInitialTerminal( ) return existingTerminalTabId } + if (setup || issueCommand) { + // Why: runtime-owned worktrees mirror their session tabs asynchronously, + // so right after create there is usually no tab yet. Hold the commands + // for the first mirrored tab instead of silently dropping them. + queueHookCommandsForFirstWorktreeTab({ + worktreeId, + deliver: (state, firstTerminalTabId) => + queueSetupAndIssueCommands( + state, + worktreeId, + firstTerminalTabId, + setup, + issueCommand, + wrappedSetupCommandStr, + opts + ) + }) + } return null } diff --git a/src/renderer/src/lib/worktree-creation-flow.test.ts b/src/renderer/src/lib/worktree-creation-flow.test.ts index f03cf282d25..1ceebd2093f 100644 --- a/src/renderer/src/lib/worktree-creation-flow.test.ts +++ b/src/renderer/src/lib/worktree-creation-flow.test.ts @@ -504,6 +504,70 @@ describe('staged background worktree creation', () => { }) }) + // Why: one-click "Start workspace from issue" commonly backgrounds, so the + // user-moved-on path is the common delivery for the repo's issue command; it + // must thread through as the 5th positional arg, not be dropped to undefined. + it('threads the request issue command into the background terminal seed', async () => { + store.activeView = 'tasks' + store.createWorktree.mockResolvedValueOnce({ + worktree: { + id: 'wt-1', + repoId: 'repo-1' + } + }) + + const started = continueBackgroundWorktreeCreation( + 'creation-1', + makeRequest({ issueCommand: { command: 'gh issue view 42' } }), + { revealCreationSurface: false } + ) + + expect(started).toBe(true) + // Why: vi.waitFor instead of a fixed microtask flush — the await count in + // executeWorktreeCreation grows over time (e.g. VM preflight), and a fixed + // flush silently starves this assertion in merged builds. + await vi.waitFor(() => + expect(ensureWorktreeHasInitialTerminal).toHaveBeenCalledWith( + store, + 'wt-1', + undefined, + undefined, + { command: 'gh issue view 42' }, + undefined, + { activateCreatedTabs: false } + ) + ) + }) + + // Why: the still-watching path activates the worktree directly, so the issue + // command must reach activateAndRevealWorktree too — both branches carry it. + it('threads the request issue command into the active reveal', async () => { + store.activeView = 'terminal' + store.activePendingCreationId = 'creation-1' + store.createWorktree.mockResolvedValueOnce({ + worktree: { + id: 'wt-1', + repoId: 'repo-1', + path: '/repo/wt-1' + } + }) + vi.mocked(activateAndRevealWorktree).mockReturnValueOnce({ primaryTabId: 'tab-1' }) + + const started = continueBackgroundWorktreeCreation( + 'creation-1', + makeRequest({ issueCommand: { command: 'gh issue view 42' } }) + ) + + expect(started).toBe(true) + await vi.waitFor(() => + expect(activateAndRevealWorktree).toHaveBeenCalledWith( + 'wt-1', + expect.objectContaining({ issueCommand: { command: 'gh issue view 42' } }) + ) + ) + expect(ensureWorktreeHasInitialTerminal).not.toHaveBeenCalled() + }) + it('toasts a staged create error after the user leaves the creation surface', async () => { store.activeView = 'tasks' store.createWorktree.mockRejectedValueOnce(new Error('create failed')) diff --git a/src/renderer/src/lib/worktree-creation-flow.ts b/src/renderer/src/lib/worktree-creation-flow.ts index f4938e44013..9af1dc61ebe 100644 --- a/src/renderer/src/lib/worktree-creation-flow.ts +++ b/src/renderer/src/lib/worktree-creation-flow.ts @@ -225,7 +225,8 @@ async function executeWorktreeCreation( sidebarRevealBehavior: 'auto', ...(result.setup ? { setup: result.setup } : {}), ...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}), - ...(startupOpt ? { startup: startupOpt } : {}) + ...(startupOpt ? { startup: startupOpt } : {}), + ...(preparedRequest.issueCommand ? { issueCommand: preparedRequest.issueCommand } : {}) }) primaryTabId = activation === false ? null : activation.primaryTabId } else { @@ -237,7 +238,7 @@ async function executeWorktreeCreation( worktree.id, startupOpt, result.setup, - undefined, + preparedRequest.issueCommand, result.defaultTabs, { activateCreatedTabs: false } ) diff --git a/src/renderer/src/runtime/web-runtime-session.test.ts b/src/renderer/src/runtime/web-runtime-session.test.ts index 2a03f55483a..52fbcf4ffb5 100644 --- a/src/renderer/src/runtime/web-runtime-session.test.ts +++ b/src/renderer/src/runtime/web-runtime-session.test.ts @@ -480,6 +480,7 @@ describe('createWebRuntimeSessionTerminal', () => { afterTabId: 'web-terminal-host-tab-1%3A%3Aleaf-1', targetGroupId: 'group-left', command: "codex 'linked issue context'", + cwd: '/repo/packages/app', env: { CODEX_PROFILE: 'captured' }, startupCommandDelivery: 'shell-ready', launchConfig: { @@ -499,6 +500,7 @@ describe('createWebRuntimeSessionTerminal', () => { afterTabId: 'host-tab-1::leaf-1', targetGroupId: 'group-left', command: "codex 'linked issue context'", + cwd: '/repo/packages/app', env: { CODEX_PROFILE: 'captured' }, startupCommandDelivery: 'shell-ready', launchConfig: { diff --git a/src/renderer/src/runtime/web-runtime-session.ts b/src/renderer/src/runtime/web-runtime-session.ts index 14408a9626a..b2e14dd161a 100644 --- a/src/renderer/src/runtime/web-runtime-session.ts +++ b/src/renderer/src/runtime/web-runtime-session.ts @@ -50,6 +50,7 @@ export async function createWebRuntimeSessionTerminal(args: { afterTabId?: string targetGroupId?: string command?: string + cwd?: string env?: Record startupCommandDelivery?: StartupCommandDelivery launchConfig?: SleepingAgentLaunchConfig @@ -78,6 +79,7 @@ export async function createWebRuntimeSessionTerminal(args: { afterTabId: args.afterTabId ? toHostSessionTabId(args.afterTabId) : undefined, targetGroupId: args.targetGroupId, command: args.command, + cwd: args.cwd, ...(args.env ? { env: args.env } : {}), startupCommandDelivery: args.startupCommandDelivery, ...(args.launchConfig ? { launchConfig: args.launchConfig } : {}), diff --git a/src/renderer/src/runtime/web-session-tabs-sync.test.ts b/src/renderer/src/runtime/web-session-tabs-sync.test.ts index b567654eae4..be97ef98b87 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -748,6 +748,7 @@ describe('applyWebSessionTabsSnapshot', () => { leafId: LEAF_ID, isActive: true, launchAgent: 'codex', + startupCwd: '/worktree/packages/web', status: 'ready', terminal: 'terminal-1' } @@ -765,6 +766,7 @@ describe('applyWebSessionTabsSnapshot', () => { id: mirroredId, ptyId: 'remote:web-env-1@@terminal-1', launchAgent: 'codex', + startupCwd: '/worktree/packages/web', title: 'host shell', worktreeId: WT } @@ -827,6 +829,48 @@ describe('applyWebSessionTabsSnapshot', () => { expect(patch.tabsByWorktree?.[WT]?.[0]?.launchAgent).toBeUndefined() }) + it('drops mirrored startup cwd when a later host snapshot omits it', () => { + const existingTab: TerminalTab = { + id: toWebTerminalSurfaceTabId('host-tab-1'), + ptyId: 'remote:web-env-1@@terminal-1', + worktreeId: WT, + title: 'zsh', + defaultTitle: 'zsh', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: NOW, + startupCwd: '/worktree/packages/web' + } + + const patch = applyWebSessionTabsSnapshot( + makeState({ + tabsByWorktree: { [WT]: [existingTab] }, + ptyIdsByTabId: { [existingTab.id]: ['remote:web-env-1@@terminal-1'] } + }), + makeSnapshot([ + { + type: 'terminal', + id: HOST_SURFACE_ID, + title: 'zsh', + parentTabId: 'host-tab-1', + leafId: LEAF_ID, + isActive: true, + status: 'ready', + terminal: 'terminal-1' + } + ]), + ENV, + NOW + 1 + ) as Partial + + expect(patch.tabsByWorktree?.[WT]?.[0]).toMatchObject({ + id: existingTab.id, + title: 'zsh' + }) + expect(patch.tabsByWorktree?.[WT]?.[0]?.startupCwd).toBeUndefined() + }) + it('adopts host viewMode when this client has no prior tab', () => { const patch = applyWebSessionTabsSnapshot( makeState(), diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 5c6b4677ce1..8d0eba1361d 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -547,6 +547,10 @@ function buildMirroredTerminalTabs( activeSurface.quickCommandLabel?.trim() || surfaces.find((surface) => surface.quickCommandLabel?.trim())?.quickCommandLabel?.trim() || existing?.quickCommandLabel?.trim() + // Why: startup cwd is host-owned launch metadata; once the host omits it, + // mirrored clients must not resurrect stale subdirectory intent. + const startupCwd = + activeSurface.startupCwd || surfaces.find((surface) => surface.startupCwd)?.startupCwd // Why: tab color/pin echo back through host snapshots, so prefer the client's // own record (kept authoritative in tabsByWorktree by the pin/color setters) // and fall back to the host value only when this client has no prior tab — @@ -570,6 +574,7 @@ function buildMirroredTerminalTabs( title, defaultTitle: existing?.defaultTitle ?? title, ...(quickCommandLabel ? { quickCommandLabel } : {}), + ...(startupCwd ? { startupCwd } : {}), customTitle: existing?.customTitle ?? null, color, isPinned, @@ -1386,6 +1391,7 @@ function terminalTabEqual(a: TerminalTab, b: TerminalTab): boolean { a.title === b.title && a.defaultTitle === b.defaultTitle && a.quickCommandLabel === b.quickCommandLabel && + a.startupCwd === b.startupCwd && a.generatedTitle === b.generatedTitle && a.customTitle === b.customTitle && a.color === b.color && diff --git a/src/renderer/src/store/slices/store-cascades.test.ts b/src/renderer/src/store/slices/store-cascades.test.ts index 6412e2c1935..fbecc991416 100644 --- a/src/renderer/src/store/slices/store-cascades.test.ts +++ b/src/renderer/src/store/slices/store-cascades.test.ts @@ -1117,6 +1117,57 @@ describe('setActiveWorktree', () => { expect(groups[0].tabOrder).toEqual([terminal.id]) }) + it('moves live PTY ownership when detaching a primary pane to a tab', () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + const sourceTabId = 'tab-source' + const targetTabId = 'tab-target' + + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + tabsByWorktree: { + [wt]: [ + makeTab({ id: sourceTabId, worktreeId: wt, ptyId: 'pty-detached' }), + makeTab({ id: targetTabId, worktreeId: wt, ptyId: null }) + ] + }, + ptyIdsByTabId: { + [sourceTabId]: ['pty-detached', 'pty-survivor'], + [targetTabId]: ['pty-detached'] + }, + lastKnownRelayPtyIdByTabId: { + [sourceTabId]: 'pty-detached', + [targetTabId]: 'pty-detached' + } + }) + + store.getState().syncPaneDetachPtyOwnership({ + detachedPtyId: 'pty-detached', + sourceLayout: { + root: { type: 'leaf', leafId: 'survivor-leaf' }, + activeLeafId: 'survivor-leaf', + expandedLeafId: null, + ptyIdsByLeafId: { 'survivor-leaf': 'pty-survivor' } + }, + sourceTabId, + targetTabId + }) + + const state = store.getState() + expect(state.ptyIdsByTabId[sourceTabId]).toEqual(['pty-survivor']) + expect(state.ptyIdsByTabId[targetTabId]).toEqual(['pty-detached']) + expect(state.lastKnownRelayPtyIdByTabId[sourceTabId]).toBe('pty-survivor') + expect(state.lastKnownRelayPtyIdByTabId[targetTabId]).toBe('pty-detached') + expect(state.tabsByWorktree[wt].find((tab) => tab.id === sourceTabId)?.ptyId).toBe( + 'pty-survivor' + ) + expect(state.tabsByWorktree[wt].find((tab) => tab.id === targetTabId)?.ptyId).toBe( + 'pty-detached' + ) + }) + it('stores trimmed quick command labels on terminal and unified tabs', () => { const store = createTestStore() const wt = 'repo1::/path/wt1' @@ -1146,6 +1197,30 @@ describe('setActiveWorktree', () => { ) }) + it('stores terminal startup cwd exactly and omits empty values', () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + } + }) + + const nested = store + .getState() + .createTab(wt, undefined, undefined, { startupCwd: '/path/wt1/packages/app ' }) + const empty = store.getState().createTab(wt, undefined, undefined, { startupCwd: '' }) + const state = store.getState() + + expect(state.tabsByWorktree[wt].find((tab) => tab.id === nested.id)?.startupCwd).toBe( + '/path/wt1/packages/app ' + ) + expect(state.tabsByWorktree[wt].find((tab) => tab.id === empty.id)).not.toHaveProperty( + 'startupCwd' + ) + }) + it('stamps the Windows default shell onto new terminal tabs', () => { const originalNavigator = globalThis.navigator Object.defineProperty(globalThis, 'navigator', { diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 559e06106e5..bbd43ff773b 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -293,6 +293,36 @@ function equalStringSets(a: readonly string[], b: readonly string[]): boolean { return a.every((value) => bSet.has(value)) } +function uniquePtyIds(ptyIds: readonly (string | null | undefined)[]): string[] { + return [...new Set(ptyIds.filter((ptyId): ptyId is string => Boolean(ptyId)))] +} + +function resolvePrimaryLayoutPtyId(layout: TerminalLayoutSnapshot): string | null { + const ptyIdsByLeafId = layout.ptyIdsByLeafId ?? {} + const activePtyId = layout.activeLeafId ? ptyIdsByLeafId[layout.activeLeafId] : undefined + return activePtyId ?? Object.values(ptyIdsByLeafId)[0] ?? null +} + +function withTerminalTabPtyId( + tabsByWorktree: Record, + tabId: string, + ptyId: string | null +): Record { + for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { + const index = tabs.findIndex((tab) => tab.id === tabId) + if (index === -1) { + continue + } + if (tabs[index]?.ptyId === ptyId) { + return tabsByWorktree + } + const nextTabs = [...tabs] + nextTabs[index] = { ...nextTabs[index]!, ptyId } + return { ...tabsByWorktree, [worktreeId]: nextTabs } + } + return tabsByWorktree +} + export type AutomaticAgentResumeClaim = { worktreeId: string launchAgent: TuiAgent @@ -440,6 +470,7 @@ export type TerminalSlice = { * `'chat'` so the tab opens in the native chat view; omitted otherwise * so the tab keeps the implicit `'terminal'` default. */ viewMode?: Tab['viewMode'] + startupCwd?: string } ) => TerminalTab openNewTerminalTabInActiveWorkspace: (groupId: string) => Promise @@ -505,6 +536,12 @@ export type TerminalSlice = { setTabPaneExpanded: (tabId: string, expanded: boolean) => void setTabCanExpandPane: (tabId: string, canExpand: boolean) => void setTabLayout: (tabId: string, layout: TerminalLayoutSnapshot | null) => void + syncPaneDetachPtyOwnership: (args: { + detachedPtyId: string | null + sourceLayout: TerminalLayoutSnapshot + sourceTabId: string + targetTabId: string + }) => void queueTabStartupCommand: ( tabId: string, startup: { @@ -764,6 +801,7 @@ export const createTerminalSlice: StateCreator const nextOrdinal = getNextTerminalOrdinal(existing) const defaultTitle = `Terminal ${nextOrdinal}` const quickCommandLabel = options?.quickCommandLabel?.trim() + const startupCwd = options?.startupCwd const remoteConnectionId = getRemoteConnectionIdForWorktree(s, worktreeId) const isRemoteWorktree = Boolean(remoteConnectionId) const isWslWorktree = worktreeUsesWslPath(s, worktreeId) @@ -800,6 +838,7 @@ export const createTerminalSlice: StateCreator sortOrder: existing.length, createdAt: Date.now(), ...(createdShellOverride !== undefined ? { shellOverride: createdShellOverride } : {}), + ...(startupCwd && startupCwd.length > 0 ? { startupCwd } : {}), ...(options?.launchAgent ? { launchAgent: options.launchAgent } : {}), // Why: when Terminal.tsx's activation fallback auto-creates a tab for a // first-visit worktree, the resulting PTY spawn is caused by the user @@ -2452,6 +2491,54 @@ export const createTerminalSlice: StateCreator }) }, + syncPaneDetachPtyOwnership: ({ detachedPtyId, sourceLayout, sourceTabId, targetTabId }) => { + set((s) => { + const layoutSourcePtyIds = uniquePtyIds(Object.values(sourceLayout.ptyIdsByLeafId ?? {})) + const existingSourcePtyIds = (s.ptyIdsByTabId[sourceTabId] ?? []).filter( + (ptyId) => ptyId !== detachedPtyId + ) + const sourcePtyIds = layoutSourcePtyIds.length > 0 ? layoutSourcePtyIds : existingSourcePtyIds + const sourcePrimaryPtyId = resolvePrimaryLayoutPtyId(sourceLayout) ?? sourcePtyIds[0] ?? null + const nextPtyIdsByTabId = { + ...s.ptyIdsByTabId, + [sourceTabId]: sourcePtyIds + } + if (detachedPtyId) { + nextPtyIdsByTabId[targetTabId] = uniquePtyIds([ + ...(nextPtyIdsByTabId[targetTabId] ?? []), + detachedPtyId + ]) + } + + const nextLastKnownRelayPtyIdByTabId = { ...s.lastKnownRelayPtyIdByTabId } + if (sourcePrimaryPtyId) { + nextLastKnownRelayPtyIdByTabId[sourceTabId] = sourcePrimaryPtyId + } else { + delete nextLastKnownRelayPtyIdByTabId[sourceTabId] + } + if (detachedPtyId) { + nextLastKnownRelayPtyIdByTabId[targetTabId] = detachedPtyId + } + + // Why: pane-to-tab detach moves live PTY ownership without spawning or + // exiting processes, so sync identity maps directly without activity bumps. + const sourceTabsByWorktree = withTerminalTabPtyId( + s.tabsByWorktree, + sourceTabId, + sourcePrimaryPtyId + ) + const nextTabsByWorktree = detachedPtyId + ? withTerminalTabPtyId(sourceTabsByWorktree, targetTabId, detachedPtyId) + : sourceTabsByWorktree + + return { + ptyIdsByTabId: nextPtyIdsByTabId, + lastKnownRelayPtyIdByTabId: nextLastKnownRelayPtyIdByTabId, + ...(nextTabsByWorktree !== s.tabsByWorktree ? { tabsByWorktree: nextTabsByWorktree } : {}) + } + }) + }, + queueTabStartupCommand: (tabId, startup) => { // Why: launchToken is only meaningful for tracked launch-config reuse; // plain startup commands must not mint or carry a synthetic token. diff --git a/src/shared/agent-detection.test.ts b/src/shared/agent-detection.test.ts index b4f8495548a..a1bee91aef3 100644 --- a/src/shared/agent-detection.test.ts +++ b/src/shared/agent-detection.test.ts @@ -8,6 +8,7 @@ import { MAX_OSC_TITLE_CHARS } from './agent-detection' import { + hasCompatibleAgentTitleIdentity, normalizeCompatibleAgentStatusEntryForOwner, normalizeCompatibleAgentTitleForOwner, resolveCompatibleAgentTypeForOwner @@ -114,6 +115,14 @@ describe('Pi-compatible title detection', () => { expect(normalizeCompatibleAgentTitleForOwner('\u280b Pi', 'codex')).toBe('\u280b Pi') }) + it('identifies titles whose compatible identity can be re-owned', () => { + expect(hasCompatibleAgentTitleIdentity('Pi ready')).toBe(true) + expect(hasCompatibleAgentTitleIdentity('π - tmp')).toBe(true) + expect(hasCompatibleAgentTitleIdentity('\u280b OMP')).toBe(true) + expect(hasCompatibleAgentTitleIdentity('Fix pi bugs')).toBe(false) + expect(hasCompatibleAgentTitleIdentity('\u280b Codex')).toBe(false) + }) + it('normalizes Pi-compatible status identity and terminal title to the owner', () => { const status = normalizeCompatibleAgentStatusEntryForOwner( { diff --git a/src/shared/agent-title-owner.ts b/src/shared/agent-title-owner.ts index a72a46b1cb8..4e1dfda55af 100644 --- a/src/shared/agent-title-owner.ts +++ b/src/shared/agent-title-owner.ts @@ -43,6 +43,14 @@ function getProfileForTitle(title: string): TitleProfileMatch | null { return null } +/** + * Why: unknown-launch remote sessions need to wait for foreground ownership + * before publishing title frames whose identity can be re-owned. + */ +export function hasCompatibleAgentTitleIdentity(title: string): boolean { + return Boolean(getProfileForTitle(title)?.profile.titleIdentityGroup) +} + /** * Detects the agent status (working, permission, idle) from a terminal title, * accounting for legacy Pi titles. diff --git a/src/shared/constants.test.ts b/src/shared/constants.test.ts index fdead8aab38..26db028766d 100644 --- a/src/shared/constants.test.ts +++ b/src/shared/constants.test.ts @@ -51,6 +51,10 @@ describe('getDefaultSettings', () => { expect(getDefaultSettings('/tmp').confirmClosePinnedTab).toBe(true) }) + it('keeps rich Markdown spellcheck enabled by default', () => { + expect(getDefaultSettings('/tmp').richMarkdownSpellcheckEnabled).toBe(true) + }) + it('enables Source Control AI by default without pinning a separate agent', () => { expect(getDefaultSettings('/tmp').commitMessageAi).toMatchObject({ enabled: true, diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 67ac5e65450..4eebbaa9383 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -203,6 +203,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { editorAutoSave: false, editorAutoSaveDelayMs: DEFAULT_EDITOR_AUTO_SAVE_DELAY_MS, editorMinimapEnabled: false, + richMarkdownSpellcheckEnabled: true, markdownReviewToolsEnabled: true, primarySelectionMiddleClickPaste: getDefaultPrimarySelectionMiddleClickPaste(), primarySelectionMiddleClickPasteDefaultedForLinux: diff --git a/src/shared/external-worktree-inbox.test.ts b/src/shared/external-worktree-inbox.test.ts index 029e04c3959..1e16c6d5134 100644 --- a/src/shared/external-worktree-inbox.test.ts +++ b/src/shared/external-worktree-inbox.test.ts @@ -1,13 +1,23 @@ import { describe, expect, it } from 'vitest' -import type { DetectedWorktree, DetectedWorktreeListResult, Repo } from './types' +import type { + DetectedWorktree, + DetectedWorktreeListResult, + GlobalSettings, + Repo, + Worktree +} from './types' import { getHiddenExternalWorktrees, getNewExternalWorktreeInboxWorktrees, mergeExternalWorktreeInboxPaths, shouldOfferNewExternalWorktreeInbox } from './external-worktree-inbox' -import { EXTERNAL_WORKTREE_VISIBILITY_ROLLOUT_AT } from './worktree-ownership' +import { + buildKnownOrcaWorkspaceLayouts, + EXTERNAL_WORKTREE_VISIBILITY_ROLLOUT_AT, + toDetectedWorktree +} from './worktree-ownership' const repo: Repo = { id: 'repo-1', @@ -54,6 +64,55 @@ function detectedResult(worktrees: DetectedWorktree[]): DetectedWorktreeListResu } } +function makeSettings(): GlobalSettings { + return { + workspaceDir: '/orca/workspaces', + nestWorkspaces: true, + workspaceDirHistory: [], + refreshLocalBaseRefOnWorktreeCreate: false, + localBaseRefSuggestionDismissed: false, + branchPrefix: 'none', + branchPrefixCustom: '', + enableGitHubAttribution: false, + theme: 'system', + appFontFamily: 'Geist', + editorAutoSave: false, + editorAutoSaveDelayMs: 1000, + editorMinimapEnabled: false, + markdownReviewToolsEnabled: true, + terminalFontSize: 14, + terminalFontFamily: 'monospace', + terminalFontWeight: 400, + terminalLineHeight: 1.2 + } as unknown as GlobalSettings +} + +function makeGitWorktree(overrides: Partial = {}): Worktree { + return { + id: `repo-1::${overrides.path ?? '/repo'}`, + repoId: repo.id, + path: '/repo', + displayName: 'repo', + branch: 'refs/heads/main', + head: 'abc123', + isBare: false, + isMainWorktree: true, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + workspaceStatus: 'todo', + ...overrides + } +} + describe('external worktree inbox', () => { it('merges inbox paths without duplicates when paths match after normalization', () => { expect(mergeExternalWorktreeInboxPaths(['/repo/one/'], ['/repo/one', '/repo/two'])).toEqual([ @@ -103,6 +162,25 @@ describe('external worktree inbox', () => { ).toEqual([hidden]) }) + it('offers metadata-free nested Orca workspace worktrees through the inbox', () => { + const settings = makeSettings() + const manual = toDetectedWorktree({ + repo, + settings, + worktree: makeGitWorktree({ + path: '/orca/workspaces/orca/manual-from-git', + displayName: 'manual-from-git', + branch: 'refs/heads/manual-from-git', + isMainWorktree: false + }), + knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) + }) + + expect(manual.ownership).toBe('external') + expect(manual.visible).toBe(false) + expect(getNewExternalWorktreeInboxWorktrees(detectedResult([manual]), repo)).toEqual([manual]) + }) + it('suppresses non-authoritative detected results', () => { expect( getNewExternalWorktreeInboxWorktrees(detectedResult([detectedWorktree()]), { diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index bcd916c01d5..136c58e58ac 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -135,6 +135,7 @@ export type RuntimeMobileSessionTerminalTab = { terminalTheme?: RuntimeMobileTerminalTheme agentStatus?: AgentStatusEntry | null launchAgent?: TuiAgent + startupCwd?: string parentLayout?: TerminalLayoutSnapshot /** Tab-level color/pin (per parentTabId), host-persisted for remote servers. */ color?: string | null @@ -319,6 +320,24 @@ export type RuntimeFileReadResult = { byteLength: number } +export type RuntimeTerminalPathOpenTarget = + | { + kind: 'worktree-file' + provider: 'local' | 'ssh' + relativePath: string + absolutePath: string + } + | { + kind: 'absolute-file' + provider: 'local' | 'ssh' + absolutePath: string + grantId: string + } + | { + kind: 'unsupported' + reason: string + } + /** Result of resolving a file path tapped in the mobile terminal against the * worktree root (+ optional cwd). relativePath is null when the path resolves * outside the worktree (not openable via the worktree-scoped file RPCs). */ @@ -330,6 +349,7 @@ export type RuntimeTerminalPathResolution = { absolutePath: string | null exists: boolean isDirectory: boolean + openTarget?: RuntimeTerminalPathOpenTarget } export type RuntimeFilePreviewResult = { @@ -463,6 +483,7 @@ type RuntimeTerminalCreateBaseRequestPayload = { afterTabId?: string targetGroupId?: string command?: string + cwd?: string env?: Record launchConfig?: SleepingAgentLaunchConfig launchToken?: string diff --git a/src/shared/terminal-file-link-conformance.ts b/src/shared/terminal-file-link-conformance.ts new file mode 100644 index 00000000000..26ee9cd76b3 --- /dev/null +++ b/src/shared/terminal-file-link-conformance.ts @@ -0,0 +1,131 @@ +export type TerminalFileLinkTapExpectation = { + pathText: string + line: number | null + column: number | null +} + +export type TerminalFileLinkTapConformanceCase = { + name: string + lineText: string + tapText: string + expected: TerminalFileLinkTapExpectation | null +} + +export function columnForTerminalFileLinkTap(testCase: TerminalFileLinkTapConformanceCase): number { + const column = testCase.lineText.indexOf(testCase.tapText) + if (column < 0) { + throw new Error(`Tap text "${testCase.tapText}" not found in "${testCase.lineText}"`) + } + return column +} + +export const TERMINAL_FILE_LINK_TAP_CONFORMANCE_CASES: TerminalFileLinkTapConformanceCase[] = [ + { + name: 'absolute path', + lineText: 'created /tmp/out/report.html for you', + tapText: 'report', + expected: { pathText: '/tmp/out/report.html', line: null, column: null } + }, + { + name: 'relative path with line and column', + lineText: 'see src/components/Button.tsx:12:7 here', + tapText: 'Button', + expected: { pathText: 'src/components/Button.tsx', line: 12, column: 7 } + }, + { + name: 'tilde path', + lineText: 'wrote ~/Documents/notes.md', + tapText: 'notes', + expected: { pathText: '~/Documents/notes.md', line: null, column: null } + }, + { + name: 'directory segment containing a space', + lineText: '/Users/me/My Project/readme.md done', + tapText: 'readme', + expected: { pathText: '/Users/me/My Project/readme.md', line: null, column: null } + }, + { + name: 'spaced path with line and column', + lineText: 'wrote /tmp/orca report/result.json:12:3 for you', + tapText: 'result', + expected: { pathText: '/tmp/orca report/result.json', line: 12, column: 3 } + }, + { + name: 'spaced path with dotted directory', + lineText: 'wrote /tmp/v1.2 reports/result.json for you', + tapText: 'v1.2', + expected: { pathText: '/tmp/v1.2 reports/result.json', line: null, column: null } + }, + { + name: 'spaced path with dotted directory at line end', + lineText: 'wrote /tmp/v1.2 reports/result.json', + tapText: 'result', + expected: { pathText: '/tmp/v1.2 reports/result.json', line: null, column: null } + }, + { + name: 'spaced path at line end', + lineText: 'wrote /tmp/final report.json', + tapText: 'report', + expected: { pathText: '/tmp/final report.json', line: null, column: null } + }, + { + name: 'path followed by prose ending in a filename', + lineText: '/var/log/app.log failed to start app.py', + tapText: 'app.log', + expected: { pathText: '/var/log/app.log', line: null, column: null } + }, + { + name: 'trailing prose filename stays its own tap target', + lineText: '/var/log/app.log failed to start app.py', + tapText: 'app.py', + expected: { pathText: 'app.py', line: null, column: null } + }, + { + name: 'relative path followed by prose ending in a filename', + lineText: 'src/main.ts uses config.yaml', + tapText: 'main', + expected: { pathText: 'src/main.ts', line: null, column: null } + }, + { + name: 'spaced filename', + lineText: 'wrote /tmp/final report.json for you', + tapText: 'report', + expected: { pathText: '/tmp/final report.json', line: null, column: null } + }, + { + name: 'spaced path ending in a dotfile', + lineText: 'wrote /tmp/My Project/.env for you', + tapText: '.env', + expected: { pathText: '/tmp/My Project/.env', line: null, column: null } + }, + { + name: 'first of two spaced candidates', + lineText: 'see /tmp/a.txt and /tmp/b.txt done', + tapText: 'a.txt', + expected: { pathText: '/tmp/a.txt', line: null, column: null } + }, + { + name: 'second of two spaced candidates', + lineText: 'see /tmp/a.txt and /tmp/b.txt done', + tapText: 'b.txt', + expected: { pathText: '/tmp/b.txt', line: null, column: null } + }, + { + name: 'surrounding punctuation', + lineText: 'open (src/a.ts) now', + tapText: 'a.ts', + expected: { pathText: 'src/a.ts', line: null, column: null } + }, + { + name: 'bare filename with extension', + lineText: 'Here you go: README.md', + tapText: 'README', + expected: { pathText: 'README.md', line: null, column: null } + }, + { + name: 'plain prose word', + lineText: 'just some prose with no path here', + tapText: 'prose', + expected: null + } +] diff --git a/src/shared/terminal-startup-cwd.test.ts b/src/shared/terminal-startup-cwd.test.ts new file mode 100644 index 00000000000..e6ac5575ceb --- /dev/null +++ b/src/shared/terminal-startup-cwd.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { + resolveTerminalStartupCwd, + resolveTerminalStartupCwdForWorkspace +} from './terminal-startup-cwd' +import { folderWorkspaceKey } from './workspace-scope' + +describe('resolveTerminalStartupCwd', () => { + it('accepts absolute child paths inside the worktree', () => { + expect(resolveTerminalStartupCwd('/repo/app', '/repo/app/packages/web')).toBe( + '/repo/app/packages/web' + ) + }) + + it('resolves relative paths against the worktree', () => { + expect(resolveTerminalStartupCwd('/repo/app', 'packages/web')).toBe('/repo/app/packages/web') + }) + + it('rejects sibling paths outside the worktree', () => { + expect(() => resolveTerminalStartupCwd('/repo/app', '/repo/app-other')).toThrow( + 'Terminal cwd must be inside the selected worktree.' + ) + }) + + it('rejects parent traversal outside the worktree', () => { + expect(() => resolveTerminalStartupCwd('/repo/app', '../other')).toThrow( + 'Terminal cwd must be inside the selected worktree.' + ) + }) + + it('handles Windows path containment without case drift', () => { + expect(resolveTerminalStartupCwd('C:\\Repo\\App', 'packages\\web')).toBe( + 'C:/Repo/App/packages/web' + ) + expect(() => resolveTerminalStartupCwd('C:\\Repo\\App', 'C:\\Repo\\AppOther')).toThrow( + 'Terminal cwd must be inside the selected worktree.' + ) + }) + + it('validates renderer PTY cwd values against raw worktree IDs', () => { + expect( + resolveTerminalStartupCwdForWorkspace({ + workspaceId: 'repo-1::/repo/app', + requestedCwd: '/repo/app/packages/web' + }) + ).toBe('/repo/app/packages/web') + expect(() => + resolveTerminalStartupCwdForWorkspace({ + workspaceId: 'repo-1::/repo/app', + requestedCwd: '/repo/app-other' + }) + ).toThrow('Terminal cwd must be inside the selected worktree.') + }) + + it('validates renderer PTY cwd values against folder workspace keys', () => { + expect( + resolveTerminalStartupCwdForWorkspace({ + workspaceId: folderWorkspaceKey('folder-1'), + requestedCwd: 'packages/web', + resolveFolderWorkspacePath: (id) => (id === 'folder-1' ? '/repo/app' : null) + }) + ).toBe('/repo/app/packages/web') + expect(() => + resolveTerminalStartupCwdForWorkspace({ + workspaceId: folderWorkspaceKey('folder-1'), + requestedCwd: '../other', + resolveFolderWorkspacePath: (id) => (id === 'folder-1' ? '/repo/app' : null) + }) + ).toThrow('Terminal cwd must be inside the selected worktree.') + }) +}) diff --git a/src/shared/terminal-startup-cwd.ts b/src/shared/terminal-startup-cwd.ts new file mode 100644 index 00000000000..d71a0f7c37d --- /dev/null +++ b/src/shared/terminal-startup-cwd.ts @@ -0,0 +1,52 @@ +import { isPathInsideOrEqual, resolveRuntimePath } from './cross-platform-path' +import { parseWorkspaceKey } from './workspace-scope' +import { splitWorktreeIdForFilesystem } from './worktree-id' + +export function resolveTerminalStartupCwd( + worktreePath: string, + requestedCwd?: string | null +): string | undefined { + if (!requestedCwd || requestedCwd.trim().length === 0) { + return undefined + } + const resolvedCwd = resolveRuntimePath(worktreePath, requestedCwd) + if (!isPathInsideOrEqual(worktreePath, resolvedCwd)) { + // Why: remote/session clients can request terminal cwd; never let that + // become a shell outside the selected workspace. + throw new Error('Terminal cwd must be inside the selected worktree.') + } + return resolvedCwd +} + +export function resolveTerminalStartupCwdForWorkspace(args: { + workspaceId?: string + requestedCwd?: string | null + resolveFolderWorkspacePath?: (folderWorkspaceId: string) => string | null | undefined +}): string | undefined { + if (!args.requestedCwd || args.requestedCwd.trim().length === 0) { + return undefined + } + const workspacePath = resolveTerminalWorkspacePath( + args.workspaceId, + args.resolveFolderWorkspacePath + ) + if (!workspacePath) { + return args.requestedCwd + } + return resolveTerminalStartupCwd(workspacePath, args.requestedCwd) +} + +function resolveTerminalWorkspacePath( + workspaceId: string | undefined, + resolveFolderWorkspacePath: ((folderWorkspaceId: string) => string | null | undefined) | undefined +): string | null { + if (!workspaceId) { + return null + } + const scope = parseWorkspaceKey(workspaceId) + if (scope?.type === 'folder') { + return resolveFolderWorkspacePath?.(scope.folderWorkspaceId) ?? null + } + const worktreeId = scope?.type === 'worktree' ? scope.worktreeId : workspaceId + return splitWorktreeIdForFilesystem(worktreeId)?.worktreePath ?? null +} diff --git a/src/shared/terminal-stream-protocol.test.ts b/src/shared/terminal-stream-protocol.test.ts index 626973b1051..58a8a0458a5 100644 --- a/src/shared/terminal-stream-protocol.test.ts +++ b/src/shared/terminal-stream-protocol.test.ts @@ -68,6 +68,20 @@ describe('terminal-stream-protocol', () => { expect(resize && decodeTerminalStreamJson(resize.payload)).toEqual({ cols: 120, rows: 40 }) }) + it('round-trips terminal metadata frames', () => { + const metadata = decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Metadata, + streamId: 11, + seq: 4, + payload: encodeTerminalStreamJson({ cwd: '/repo/src' }) + }) + ) + + expect(metadata?.opcode).toBe(TerminalStreamOpcode.Metadata) + expect(metadata && decodeTerminalStreamJson(metadata.payload)).toEqual({ cwd: '/repo/src' }) + }) + it('round-trips multiplex subscribe, snapshot request, and unsubscribe frames', () => { const subscribe = decodeTerminalStreamFrame( encodeTerminalStreamFrame({ diff --git a/src/shared/terminal-stream-protocol.ts b/src/shared/terminal-stream-protocol.ts index 9a993bee99a..2f8f4e04247 100644 --- a/src/shared/terminal-stream-protocol.ts +++ b/src/shared/terminal-stream-protocol.ts @@ -13,7 +13,8 @@ export enum TerminalStreamOpcode { Resize = 8, Subscribe = 9, Unsubscribe = 10, - SnapshotRequest = 11 + SnapshotRequest = 11, + Metadata = 12 } export type TerminalStreamFrame = { @@ -92,6 +93,7 @@ function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode { value === TerminalStreamOpcode.Resize || value === TerminalStreamOpcode.Subscribe || value === TerminalStreamOpcode.Unsubscribe || - value === TerminalStreamOpcode.SnapshotRequest + value === TerminalStreamOpcode.SnapshotRequest || + value === TerminalStreamOpcode.Metadata ) } diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-unicode-provider.ts b/src/shared/terminal-unicode-provider.ts similarity index 100% rename from src/renderer/src/lib/pane-manager/pane-terminal-unicode-provider.ts rename to src/shared/terminal-unicode-provider.ts diff --git a/src/shared/types.ts b/src/shared/types.ts index d7ff1547dd4..b55b96270e4 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -836,6 +836,9 @@ export type TerminalTab = { * PTY and tab icon stay stable even if the default shell setting changes * later. Older persisted tabs may omit this field. */ shellOverride?: string + /** Why: explorer-created terminals can start below the workspace root while + * still belonging to that workspace for tab/session ownership. */ + startupCwd?: string /** Why: the coding-harness agent Orca launched in this tab. Lets the tab bar * show the provider icon immediately, before the agent emits its first hook * event (a freshly-launched, idle agent reports no live status yet). Live @@ -2469,6 +2472,8 @@ export type GlobalSettings = { editorAutoSave: boolean editorAutoSaveDelayMs: number editorMinimapEnabled: boolean + /** Persisted opt-out for browser spellcheck noise in rich Markdown editing surfaces. */ + richMarkdownSpellcheckEnabled?: boolean /** Whether local markdown review note controls and the review panel are shown. */ markdownReviewToolsEnabled: boolean /** Why: mirrors terminal selection-paste muscle memory without mutating the diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index 15ed9047160..d58f6ddb3d2 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -78,6 +78,7 @@ const terminalTabSchema = z.object({ sortOrder: z.number(), createdAt: z.number(), generation: z.number().optional(), + startupCwd: z.string().min(1).optional(), // Why: persist the launched agent so a restored idle agent tab keeps its // provider icon before any hook fires. `.catch(undefined)` keeps a stale or // unknown agent id from failing the whole-session parse (which would reset diff --git a/src/shared/workspace-session-terminal-schema.test.ts b/src/shared/workspace-session-terminal-schema.test.ts new file mode 100644 index 00000000000..367ee953f78 --- /dev/null +++ b/src/shared/workspace-session-terminal-schema.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { parseWorkspaceSession } from './workspace-session-schema' + +describe('parseWorkspaceSession terminal fields', () => { + it('preserves terminal startup cwd while accepting older omitted fields', () => { + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: 'wt', + activeTabId: 'tab1', + tabsByWorktree: { + wt: [ + { + id: 'tab1', + ptyId: null, + worktreeId: 'wt', + title: 'Terminal 1', + defaultTitle: 'Terminal 1', + startupCwd: '/repo/packages/app', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + }, + { + id: 'tab2', + ptyId: null, + worktreeId: 'wt', + title: 'Terminal 2', + customTitle: null, + color: null, + sortOrder: 1, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: {} + }) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.tabsByWorktree.wt[0].startupCwd).toBe('/repo/packages/app') + expect(result.value.tabsByWorktree.wt[1].startupCwd).toBeUndefined() + } + }) + + it('rejects empty terminal startup cwd values', () => { + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: 'wt', + activeTabId: 'tab1', + tabsByWorktree: { + wt: [ + { + id: 'tab1', + ptyId: null, + worktreeId: 'wt', + title: 'Terminal 1', + defaultTitle: 'Terminal 1', + startupCwd: '', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + ] + }, + terminalLayoutsByTabId: {} + }) + + expect(result.ok).toBe(false) + }) +}) diff --git a/src/shared/worktree-ownership-worktree-base-path.test.ts b/src/shared/worktree-ownership-worktree-base-path.test.ts index 968b1f3dab2..d6af4dab77d 100644 --- a/src/shared/worktree-ownership-worktree-base-path.test.ts +++ b/src/shared/worktree-ownership-worktree-base-path.test.ts @@ -48,7 +48,7 @@ describe('repo-specific worktree ownership layouts', () => { worktree: makeWorktree('/projects/a/worktrees/repo/feature'), knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repoA) }) - ).toBe('orca-managed') + ).toBe('external') expect( classifyWorktreeOwnership({ repo: repoB, @@ -73,7 +73,7 @@ describe('repo-specific worktree ownership layouts', () => { worktree: makeWorktree('C:\\projects\\App\\worktrees\\repo\\Feature'), knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) }) - ).toBe('orca-managed') + ).toBe('external') }) it('includes relative global layouts for SSH repos without applying absolute desktop paths', () => { diff --git a/src/shared/worktree-ownership.test.ts b/src/shared/worktree-ownership.test.ts index c7c77ea5937..0b41859d28b 100644 --- a/src/shared/worktree-ownership.test.ts +++ b/src/shared/worktree-ownership.test.ts @@ -108,7 +108,7 @@ describe('worktree ownership classification', () => { ).toBe('orca-managed') }) - it('requires the nested repo-specific path shape for path-only ownership', () => { + it('treats nested Orca workspace paths without metadata as external', () => { const repo = makeRepo() const settings = makeSettings() const layouts = buildKnownOrcaWorkspaceLayouts(settings, repo) @@ -119,7 +119,7 @@ describe('worktree ownership classification', () => { worktree: makeWorktree({ path: '/orca/workspaces/app/feature' }), knownOrcaLayouts: layouts }) - ).toBe('orca-managed') + ).toBe('external') expect( classifyWorktreeOwnership({ repo, @@ -130,6 +130,94 @@ describe('worktree ownership classification', () => { ).toBe('external') }) + it('treats explicit Orca creation layout metadata as managed', () => { + const repo = makeRepo() + const settings = makeSettings() + expect( + classifyWorktreeOwnership({ + repo, + settings, + worktree: makeWorktree({ path: '/orca/workspaces/app/feature' }), + meta: makeMeta({ + orcaCreationWorkspaceLayout: { path: '/orca/workspaces', nestWorkspaces: true } + }), + knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) + }) + ).toBe('orca-managed') + }) + + it('does not treat metadata-free nested workspace paths as Orca-managed for new repos', () => { + const repo = makeRepo({ externalWorktreeVisibility: 'hide' }) + const settings = makeSettings() + const detected = toDetectedWorktree({ + repo, + settings, + worktree: makeWorktree({ + path: '/orca/workspaces/app/manual-git-worktree', + isMainWorktree: false + }), + knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) + }) + + expect(detected.ownership).toBe('external') + expect(detected.visible).toBe(false) + }) + + it('does not treat generic discovery metadata on nested workspace paths as Orca-managed', () => { + const repo = makeRepo({ externalWorktreeVisibility: 'hide' }) + const settings = makeSettings() + const detected = toDetectedWorktree({ + repo, + settings, + worktree: makeWorktree({ + path: '/orca/workspaces/app/manual-git-worktree', + isMainWorktree: false + }), + meta: makeMeta({ displayName: 'manual-git-worktree' }), + knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) + }) + + expect(detected.ownership).toBe('external') + expect(detected.visible).toBe(false) + }) + + it('keeps nested workspace paths visible for legacy repos without explicit visibility', () => { + const repo = makeRepo() + const settings = makeSettings() + const detected = toDetectedWorktree({ + repo, + settings, + worktree: makeWorktree({ + path: '/orca/workspaces/app/manual-git-worktree', + isMainWorktree: false + }), + knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) + }) + + expect(detected.ownership).toBe('external') + expect(detected.visible).toBe(true) + }) + + it('hides metadata-free nested workspace paths for legacy repos that hide external worktrees', () => { + const repo = makeRepo({ + externalWorktreeVisibility: 'hide', + externalWorktreeVisibilityLegacy: true + }) + const settings = makeSettings() + const detected = toDetectedWorktree({ + repo, + settings, + worktree: makeWorktree({ + path: '/orca/workspaces/app/manual-git-worktree', + isMainWorktree: false + }), + knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) + }) + + expect(detected.ownership).toBe('external') + expect(detected.visible).toBe(false) + }) + it('treats flat workspace-root descendants as unknown legacy without strong metadata', () => { const repo = makeRepo() const settings = makeSettings({ nestWorkspaces: false }) @@ -172,7 +260,7 @@ describe('worktree ownership classification', () => { worktree: makeWorktree({ path: '/old/workspaces/app/feature' }), knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) }) - ).toBe('orca-managed') + ).toBe('external') }) it('builds known layouts from large workspace history lists', () => { @@ -214,7 +302,7 @@ describe('worktree ownership classification', () => { }), knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) }) - ).toBe('orca-managed') + ).toBe('external') }) it('keeps selected linked checkouts visible without trusting Git main-worktree', () => { diff --git a/src/shared/worktree-ownership.ts b/src/shared/worktree-ownership.ts index 9140f41795b..94ba1d908ff 100644 --- a/src/shared/worktree-ownership.ts +++ b/src/shared/worktree-ownership.ts @@ -1,5 +1,4 @@ import { - getRuntimePathBasename, isRuntimePathAbsolute, isWindowsAbsolutePathLike, normalizeRuntimePathForComparison, @@ -160,15 +159,13 @@ export function classifyWorktreeOwnership(args: { return 'orca-managed' } - if (matchesStrongOrcaCreatePath(args.worktree.path, args.knownOrcaLayouts, args.repo)) { - return 'orca-managed' - } - if (isUnderFlatOrUntrustedOrcaRoot(args.worktree.path, args.knownOrcaLayouts)) { return 'unknown-legacy' } if (canClassifyAsExternal(args.worktree.path, args.knownOrcaLayouts)) { + // Why: a plain `git worktree add` can target Orca's nested workspace + // folder. Only metadata proves Orca created it. return 'external' } @@ -240,6 +237,7 @@ export function areRuntimePathsEqual(leftPath: string, rightPath: string): boole function hasStrongOrcaMetadata(meta: WorktreeMeta | undefined): boolean { return Boolean( meta?.orcaCreatedAt || + meta?.orcaCreationWorkspaceLayout || meta?.createdAt || meta?.createdWithAgent || meta?.pushTarget || @@ -249,38 +247,6 @@ function hasStrongOrcaMetadata(meta: WorktreeMeta | undefined): boolean { ) } -export function matchesStrongOrcaCreatePath( - worktreePath: string, - knownOrcaLayouts: readonly OrcaWorkspaceLayout[], - repo: Pick -): boolean { - const repoName = getRuntimePathBasename(repo.path).replace(/\.git$/i, '') - if (!repoName) { - return false - } - for (const layout of knownOrcaLayouts) { - if (!layout.nestWorkspaces) { - continue - } - const relative = relativePathInsideRoot(layout.path, worktreePath) - if (relative === null) { - continue - } - const segments = splitNormalizedPath(relative) - const caseInsensitive = - isWindowsAbsolutePathLike(layout.path) || isWindowsAbsolutePathLike(worktreePath) - if ( - segments.length === 2 && - normalizePathSegment(segments[0], caseInsensitive) === - normalizePathSegment(repoName, caseInsensitive) && - segments[1].length > 0 - ) { - return true - } - } - return false -} - function isUnderFlatOrUntrustedOrcaRoot( worktreePath: string, knownOrcaLayouts: OrcaWorkspaceLayout[] @@ -313,12 +279,3 @@ function canClassifyAsExternal( } return true } - -function splitNormalizedPath(value: string): string[] { - return normalizeRuntimePathSeparators(value).split('/').filter(Boolean) -} - -function normalizePathSegment(value: string, caseInsensitive: boolean): string { - const normalized = normalizeRuntimePathSeparators(value) - return caseInsensitive ? normalized.toLowerCase() : normalized -} diff --git a/tests/e2e/fixtures/visible-tui-scroll-fixture.cjs b/tests/e2e/fixtures/visible-tui-scroll-fixture.cjs new file mode 100644 index 00000000000..137bc48dee1 --- /dev/null +++ b/tests/e2e/fixtures/visible-tui-scroll-fixture.cjs @@ -0,0 +1,96 @@ +const fs = require('node:fs') + +const ESC = '\x1b' +const MOUSE_REPORT_PATTERN = new RegExp(`${ESC}\\[<(64|65);\\d+;\\d+M`, 'g') + +// Optional flags: +// --log append " " per consumed stdin +// chunk so tests can measure when reports reached the PTY. +// --heavy emit Claude-Code-scale redraw frames (~4KB of styled cells +// per render, ~430KB/s at 120 reports/s — matches output +// volume measured from a real Claude Code session scrolling +// at 120 reports/s) instead of minimal rows. +const args = process.argv.slice(2) +const logIndex = args.indexOf('--log') +const LOG_PATH = logIndex >= 0 ? args[logIndex + 1] : null +const HEAVY_FRAMES = args.includes('--heavy') + +let offset = 0 +let pending = '' + +function write(data) { + process.stdout.write(data) +} + +function visibleRows() { + return Math.max(3, process.stdout.rows || 24) +} + +function visibleCols() { + return Math.max(20, process.stdout.columns || 80) +} + +function heavyRowFiller(row, cols) { + // ~19 bytes per 8 visible columns of styled filler. + const unit = `${ESC}[38;5;${((row * 17) % 200) + 16}m········` + const units = Math.max(0, Math.floor((cols - 24) / 8)) + return `${unit.repeat(units)}${ESC}[0m` +} + +function render() { + const rows = visibleRows() + const cols = visibleCols() + let frame = `${ESC}[?2026h${ESC}[H` + frame += `TUI_SCROLL_READY offset=${offset}${ESC}[K` + for (let row = 1; row < rows; row += 1) { + const label = `TUI_SCROLL_ROW_${String(offset + row - 1).padStart(4, '0')}` + frame += `\r\n${label}${HEAVY_FRAMES ? ` ${heavyRowFiller(row, cols)}` : ''}${ESC}[K` + } + frame += `${ESC}[?2026l` + write(frame) +} + +function cleanup() { + write(`${ESC}[?1003l${ESC}[?1006l${ESC}[?25h${ESC}[?1049l`) + process.exit(0) +} + +process.stdin.setEncoding('utf8') +if (process.stdin.isTTY) { + process.stdin.setRawMode(true) +} +process.stdin.resume() + +write(`${ESC}[?1049h${ESC}[?1003h${ESC}[?1006h${ESC}[?25l${ESC}[2J`) +render() + +process.stdin.on('data', (chunk) => { + if (chunk.includes('\x03') || chunk.includes('q')) { + cleanup() + } + + pending += chunk + let match + let lastIndex = 0 + let reportsInChunk = 0 + + MOUSE_REPORT_PATTERN.lastIndex = 0 + while ((match = MOUSE_REPORT_PATTERN.exec(pending)) !== null) { + offset = Math.max(0, offset + (match[1] === '65' ? 1 : -1)) + reportsInChunk += 1 + lastIndex = MOUSE_REPORT_PATTERN.lastIndex + } + + if (lastIndex > 0) { + if (LOG_PATH) { + fs.appendFileSync(LOG_PATH, `${Date.now()} ${reportsInChunk}\n`) + } + pending = pending.slice(lastIndex) + render() + return + } + + pending = pending.slice(-32) +}) + +process.on('SIGINT', cleanup) diff --git a/tests/e2e/terminal-tui-wheel-drain.spec.ts b/tests/e2e/terminal-tui-wheel-drain.spec.ts new file mode 100644 index 00000000000..c228159852c --- /dev/null +++ b/tests/e2e/terminal-tui-wheel-drain.spec.ts @@ -0,0 +1,195 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + execInTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' + +const VISIBLE_TUI_FIXTURE_PATH = path.join( + process.cwd(), + 'tests/e2e/fixtures/visible-tui-scroll-fixture.cjs' +) + +// Reports must stop reaching the PTY shortly after the gesture ends. Before +// the input-write-queue coalescing fix, each SGR report drained in its own +// >=4ms-clamped macrotask turn; a dense trackpad stream delivered through the +// real input pipeline (which outprioritizes timers) plus TUI-scale redraw +// output starved that drain, and the backlog replayed for seconds after the +// fingers left the trackpad. +const MAX_ARRIVAL_LAG_MS = 900 +const WHEEL_EVENTS = 240 + +type WheelStreamResult = { + dispatchedEvents: number + inputEndWallClockMs: number +} + +async function startHeavyTuiFixture(page: Page, logPath: string): Promise { + await waitForSessionReady(page) + await waitForActiveWorktree(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + await page.evaluate(() => + window.__store?.getState().updateSettings({ terminalTuiScrollSensitivity: 1 }) + ) + + const ptyId = await waitForActivePanePtyId(page) + await execInTerminal( + page, + ptyId, + `node ${JSON.stringify(VISIBLE_TUI_FIXTURE_PATH)} --heavy --log ${JSON.stringify(logPath)}` + ) + + await expect + .poll( + () => + page.evaluate(() => { + 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?.terminal.element?.classList.contains('enable-mouse-events') ?? false + }), + { timeout: 15_000, message: 'fixture did not enable mouse reporting' } + ) + .toBe(true) +} + +async function terminalWheelTarget( + page: Page +): Promise<{ x: number; y: number; cellHeight: number }> { + return page.evaluate(() => { + 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 + const screen = pane?.terminal.element?.querySelector('.xterm-screen') + if (!pane?.terminal || !screen) { + throw new Error('Active terminal screen unavailable') + } + const rect = screen.getBoundingClientRect() + return { + x: rect.left + rect.width / 2, + y: rect.top + Math.min(rect.height - 1, 40), + cellHeight: + pane.terminal._core?._renderService?.dimensions?.css?.cell?.height ?? + rect.height / pane.terminal.rows + } + }) +} + +/** + * Drives real wheel input (CDP mouse events through the compositor input + * pipeline — the same priority class as physical trackpad input) rather than + * synthetic DOM dispatchEvent, so input can genuinely compete with the + * renderer's timer-based PTY input drain the way a physical gesture does. + */ +async function dispatchTrackpadWheelStream( + page: Page, + options: { alternate: boolean; events: number; deltaY: number } +): Promise { + const target = await terminalWheelTarget(page) + await page.mouse.move(target.x, target.y) + for (let i = 0; i < options.events; i += 1) { + const direction = options.alternate && Math.floor(i / 18) % 2 === 1 ? -1 : 1 + // No artificial sleep: CDP round-trips pace this near real trackpad rates + // while keeping the renderer's input queue continuously occupied. + await page.mouse.wheel(0, direction * options.deltaY) + } + const inputEndWallClockMs = await page.evaluate(() => Date.now()) + return { dispatchedEvents: options.events, inputEndWallClockMs } +} + +type ReportArrival = { atMs: number; reports: number } + +function readReportArrivalLog(logPath: string): ReportArrival[] { + if (!fs.existsSync(logPath)) { + return [] + } + return fs + .readFileSync(logPath, 'utf8') + .split('\n') + .filter(Boolean) + .map((line) => { + const [atMs, reports] = line.split(' ') + return { atMs: Number(atMs), reports: Number(reports) } + }) +} + +function summarizeArrivals( + arrivals: ReportArrival[], + input: WheelStreamResult +): { arrivalLagMs: number; chunks: number; totalReports: number } { + const totalReports = arrivals.reduce((sum, entry) => sum + entry.reports, 0) + const lastArrivalMs = arrivals.at(-1)?.atMs ?? 0 + return { + arrivalLagMs: lastArrivalMs - input.inputEndWallClockMs, + chunks: arrivals.length, + totalReports + } +} + +test.describe('terminal TUI wheel report drain', () => { + test('dense trackpad-like wheel stream reaches the PTY while the gesture happens', async ({ + orcaPage + }) => { + const logPath = path.join(os.tmpdir(), `tui-wheel-drain-${Date.now()}.log`) + await startHeavyTuiFixture(orcaPage, logPath) + + const target = await terminalWheelTarget(orcaPage) + const input = await dispatchTrackpadWheelStream(orcaPage, { + alternate: false, + events: WHEEL_EVENTS, + deltaY: Math.min(49, target.cellHeight) + }) + // Give a laggy drain ample time to expose itself before reading the log. + await orcaPage.waitForTimeout(8000) + + const summary = summarizeArrivals(readReportArrivalLog(logPath), input) + fs.rmSync(logPath, { force: true }) + console.log(`[tui-wheel-drain] dense: ${JSON.stringify(summary)}`) + + // The full gesture distance must reach the TUI (no dead/eaten scrolls)... + expect(summary.totalReports, JSON.stringify(summary)).toBeGreaterThanOrEqual(WHEEL_EVENTS - 10) + // ...while the gesture happens, not replayed 1-by-1 afterwards. + expect(summary.arrivalLagMs, JSON.stringify(summary)).toBeLessThanOrEqual(MAX_ARRIVAL_LAG_MS) + }) + + test('aggressive alternating trackpad-like gesture does not replay after input ends', async ({ + orcaPage + }) => { + const logPath = path.join(os.tmpdir(), `tui-wheel-drain-alt-${Date.now()}.log`) + await startHeavyTuiFixture(orcaPage, logPath) + + const target = await terminalWheelTarget(orcaPage) + const input = await dispatchTrackpadWheelStream(orcaPage, { + alternate: true, + events: WHEEL_EVENTS, + deltaY: Math.min(49, target.cellHeight) + }) + await orcaPage.waitForTimeout(8000) + + const summary = summarizeArrivals(readReportArrivalLog(logPath), input) + fs.rmSync(logPath, { force: true }) + console.log(`[tui-wheel-drain] alternate: ${JSON.stringify(summary)}`) + + expect(summary.arrivalLagMs, JSON.stringify(summary)).toBeLessThanOrEqual(MAX_ARRIVAL_LAG_MS) + }) +}) diff --git a/tests/e2e/terminal-tui-wheel-reports.spec.ts b/tests/e2e/terminal-tui-wheel-reports.spec.ts index 0e7ae648422..a2718e2d0ab 100644 --- a/tests/e2e/terminal-tui-wheel-reports.spec.ts +++ b/tests/e2e/terminal-tui-wheel-reports.spec.ts @@ -1,7 +1,12 @@ import type { Page } from '@stablyai/playwright-test' +import path from 'node:path' import { test, expect } from './helpers/orca-app' import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' -import { waitForActiveTerminalManager } from './helpers/terminal' +import { + execInTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' type WheelReportSample = { reportCount: number @@ -9,7 +14,15 @@ type WheelReportSample = { reports: string[] } +type TimedWheelReportSample = WheelReportSample & { + elapsedMs: number +} + const PHYSICAL_MOUSE_WHEEL_DELTA = -120 +const VISIBLE_TUI_FIXTURE_PATH = path.join( + process.cwd(), + 'tests/e2e/fixtures/visible-tui-scroll-fixture.cjs' +) async function probeSmallMouseWheelReports( page: Page, @@ -91,6 +104,181 @@ async function probeSmallMouseWheelReports( ) } +async function probeTimedSmallMouseWheelReports( + page: Page, + options: { + drainWaitMs: number + intervalMs: number + ticks: number + } +): Promise<{ + reports: string[] + samples: TimedWheelReportSample[] +}> { + return page.evaluate( + async ({ drainWaitMs, intervalMs, physicalMouseWheelDelta, tickCount }) => { + 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 + if (!pane?.terminal.element) { + throw new Error('Active terminal pane unavailable') + } + + const reports: string[] = [] + const disposable = pane.terminal.onData((data) => reports.push(data)) + try { + await new Promise((resolve) => pane.terminal.write('\x1b[?1003h\x1b[?1006h', resolve)) + await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))) + if (!pane.terminal.element.classList.contains('enable-mouse-events')) { + throw new Error('Mouse reporting mode did not activate') + } + + const screen = pane.terminal.element.querySelector('.xterm-screen') + if (!screen) { + throw new Error('Active terminal screen unavailable') + } + + const rect = screen.getBoundingClientRect() + const cellHeight = + pane.terminal._core?._renderService?.dimensions?.css?.cell?.height ?? + rect.height / pane.terminal.rows + const scrollSensitivity = Number(pane.terminal.options.scrollSensitivity ?? 1) + // Why: this is a notched mouse wheel event that Chromium can surface as a + // small pixel delta; xterm's <50px damping accumulates it for four ticks. + const deltaY = (cellHeight * 0.28) / (scrollSensitivity * 0.3) + const samples: TimedWheelReportSample[] = [] + const startedAt = performance.now() + + for (let i = 0; i < tickCount; i += 1) { + const before = reports.length + const event = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + clientX: rect.left + rect.width / 2, + clientY: rect.top + Math.min(rect.height - 1, cellHeight * 4), + deltaMode: WheelEvent.DOM_DELTA_PIXEL, + deltaY + }) + Object.defineProperty(event, 'wheelDeltaY', { + configurable: true, + value: physicalMouseWheelDelta + }) + Object.defineProperty(event, 'wheelDelta', { + configurable: true, + value: physicalMouseWheelDelta + }) + pane.terminal.element.dispatchEvent(event) + await new Promise((resolve) => setTimeout(resolve, intervalMs)) + samples.push({ + elapsedMs: performance.now() - startedAt, + reportCount: reports.length, + reportDelta: reports.length - before, + reports: [...reports] + }) + } + + await new Promise((resolve) => setTimeout(resolve, drainWaitMs)) + + return { + reports: [...reports], + samples + } + } finally { + disposable.dispose() + } + }, + { + drainWaitMs: options.drainWaitMs, + intervalMs: options.intervalMs, + physicalMouseWheelDelta: PHYSICAL_MOUSE_WHEEL_DELTA, + tickCount: options.ticks + } + ) +} + +async function readVisibleTuiOffset(page: Page): Promise { + return page.evaluate(() => { + 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 + if (!pane?.terminal) { + return null + } + + for (let row = 0; row < pane.terminal.rows; row += 1) { + const text = pane.terminal.buffer.active.getLine(row)?.translateToString(true) ?? '' + const match = /TUI_SCROLL_ROW_(\d+)/.exec(text) + if (match) { + return Number(match[1]) + } + } + return null + }) +} + +async function dispatchTuiWheel( + page: Page, + options: { + deltaY: number + wheelDeltaY?: number + } +): Promise { + await page.evaluate(({ deltaY, wheelDeltaY }) => { + 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 + if (!pane?.terminal.element) { + throw new Error('Active terminal pane unavailable') + } + + const screen = pane.terminal.element.querySelector('.xterm-screen') + if (!screen) { + throw new Error('Active terminal screen unavailable') + } + const rect = screen.getBoundingClientRect() + const event = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + clientX: rect.left + rect.width / 2, + clientY: rect.top + Math.min(rect.height - 1, 40), + deltaMode: WheelEvent.DOM_DELTA_PIXEL, + deltaY + }) + if (wheelDeltaY !== undefined) { + Object.defineProperty(event, 'wheelDeltaY', { + configurable: true, + value: wheelDeltaY + }) + Object.defineProperty(event, 'wheelDelta', { + configurable: true, + value: wheelDeltaY + }) + } + pane.terminal.element.dispatchEvent(event) + }, options) +} + test.describe('terminal TUI wheel reports', () => { test('notched mouse wheel ticks produce immediate mouse-reporting TUI scroll reports', async ({ orcaPage @@ -111,4 +299,123 @@ test.describe('terminal TUI wheel reports', () => { ).toEqual([1, 1, 1, 1]) expect(samples.at(-1)?.reports.join('')).toContain('\x1b[<65;') }) + + test('fullscreen mouse-reporting TUI scroll distance follows wheel magnitude @headful', async ({ + electronApp, + orcaPage + }) => { + await electronApp.evaluate(({ BrowserWindow }) => { + const win = BrowserWindow.getAllWindows()[0] + if (!win) { + throw new Error('No BrowserWindow available') + } + if (win.isMinimized()) { + win.restore() + } + win.show() + win.focus() + win.setFullScreen(true) + }) + await expect + .poll(() => + electronApp.evaluate( + ({ BrowserWindow }) => BrowserWindow.getAllWindows()[0]?.isFullScreen() ?? false + ) + ) + .toBe(true) + await orcaPage.waitForTimeout(1200) + + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await orcaPage.evaluate(() => + window.__store?.getState().updateSettings({ terminalTuiScrollSensitivity: 1 }) + ) + + const ptyId = await waitForActivePanePtyId(orcaPage) + await execInTerminal(orcaPage, ptyId, `node ${JSON.stringify(VISIBLE_TUI_FIXTURE_PATH)}`) + + await expect + .poll(() => readVisibleTuiOffset(orcaPage), { + timeout: 10_000, + message: 'visible fullscreen TUI did not render numbered rows' + }) + .toBe(0) + + await dispatchTuiWheel(orcaPage, { + deltaY: 10, + wheelDeltaY: PHYSICAL_MOUSE_WHEEL_DELTA + }) + await expect + .poll(() => readVisibleTuiOffset(orcaPage), { + timeout: 5_000, + message: 'single notched wheel tick did not visibly scroll the TUI' + }) + .toBe(1) + + const cellHeight = await orcaPage.evaluate(() => { + 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 + const screen = pane?.terminal.element?.querySelector('.xterm-screen') + if (!pane?.terminal || !screen) { + throw new Error('Active terminal screen unavailable') + } + return screen.getBoundingClientRect().height / pane.terminal.rows + }) + + await dispatchTuiWheel(orcaPage, { + deltaY: cellHeight * 12, + wheelDeltaY: PHYSICAL_MOUSE_WHEEL_DELTA * 12 + }) + + await expect + .poll(() => readVisibleTuiOffset(orcaPage), { + timeout: 5_000, + message: 'larger wheel movement did not visibly move the TUI farther' + }) + .toBe(7) + }) + + test('TUI scroll setting scales notched mouse wheel reports', async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await orcaPage.evaluate(() => + window.__store?.getState().updateSettings({ terminalTuiScrollSensitivity: 5 }) + ) + + const slow = await probeTimedSmallMouseWheelReports(orcaPage, { + drainWaitMs: 120, + intervalMs: 220, + ticks: 5 + }) + await orcaPage.waitForTimeout(220) + const paced = await probeTimedSmallMouseWheelReports(orcaPage, { + drainWaitMs: 220, + intervalMs: 80, + ticks: 5 + }) + + expect( + slow.samples.map((sample) => sample.reportDelta), + `slow per-tick SGR mouse reports: ${JSON.stringify(slow.samples)}` + ).toEqual([5, 5, 5, 5, 5]) + expect( + paced.samples.map((sample) => sample.reportDelta), + `paced per-tick SGR mouse reports: ${JSON.stringify(paced.samples)}` + ).toEqual([5, 5, 5, 5, 5]) + expect(paced.reports.length, `paced SGR mouse reports: ${JSON.stringify(paced.samples)}`).toBe( + 25 + ) + }) }) diff --git a/tools/benchmarks/results/terminal-perf-baseline-2026-07-02T07-23-46-933Z.json b/tools/benchmarks/results/terminal-perf-baseline-2026-07-02T07-23-46-933Z.json new file mode 100644 index 00000000000..87d8634572f --- /dev/null +++ b/tools/benchmarks/results/terminal-perf-baseline-2026-07-02T07-23-46-933Z.json @@ -0,0 +1,1041 @@ +{ + "label": "baseline", + "startedAt": "2026-07-02T07:23:46.933Z", + "platform": "win32 10.0.26200", + "shell": "default", + "args": { + "iterations": 8, + "switches": 24, + "cycles": 12 + }, + "scenarios": { + "tab-create": { + "samples": [ + { + "index": 0, + "storeCreateMs": 2.700000047683716, + "ptyBindMs": 5853.5, + "firstOutputMs": 5875.900000035763, + "paintSettleMs": 5878.100000023842, + "timedOut": false + }, + { + "index": 1, + "storeCreateMs": 1.9000000357627869, + "ptyBindMs": 476.9000000357628, + "firstOutputMs": 489.10000002384186, + "paintSettleMs": 493.10000002384186, + "timedOut": false + }, + { + "index": 2, + "storeCreateMs": 1.9000000357627869, + "ptyBindMs": 381.60000002384186, + "firstOutputMs": 401, + "paintSettleMs": 404.10000002384186, + "timedOut": false + }, + { + "index": 3, + "storeCreateMs": 1.300000011920929, + "ptyBindMs": 391.10000002384186, + "firstOutputMs": 405.80000001192093, + "paintSettleMs": 408.10000002384186, + "timedOut": false + }, + { + "index": 4, + "storeCreateMs": 1.5, + "ptyBindMs": 454, + "firstOutputMs": 467.80000001192093, + "paintSettleMs": 474, + "timedOut": false + }, + { + "index": 5, + "storeCreateMs": 2, + "ptyBindMs": 527.6999999880791, + "firstOutputMs": 544.5, + "paintSettleMs": 551.3999999761581, + "timedOut": false + }, + { + "index": 6, + "storeCreateMs": 1.9000000357627869, + "ptyBindMs": 483.60000002384186, + "firstOutputMs": 498.5, + "paintSettleMs": 501.10000002384186, + "timedOut": false + }, + { + "index": 7, + "storeCreateMs": 1.600000023841858, + "ptyBindMs": 492.89999997615814, + "firstOutputMs": 509.19999998807907, + "paintSettleMs": 517, + "timedOut": false + } + ], + "longtasks": [ + 411, 65, 184, 124, 191, 163, 141, 183, 59, 158, 217, 61, 61, 253, 89, 70, 248, 152, 73, 170, + 184, 160, 178, 176, 151 + ] + }, + "tab-switch": { + "samples": [ + { + "index": 0, + "targetTabId": "3db20953-661a-4e65-8e4c-ca6a6f9d0538", + "storeMs": 1.100000023841858, + "paneVisibleMs": 1.100000023841858, + "paintSettleMs": 109, + "timedOut": false + }, + { + "index": 1, + "targetTabId": "f4dca755-e41c-4f03-aa25-ed67526ca90e", + "storeMs": 1.699999988079071, + "paneVisibleMs": 1.699999988079071, + "paintSettleMs": 114.5, + "timedOut": false + }, + { + "index": 2, + "targetTabId": "8f40ca49-476c-4959-ae9f-f976293e0b62", + "storeMs": 1.699999988079071, + "paneVisibleMs": 1.699999988079071, + "paintSettleMs": 138.5, + "timedOut": false + }, + { + "index": 3, + "targetTabId": "d7adfbbb-a7eb-48ed-876b-440835e7174f", + "storeMs": 1.100000023841858, + "paneVisibleMs": 1.100000023841858, + "paintSettleMs": 73, + "timedOut": false + }, + { + "index": 4, + "targetTabId": "3db20953-661a-4e65-8e4c-ca6a6f9d0538", + "storeMs": 1.800000011920929, + "paneVisibleMs": 1.800000011920929, + "paintSettleMs": 79.60000002384186, + "timedOut": false + }, + { + "index": 5, + "targetTabId": "f4dca755-e41c-4f03-aa25-ed67526ca90e", + "storeMs": 2, + "paneVisibleMs": 2, + "paintSettleMs": 43.80000001192093, + "timedOut": false + }, + { + "index": 6, + "targetTabId": "8f40ca49-476c-4959-ae9f-f976293e0b62", + "storeMs": 2.5, + "paneVisibleMs": 2.5, + "paintSettleMs": 125.89999997615814, + "timedOut": false + }, + { + "index": 7, + "targetTabId": "d7adfbbb-a7eb-48ed-876b-440835e7174f", + "storeMs": 1.699999988079071, + "paneVisibleMs": 1.699999988079071, + "paintSettleMs": 116.5, + "timedOut": false + }, + { + "index": 8, + "targetTabId": "3db20953-661a-4e65-8e4c-ca6a6f9d0538", + "storeMs": 1.399999976158142, + "paneVisibleMs": 1.399999976158142, + "paintSettleMs": 51.80000001192093, + "timedOut": false + }, + { + "index": 9, + "targetTabId": "f4dca755-e41c-4f03-aa25-ed67526ca90e", + "storeMs": 1, + "paneVisibleMs": 1, + "paintSettleMs": 45.19999998807907, + "timedOut": false + }, + { + "index": 10, + "targetTabId": "8f40ca49-476c-4959-ae9f-f976293e0b62", + "storeMs": 1.600000023841858, + "paneVisibleMs": 1.600000023841858, + "paintSettleMs": 50.30000001192093, + "timedOut": false + }, + { + "index": 11, + "targetTabId": "d7adfbbb-a7eb-48ed-876b-440835e7174f", + "storeMs": 1.399999976158142, + "paneVisibleMs": 1.399999976158142, + "paintSettleMs": 97.89999997615814, + "timedOut": false + }, + { + "index": 12, + "targetTabId": "3db20953-661a-4e65-8e4c-ca6a6f9d0538", + "storeMs": 1.0999999642372131, + "paneVisibleMs": 1.0999999642372131, + "paintSettleMs": 112.5, + "timedOut": false + }, + { + "index": 13, + "targetTabId": "f4dca755-e41c-4f03-aa25-ed67526ca90e", + "storeMs": 2.100000023841858, + "paneVisibleMs": 2.100000023841858, + "paintSettleMs": 48.69999998807907, + "timedOut": false + }, + { + "index": 14, + "targetTabId": "8f40ca49-476c-4959-ae9f-f976293e0b62", + "storeMs": 1.399999976158142, + "paneVisibleMs": 1.5, + "paintSettleMs": 35.09999996423721, + "timedOut": false + }, + { + "index": 15, + "targetTabId": "d7adfbbb-a7eb-48ed-876b-440835e7174f", + "storeMs": 1, + "paneVisibleMs": 1, + "paintSettleMs": 28.30000001192093, + "timedOut": false + }, + { + "index": 16, + "targetTabId": "3db20953-661a-4e65-8e4c-ca6a6f9d0538", + "storeMs": 0.8999999761581421, + "paneVisibleMs": 0.8999999761581421, + "paintSettleMs": 35.09999996423721, + "timedOut": false + }, + { + "index": 17, + "targetTabId": "f4dca755-e41c-4f03-aa25-ed67526ca90e", + "storeMs": 1.300000011920929, + "paneVisibleMs": 1.300000011920929, + "paintSettleMs": 150.70000004768372, + "timedOut": false + }, + { + "index": 18, + "targetTabId": "8f40ca49-476c-4959-ae9f-f976293e0b62", + "storeMs": 1.9000000357627869, + "paneVisibleMs": 1.9000000357627869, + "paintSettleMs": 95.60000002384186, + "timedOut": false + }, + { + "index": 19, + "targetTabId": "d7adfbbb-a7eb-48ed-876b-440835e7174f", + "storeMs": 2.099999964237213, + "paneVisibleMs": 2.099999964237213, + "paintSettleMs": 75.79999995231628, + "timedOut": false + }, + { + "index": 20, + "targetTabId": "3db20953-661a-4e65-8e4c-ca6a6f9d0538", + "storeMs": 1.2000000476837158, + "paneVisibleMs": 1.2000000476837158, + "paintSettleMs": 40.30000001192093, + "timedOut": false + }, + { + "index": 21, + "targetTabId": "f4dca755-e41c-4f03-aa25-ed67526ca90e", + "storeMs": 1.5999999642372131, + "paneVisibleMs": 1.5999999642372131, + "paintSettleMs": 30.099999964237213, + "timedOut": false + }, + { + "index": 22, + "targetTabId": "8f40ca49-476c-4959-ae9f-f976293e0b62", + "storeMs": 1.399999976158142, + "paneVisibleMs": 1.399999976158142, + "paintSettleMs": 86.89999997615814, + "timedOut": false + }, + { + "index": 23, + "targetTabId": "d7adfbbb-a7eb-48ed-876b-440835e7174f", + "storeMs": 1.300000011920929, + "paneVisibleMs": 1.300000011920929, + "paintSettleMs": 133, + "timedOut": false + } + ], + "longtasks": [98, 109, 130, 66, 71, 117, 110, 94, 100, 144, 91, 64, 82, 122] + }, + "workspace-switch": { + "samples": [ + { + "index": 0, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/repo", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/repo@@f226ee15", + "activationMs": 202, + "ptyBindMs": 204, + "paintSettleMs": 211 + }, + { + "index": 1, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-one", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-one@@d00bc639", + "activationMs": 406, + "ptyBindMs": 550, + "paintSettleMs": 563 + }, + { + "index": 2, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two@@e12f992a", + "activationMs": 373, + "ptyBindMs": 476, + "paintSettleMs": 488 + }, + { + "index": 3, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/repo", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/repo@@f226ee15", + "activationMs": 205, + "ptyBindMs": 251, + "paintSettleMs": 258 + }, + { + "index": 4, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-one", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-one@@d00bc639", + "activationMs": 345, + "ptyBindMs": 369, + "paintSettleMs": 379 + }, + { + "index": 5, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two@@e12f992a", + "activationMs": 149, + "ptyBindMs": 177, + "paintSettleMs": 184 + }, + { + "index": 6, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/repo", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/repo@@f226ee15", + "activationMs": 147, + "ptyBindMs": 162, + "paintSettleMs": 170 + }, + { + "index": 7, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-one", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-one@@d00bc639", + "activationMs": 178, + "ptyBindMs": 202, + "paintSettleMs": 208 + }, + { + "index": 8, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two@@e12f992a", + "activationMs": 336, + "ptyBindMs": 366, + "paintSettleMs": 375 + }, + { + "index": 9, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/repo", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/repo@@f226ee15", + "activationMs": 157, + "ptyBindMs": 192, + "paintSettleMs": 200 + }, + { + "index": 10, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-one", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-one@@d00bc639", + "activationMs": 140, + "ptyBindMs": 164, + "paintSettleMs": 173 + }, + { + "index": 11, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two@@e12f992a", + "activationMs": 159, + "ptyBindMs": 188, + "paintSettleMs": 199 + } + ] + } + }, + "summaries": [ + { + "name": "tab-create", + "samples": 8, + "timedOut": 0, + "fields": { + "storeCreateMs": { + "count": 8, + "median": 2, + "p95": 3, + "max": 3 + }, + "ptyBindMs": { + "count": 8, + "median": 484, + "p95": 5854, + "max": 5854 + }, + "firstOutputMs": { + "count": 8, + "median": 499, + "p95": 5876, + "max": 5876 + }, + "paintSettleMs": { + "count": 8, + "median": 501, + "p95": 5878, + "max": 5878 + } + }, + "longtaskCount": 25, + "longtaskMaxMs": 411 + }, + { + "name": "tab-switch", + "samples": 24, + "timedOut": 0, + "fields": { + "storeMs": { + "count": 24, + "median": 1, + "p95": 2, + "max": 3 + }, + "paneVisibleMs": { + "count": 24, + "median": 2, + "p95": 2, + "max": 3 + }, + "paintSettleMs": { + "count": 24, + "median": 80, + "p95": 139, + "max": 151 + } + }, + "longtaskCount": 14, + "longtaskMaxMs": 144 + }, + { + "name": "workspace-switch", + "samples": 12, + "timedOut": 0, + "fields": { + "activationMs": { + "count": 12, + "median": 202, + "p95": 373, + "max": 406 + }, + "ptyBindMs": { + "count": 12, + "median": 204, + "p95": 476, + "max": 550 + }, + "paintSettleMs": { + "count": 12, + "median": 211, + "p95": 488, + "max": 563 + } + }, + "longtaskCount": 0, + "longtaskMaxMs": 0 + } + ], + "finalDiagnostics": { + "hasStore": true, + "workspaceSessionReady": true, + "hydrationSucceeded": true, + "activeView": "terminal", + "activeRepoId": "f93c43c3-57ef-4811-8edc-73d924918ed8", + "activeWorktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two", + "activeTabType": "terminal", + "activeTabId": "2a067075-6acf-4aca-a0a6-bc88c1e6abc7", + "terminalGpuAcceleration": "auto", + "repoCount": 1, + "worktreeCountsByRepo": { + "f93c43c3-57ef-4811-8edc-73d924918ed8": 3 + }, + "ptyIdsByTabId": [ + "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two@@e12f992a" + ], + "paneManagerCount": 3, + "activePane": { + "id": 1, + "leafId": "6a3ca793-864f-4182-bec5-5b044a884b9b", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two@@e12f992a", + "cols": 175, + "rows": 73, + "buffer": { + "baseY": 0, + "viewportY": 0, + "cursorY": 0, + "length": 73 + } + }, + "renderingDiagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": false, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ], + "allPaneManagersDiagnostics": [ + { + "tabId": "ab3a14da-e087-4677-9fa8-df990c149951", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": true, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": false + } + ] + }, + { + "tabId": "afdd9731-982a-4255-8c0e-b4360453f433", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": true, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": false + } + ] + }, + { + "tabId": "2a067075-6acf-4aca-a0a6-bc88c1e6abc7", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": false, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ] + } + ], + "webglContextCounts": { + "attachedWebglCount": 1, + "deferredWebglCount": 2, + "managerCount": 3, + "paneCount": 3 + }, + "webglIdentity": { + "available": true, + "vendor": "Google Inc. (Intel)", + "renderer": "ANGLE (Intel, Intel(R) Arc(TM) Graphics (0x00007D55) Direct3D11 vs_5_0 ps_5_0, D3D11)" + }, + "rendererProbe": { + "intervalMs": 50, + "last": 50268.60000002384, + "maxDriftMs": 440.10000002384186, + "samples": 335, + "startedAt": 25060.600000023842, + "lastTickAt": 50268.60000002384 + }, + "ptySessions": { + "value": [ + { + "id": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/repo@@f226ee15", + "cwd": "", + "title": "shell" + }, + { + "id": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-one@@d00bc639", + "cwd": "", + "title": "shell" + }, + { + "id": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two@@e12f992a", + "cwd": "", + "title": "shell" + } + ] + }, + "rendererDeliveryDebug": { + "value": { + "pendingPtyCount": 0, + "pendingChars": 0, + "maxPendingCharsByPty": 0, + "rendererInFlightPtyCount": 0, + "rendererInFlightChars": 0, + "maxRendererInFlightCharsByPty": 0, + "activeRendererPtyCount": 1, + "flushScheduled": false, + "peakPendingChars": 82, + "peakMaxPendingCharsByPty": 82, + "peakRendererInFlightChars": 82, + "peakMaxRendererInFlightCharsByPty": 82, + "ackGatedFlushSkipCount": 0 + } + } + }, + "cleanupErrors": [], + "elapsedMs": 71875, + "appLogsTail": [ + { + "source": "stderr", + "line": "https://electronjs.org/docs/tutorial/security.", + "at": 1782977071571 + }, + { + "source": "stderr", + "line": "This warning will not show up", + "at": 1782977071571 + }, + { + "source": "stderr", + "line": "once the app is packaged.\", source: node:electron/js2c/sandbox_bundle (2)", + "at": 1782977071571 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-settings-done t=30952 rendererT=23288 durationMs=454", + "at": 1782977071792 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-settings-done t=31069 rendererT=23405 durationMs=533", + "at": 1782977071909 + }, + { + "source": "stderr", + "line": "[50192:0702/032432.013:INFO:CONSOLE:1078] \"%cReact Grab v0.1.34%c", + "at": 1782977072013 + }, + { + "source": "stderr", + "line": "https://react-grab.com background: #330039; color: #ffffff; border: 1px solid #d75fcb; padding: 4px 4px 4px 24px; border-radius: 4px; background-image: url(\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCAyOTQgMjk0Ij48cGF0aCBmaWxsPSIjZmY0MGUwIiBkPSJNMTQ1IDQ3YzI1LTIwIDUwLTI3IDY3LTE3IDE2IDkgMjMgMzAgMjAgNjAgMCAyLTEgNS0xIDdsLTIgMTNoLTFsLTEyLTRjLTgtMy0xNy01LTI1LTZsLTE3LTNjLTEwLTEtMjAtMS0yOS0xLTEwIDAtMjAgMC0yOSAxLTYgOC0xMSAxNi0xNiAyNC01IDktOSAxNy0xMyAyNiA0IDkgOCAxOCAxMyAyNnMxMCAxNiAxNiAyNGwxMSAxNGM1IDcgMTEgMTMgMTggMTlsMTAgOHMtMSAwIDAgMGwtMTEgOWMtMTcgMTQtMzUgMjItNDkgMjItNiAwLTEzLTItMTgtNS0xNi05LTIzLTMwLTIwLTU5IDEtMyAxLTUgMS04LTMwLTEyLTQ4LTI5LTQ4LTUwIDAtMTggMTQtMzUgNDEtNDcgMi0xIDUtMiA3LTMgMC0yIDAtNS0xLTctMy0zMCA0LTUxIDIwLTYwIDE4LTEwIDQyLTMgNjggMTdNNzEgMjAxYy0xIDItMSA0LTEgNS0yIDI0IDMgNDEgMTMgNDdoMWMxMSA3IDMwIDEgNTEtMTUtMTAtOS0xOC0xOC0yNi0yOS0xMy0xLTI2LTQtMzgtOG05LTM4Yy0zIDktNSAxNy03IDI2IDggMiAxNyA0IDI1IDYtMy01LTYtMTAtOS0xNi0zLTUtNi0xMC05LTE2bS0xOS01M2MtMiAxLTMgMS01IDItMjEgMTAtMzQgMjMtMzQgMzUgMCAxMyAxNCAyNyAzOSAzNyAzLTEyIDctMjUgMTItMzctNS0xMi05LTI0LTEyLTM3bTM3LTEwYy04IDEtMTcgMy0yNSA2IDIgOCA0IDE2IDcgMjUgMy01IDYtMTEgOS0xNnptLTMtNjFjLTQtMS04IDAtMTIgMi0xMCA3LTE1IDI0LTEzIDQ3IDAgMiAwIDMgMSA1IDEyLTQgMjUtNiAzOC04IDgtMTAgMTYtMjAgMjYtMjktMTUtMTEtMjktMTctNDAtMTdtMTExIDJjLTQtMi04LTMtMTItMi0xMSAwLTI1IDUtNDAgMTcgMTAgOSAxOSAxOSAyNiAyOSAxMyAyIDI2IDQgMzkgOHYtNWMzLTIzLTItNDAtMTMtNDdtLTYxIDIzYy03IDYtMTMgMTMtMTkgMTloMzdjLTYtNi0xMi0xMy0xOC0xOSIvPjxtYXNrIGlkPSJhIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJtMjM1IDg1LTEzMyAyNyAyOCAxMzMgMTMzLTI3WiIvPjwvbWFzaz48cGF0aCBmaWxsPSIjZmY0MGUwIiBkPSJtMTM3IDEzMCA3NiAxMWM4IDEgOSAxMSAzIDE1bC0yOCAxNi00IDMyYy0xIDgtMTAgMTAtMTQgNGwtNDEtNjZjLTMtNiAxLTEzIDgtMTIiIG1hc2s9InVybCgjYSkiLz48L3N2Zz4=\"); background-size: 16px 16px; background-repeat: no-repeat; background-position: 4px center; display: inline-block; margin-bottom: 4px; \", source: http://localhost:5173/@fs/C:/Users/jinwo/orca/workspaces/orca/windows-performance-improvement/node_modules/.vite/deps/react-grab.js?v=e74fde05 (1078)", + "at": 1782977072013 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=31229 maxGapMs=223", + "at": 1782977072070 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-repos-done t=31330 rendererT=23666 durationMs=378", + "at": 1782977072171 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-repos-done t=31330 rendererT=23667 durationMs=262", + "at": 1782977072171 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-project-groups-done t=31425 rendererT=23762 durationMs=95", + "at": 1782977072266 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-project-groups-done t=31426 rendererT=23762 durationMs=96", + "at": 1782977072266 + }, + { + "source": "stderr", + "line": "[50192:0702/032432.274:INFO:CONSOLE:1080] \"[React Grab] v0.1.34 is outdated (latest: v0.1.47). Run `npx grab@latest upgrade` to upgrade.\", source: http://localhost:5173/@fs/C:/Users/jinwo/orca/workspaces/orca/windows-performance-improvement/node_modules/.vite/deps/react-grab.js?v=e74fde05 (1080)", + "at": 1782977072274 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-folder-workspaces-done t=31469 rendererT=23805 durationMs=43", + "at": 1782977072309 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktrees-done t=31469 rendererT=23806 durationMs=1", + "at": 1782977072310 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-folder-workspaces-done t=31470 rendererT=23807 durationMs=44", + "at": 1782977072311 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktrees-done t=31470 rendererT=23807 durationMs=0", + "at": 1782977072311 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktree-lineage-done t=31486 rendererT=23823 durationMs=16", + "at": 1782977072327 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktree-lineage-done t=31490 rendererT=23827 durationMs=20", + "at": 1782977072331 + }, + { + "source": "stderr", + "line": "[startup] renderer-ui-get-done t=31511 rendererT=23848 durationMs=25", + "at": 1782977072352 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-persisted-ui-done t=31511 rendererT=23848 durationMs=0", + "at": 1782977072352 + }, + { + "source": "stderr", + "line": "[startup] renderer-ui-get-done t=31512 rendererT=23848 durationMs=22", + "at": 1782977072353 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-persisted-ui-done t=31515 rendererT=23851 durationMs=3", + "at": 1782977072355 + }, + { + "source": "stderr", + "line": "[50192:0702/032432.892:INFO:CONSOLE:397] \"Tooltip is changing from uncontrolled to controlled. Components should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled value for the lifetime of the component.\", source: http://localhost:5173/@fs/C:/Users/jinwo/orca/workspaces/orca/windows-performance-improvement/node_modules/.vite/deps/chunk-22L5D3A7.js?v=33341407 (397)", + "at": 1782977072892 + }, + { + "source": "stderr", + "line": "[startup] renderer-session-get-done t=32052 rendererT=24110 durationMs=262", + "at": 1782977072893 + }, + { + "source": "stderr", + "line": "[startup] renderer-session-get-done t=32054 rendererT=24149 durationMs=298", + "at": 1782977072894 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-keybindings-done t=32142 rendererT=24479 durationMs=368", + "at": 1782977072983 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-keybindings-done t=32218 rendererT=24554 durationMs=405", + "at": 1782977073059 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-session-stores-done t=32222 rendererT=24559 durationMs=5", + "at": 1782977073063 + }, + { + "source": "stderr", + "line": "[startup] renderer-visit-timestamp-prune-done t=32225 rendererT=24560 durationMs=1", + "at": 1782977073066 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-browser-session-profiles-done t=32574 rendererT=24606 durationMs=46", + "at": 1782977073415 + }, + { + "source": "stderr", + "line": "[startup] renderer-onboarding-get-done t=32587 rendererT=24924 durationMs=318", + "at": 1782977073428 + }, + { + "source": "stderr", + "line": "[startup] renderer-ssh-reconnect-skipped t=32588 rendererT=24924 connectionIds=0", + "at": 1782977073428 + }, + { + "source": "stderr", + "line": "[startup] renderer-first-window-services-await-done t=32628 rendererT=24965 durationMs=41", + "at": 1782977073472 + }, + { + "source": "stderr", + "line": "[startup] renderer-reconnect-terminals-done t=32675 rendererT=25011 durationMs=46", + "at": 1782977073515 + }, + { + "source": "stderr", + "line": "[startup] renderer-startup-hydration-done t=32677 rendererT=25013 durationMs=2142", + "at": 1782977073517 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=33422 maxGapMs=401", + "at": 1782977074262 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=35433 maxGapMs=309", + "at": 1782977076274 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=37456 maxGapMs=10", + "at": 1782977078296 + }, + { + "source": "stderr", + "line": "[50192:0702/032438.587:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=ab3a14da-e087-4677-9fa8-df990c149951 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=ab3a14da-e087-4677-9fa8-df990c149951:4f90bd45-a941-4d4f-b132-614d700c43c8\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977078587 + }, + { + "source": "stderr", + "line": "[50192:0702/032438.587:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977078587 + }, + { + "source": "stderr", + "line": "[50192:0702/032438.636:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=0d6b052e-77c8-463f-afc9-d8f151d2cdeb restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=0d6b052e-77c8-463f-afc9-d8f151d2cdeb:69c12565-c05a-493d-8de2-11ff97619d51\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977078637 + }, + { + "source": "stderr", + "line": "[50192:0702/032438.636:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977078637 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=39469 maxGapMs=57", + "at": 1782977080309 + }, + { + "source": "stderr", + "line": "[50192:0702/032441.957:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=c9f77dc6-cd6f-4965-809c-c09127a40a03 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=c9f77dc6-cd6f-4965-809c-c09127a40a03:f1346d6f-965f-41fb-924b-764c7e3fc305\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977081957 + }, + { + "source": "stderr", + "line": "[50192:0702/032441.957:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977081957 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=41474 maxGapMs=48", + "at": 1782977082314 + }, + { + "source": "stderr", + "line": "[50192:0702/032442.858:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=cfb07cef-9bf1-4c78-996b-849186b593a4 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=cfb07cef-9bf1-4c78-996b-849186b593a4:de8f4a47-b5c9-4bf7-a650-1689aba5a8a8\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977082858 + }, + { + "source": "stderr", + "line": "[50192:0702/032442.858:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977082858 + }, + { + "source": "stderr", + "line": "[50192:0702/032443.702:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=3a994118-6004-4225-9c79-b7f00e7d72f1 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=3a994118-6004-4225-9c79-b7f00e7d72f1:89f847c7-b204-4b3a-b4cf-06f5c8c513bd\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977083702 + }, + { + "source": "stderr", + "line": "[50192:0702/032443.702:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977083702 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=43486 maxGapMs=53", + "at": 1782977084327 + }, + { + "source": "stderr", + "line": "[50192:0702/032444.556:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=8fc5cf91-915a-4244-a4da-1724f3682004 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=8fc5cf91-915a-4244-a4da-1724f3682004:805fa077-9c84-409e-95c0-d6974394f92a\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977084556 + }, + { + "source": "stderr", + "line": "[50192:0702/032444.556:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977084556 + }, + { + "source": "stderr", + "line": "[50192:0702/032444.635:INFO:CONSOLE:397] \"Tooltip is changing from controlled to uncontrolled. Components should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled value for the lifetime of the component.\", source: http://localhost:5173/@fs/C:/Users/jinwo/orca/workspaces/orca/windows-performance-improvement/node_modules/.vite/deps/chunk-22L5D3A7.js?v=33341407 (397)", + "at": 1782977084635 + }, + { + "source": "stderr", + "line": "[50192:0702/032445.429:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=9fa0c94f-508a-44a0-b8ba-5c24ac4ee536 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=9fa0c94f-508a-44a0-b8ba-5c24ac4ee536:89760fac-065f-4c31-a88a-3d57536749a6\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977085430 + }, + { + "source": "stderr", + "line": "[50192:0702/032445.429:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977085430 + }, + { + "source": "stderr", + "line": "[50192:0702/032446.318:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=be158070-c785-4cea-aca9-a94e71675335 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=be158070-c785-4cea-aca9-a94e71675335:8f686ee6-1816-4b55-8368-0cf249d9ca92\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977086318 + }, + { + "source": "stderr", + "line": "[50192:0702/032446.318:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977086319 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=45527 maxGapMs=50", + "at": 1782977086368 + }, + { + "source": "stderr", + "line": "[50192:0702/032447.232:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=257d758c-e00a-45f4-9543-41bb82652d68 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=257d758c-e00a-45f4-9543-41bb82652d68:d3e9824c-9691-4f5d-8bfa-8e7099c095a3\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977087232 + }, + { + "source": "stderr", + "line": "[50192:0702/032447.232:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977087233 + }, + { + "source": "stderr", + "line": "[50192:0702/032448.177:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=3db20953-661a-4e65-8e4c-ca6a6f9d0538 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=3db20953-661a-4e65-8e4c-ca6a6f9d0538:621eeeff-c3b9-4558-bb55-fe3470e05025\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977088177 + }, + { + "source": "stderr", + "line": "[50192:0702/032448.177:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977088178 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=47556 maxGapMs=42", + "at": 1782977088397 + }, + { + "source": "stderr", + "line": "[50192:0702/032448.612:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=f4dca755-e41c-4f03-aa25-ed67526ca90e restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=f4dca755-e41c-4f03-aa25-ed67526ca90e:edcb7b76-70ec-4c21-b6c1-be29e795657d\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977088612 + }, + { + "source": "stderr", + "line": "[50192:0702/032448.612:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977088612 + }, + { + "source": "stderr", + "line": "[50192:0702/032449.077:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=8f40ca49-476c-4959-ae9f-f976293e0b62 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=8f40ca49-476c-4959-ae9f-f976293e0b62:f690ab96-e8c7-416a-a8e2-26cc38e11325\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977089078 + }, + { + "source": "stderr", + "line": "[50192:0702/032449.078:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977089078 + }, + { + "source": "stderr", + "line": "[50192:0702/032449.492:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=d7adfbbb-a7eb-48ed-876b-440835e7174f restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=d7adfbbb-a7eb-48ed-876b-440835e7174f:dee0c9f1-8ff4-4c92-894b-178d9a23b3a0\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977089493 + }, + { + "source": "stderr", + "line": "[50192:0702/032449.493:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977089494 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=49559 maxGapMs=57", + "at": 1782977090400 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=51588 maxGapMs=12", + "at": 1782977092429 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=53613 maxGapMs=12", + "at": 1782977094454 + }, + { + "source": "stderr", + "line": "[50192:0702/032455.950:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=afdd9731-982a-4255-8c0e-b4360453f433 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=afdd9731-982a-4255-8c0e-b4360453f433:e6721533-a824-4847-98d2-b2c9f073a233\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977095950 + }, + { + "source": "stderr", + "line": "[50192:0702/032455.950:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977095951 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=55626 maxGapMs=26", + "at": 1782977096467 + }, + { + "source": "stderr", + "line": "[50192:0702/032456.469:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=2a067075-6acf-4aca-a0a6-bc88c1e6abc7 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=2a067075-6acf-4aca-a0a6-bc88c1e6abc7:6a3ca793-864f-4182-bec5-5b044a884b9b\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977096469 + }, + { + "source": "stderr", + "line": "[50192:0702/032456.469:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977096469 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=57642 maxGapMs=30", + "at": 1782977098483 + } + ] +} diff --git a/tools/benchmarks/results/terminal-perf-fix-a-b-2026-07-02T07-46-58-178Z.json b/tools/benchmarks/results/terminal-perf-fix-a-b-2026-07-02T07-46-58-178Z.json new file mode 100644 index 00000000000..ece15c26cfc --- /dev/null +++ b/tools/benchmarks/results/terminal-perf-fix-a-b-2026-07-02T07-46-58-178Z.json @@ -0,0 +1,1058 @@ +{ + "label": "fix-a-b", + "startedAt": "2026-07-02T07:46:58.178Z", + "platform": "win32 10.0.26200", + "shell": "default", + "args": { + "iterations": 8, + "switches": 24, + "cycles": 12 + }, + "scenarios": { + "tab-create": { + "samples": [ + { + "index": 0, + "storeCreateMs": 3.099999964237213, + "ptyBindMs": 5504, + "firstOutputMs": 5550.799999952316, + "paintSettleMs": 5553.099999964237, + "timedOut": false + }, + { + "index": 1, + "storeCreateMs": 1.9000000357627869, + "ptyBindMs": 518.2000000476837, + "firstOutputMs": 535.7000000476837, + "paintSettleMs": 544.7000000476837, + "timedOut": false + }, + { + "index": 2, + "storeCreateMs": 2.399999976158142, + "ptyBindMs": 412.5999999642372, + "firstOutputMs": 432.89999997615814, + "paintSettleMs": 434.7999999523163, + "timedOut": false + }, + { + "index": 3, + "storeCreateMs": 1.5, + "ptyBindMs": 388.9000000357628, + "firstOutputMs": 401.10000002384186, + "paintSettleMs": 408.60000002384186, + "timedOut": false + }, + { + "index": 4, + "storeCreateMs": 2.800000011920929, + "ptyBindMs": 469, + "firstOutputMs": 482.5999999642372, + "paintSettleMs": 485.30000001192093, + "timedOut": false + }, + { + "index": 5, + "storeCreateMs": 1.899999976158142, + "ptyBindMs": 499.60000002384186, + "firstOutputMs": 511.60000002384186, + "paintSettleMs": 519, + "timedOut": false + }, + { + "index": 6, + "storeCreateMs": 2, + "ptyBindMs": 441.10000002384186, + "firstOutputMs": 455.60000002384186, + "paintSettleMs": 456.39999997615814, + "timedOut": false + }, + { + "index": 7, + "storeCreateMs": 2.300000011920929, + "ptyBindMs": 312.80000001192093, + "firstOutputMs": 326.69999998807907, + "paintSettleMs": 334.19999998807907, + "timedOut": false + } + ], + "longtasks": [ + 404, 60, 184, 142, 208, 169, 148, 194, 87, 159, 211, 62, 67, 258, 95, 55, 226, 151, 53, 149, + 172, 142, 144, 56, 64 + ] + }, + "tab-switch": { + "samples": [ + { + "index": 0, + "targetTabId": "8d57a052-276e-4882-ade4-dcc5278b822c", + "storeMs": 1.7000000476837158, + "paneVisibleMs": 1.7000000476837158, + "paintSettleMs": 95.5, + "timedOut": false + }, + { + "index": 1, + "targetTabId": "d0231601-75d6-4b5e-b6f9-edae135f3beb", + "storeMs": 1.100000023841858, + "paneVisibleMs": 1.100000023841858, + "paintSettleMs": 40.5, + "timedOut": false + }, + { + "index": 2, + "targetTabId": "37b9b3da-9361-47ea-b14e-eb9bc33fd17a", + "storeMs": 2, + "paneVisibleMs": 2, + "paintSettleMs": 129.5999999642372, + "timedOut": false + }, + { + "index": 3, + "targetTabId": "ac3abf8a-ed89-4c51-83ae-5fca5e395ed5", + "storeMs": 1.899999976158142, + "paneVisibleMs": 1.899999976158142, + "paintSettleMs": 118.39999997615814, + "timedOut": false + }, + { + "index": 4, + "targetTabId": "8d57a052-276e-4882-ade4-dcc5278b822c", + "storeMs": 1.9000000357627869, + "paneVisibleMs": 1.9000000357627869, + "paintSettleMs": 86.60000002384186, + "timedOut": false + }, + { + "index": 5, + "targetTabId": "d0231601-75d6-4b5e-b6f9-edae135f3beb", + "storeMs": 1.600000023841858, + "paneVisibleMs": 1.600000023841858, + "paintSettleMs": 34.80000001192093, + "timedOut": false + }, + { + "index": 6, + "targetTabId": "37b9b3da-9361-47ea-b14e-eb9bc33fd17a", + "storeMs": 1, + "paneVisibleMs": 1, + "paintSettleMs": 43.59999996423721, + "timedOut": false + }, + { + "index": 7, + "targetTabId": "ac3abf8a-ed89-4c51-83ae-5fca5e395ed5", + "storeMs": 1.7999999523162842, + "paneVisibleMs": 1.7999999523162842, + "paintSettleMs": 128.89999997615814, + "timedOut": false + }, + { + "index": 8, + "targetTabId": "8d57a052-276e-4882-ade4-dcc5278b822c", + "storeMs": 1.100000023841858, + "paneVisibleMs": 1.100000023841858, + "paintSettleMs": 95.5, + "timedOut": false + }, + { + "index": 9, + "targetTabId": "d0231601-75d6-4b5e-b6f9-edae135f3beb", + "storeMs": 1.399999976158142, + "paneVisibleMs": 1.399999976158142, + "paintSettleMs": 94.19999998807907, + "timedOut": false + }, + { + "index": 10, + "targetTabId": "37b9b3da-9361-47ea-b14e-eb9bc33fd17a", + "storeMs": 1.5, + "paneVisibleMs": 1.5, + "paintSettleMs": 29.100000023841858, + "timedOut": false + }, + { + "index": 11, + "targetTabId": "ac3abf8a-ed89-4c51-83ae-5fca5e395ed5", + "storeMs": 1.199999988079071, + "paneVisibleMs": 1.199999988079071, + "paintSettleMs": 35, + "timedOut": false + }, + { + "index": 12, + "targetTabId": "8d57a052-276e-4882-ade4-dcc5278b822c", + "storeMs": 1.800000011920929, + "paneVisibleMs": 1.800000011920929, + "paintSettleMs": 167.30000001192093, + "timedOut": false + }, + { + "index": 13, + "targetTabId": "d0231601-75d6-4b5e-b6f9-edae135f3beb", + "storeMs": 2.900000035762787, + "paneVisibleMs": 2.900000035762787, + "paintSettleMs": 141.9000000357628, + "timedOut": false + }, + { + "index": 14, + "targetTabId": "37b9b3da-9361-47ea-b14e-eb9bc33fd17a", + "storeMs": 1, + "paneVisibleMs": 1, + "paintSettleMs": 57.69999998807907, + "timedOut": false + }, + { + "index": 15, + "targetTabId": "ac3abf8a-ed89-4c51-83ae-5fca5e395ed5", + "storeMs": 1.7000000476837158, + "paneVisibleMs": 1.7000000476837158, + "paintSettleMs": 42.90000003576279, + "timedOut": false + }, + { + "index": 16, + "targetTabId": "8d57a052-276e-4882-ade4-dcc5278b822c", + "storeMs": 2.399999976158142, + "paneVisibleMs": 2.399999976158142, + "paintSettleMs": 49.30000001192093, + "timedOut": false + }, + { + "index": 17, + "targetTabId": "d0231601-75d6-4b5e-b6f9-edae135f3beb", + "storeMs": 1.2000000476837158, + "paneVisibleMs": 1.2000000476837158, + "paintSettleMs": 126.40000003576279, + "timedOut": false + }, + { + "index": 18, + "targetTabId": "37b9b3da-9361-47ea-b14e-eb9bc33fd17a", + "storeMs": 1.399999976158142, + "paneVisibleMs": 1.399999976158142, + "paintSettleMs": 134.89999997615814, + "timedOut": false + }, + { + "index": 19, + "targetTabId": "ac3abf8a-ed89-4c51-83ae-5fca5e395ed5", + "storeMs": 2.100000023841858, + "paneVisibleMs": 2.100000023841858, + "paintSettleMs": 75.19999998807907, + "timedOut": false + }, + { + "index": 20, + "targetTabId": "8d57a052-276e-4882-ade4-dcc5278b822c", + "storeMs": 1.699999988079071, + "paneVisibleMs": 1.699999988079071, + "paintSettleMs": 40.69999998807907, + "timedOut": false + }, + { + "index": 21, + "targetTabId": "d0231601-75d6-4b5e-b6f9-edae135f3beb", + "storeMs": 1.0999999642372131, + "paneVisibleMs": 1.0999999642372131, + "paintSettleMs": 40.80000001192093, + "timedOut": false + }, + { + "index": 22, + "targetTabId": "37b9b3da-9361-47ea-b14e-eb9bc33fd17a", + "storeMs": 2.099999964237213, + "paneVisibleMs": 2.099999964237213, + "paintSettleMs": 131.89999997615814, + "timedOut": false + }, + { + "index": 23, + "targetTabId": "ac3abf8a-ed89-4c51-83ae-5fca5e395ed5", + "storeMs": 2.100000023841858, + "paneVisibleMs": 2.100000023841858, + "paintSettleMs": 146.9000000357628, + "timedOut": false + } + ], + "longtasks": [86, 123, 111, 73, 122, 90, 84, 108, 127, 52, 121, 129, 64, 124, 134] + }, + "workspace-switch": { + "samples": [ + { + "index": 0, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@588e69ce", + "activationMs": 240, + "ptyBindMs": 247, + "paintSettleMs": 254 + }, + { + "index": 1, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-one", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-one@@407494c8", + "activationMs": 423, + "ptyBindMs": 573, + "paintSettleMs": 584 + }, + { + "index": 2, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two@@5d681b07", + "activationMs": 355, + "ptyBindMs": 441, + "paintSettleMs": 464 + }, + { + "index": 3, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@588e69ce", + "activationMs": 152, + "ptyBindMs": 177, + "paintSettleMs": 182 + }, + { + "index": 4, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-one", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-one@@407494c8", + "activationMs": 294, + "ptyBindMs": 330, + "paintSettleMs": 341 + }, + { + "index": 5, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two@@5d681b07", + "activationMs": 167, + "ptyBindMs": 192, + "paintSettleMs": 200 + }, + { + "index": 6, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@588e69ce", + "activationMs": 141, + "ptyBindMs": 158, + "paintSettleMs": 164 + }, + { + "index": 7, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-one", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-one@@407494c8", + "activationMs": 115, + "ptyBindMs": 139, + "paintSettleMs": 145 + }, + { + "index": 8, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two@@5d681b07", + "activationMs": 123, + "ptyBindMs": 147, + "paintSettleMs": 154 + }, + { + "index": 9, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@588e69ce", + "activationMs": 258, + "ptyBindMs": 284, + "paintSettleMs": 294 + }, + { + "index": 10, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-one", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-one@@407494c8", + "activationMs": 153, + "ptyBindMs": 176, + "paintSettleMs": 185 + }, + { + "index": 11, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two@@5d681b07", + "activationMs": 123, + "ptyBindMs": 151, + "paintSettleMs": 158 + } + ] + } + }, + "summaries": [ + { + "name": "tab-create", + "samples": 8, + "timedOut": 0, + "fields": { + "storeCreateMs": { + "count": 8, + "median": 2, + "p95": 3, + "max": 3 + }, + "ptyBindMs": { + "count": 8, + "median": 469, + "p95": 5504, + "max": 5504 + }, + "firstOutputMs": { + "count": 8, + "median": 483, + "p95": 5551, + "max": 5551 + }, + "paintSettleMs": { + "count": 8, + "median": 485, + "p95": 5553, + "max": 5553 + } + }, + "longtaskCount": 25, + "longtaskMaxMs": 404 + }, + { + "name": "tab-switch", + "samples": 24, + "timedOut": 0, + "fields": { + "storeMs": { + "count": 24, + "median": 2, + "p95": 2, + "max": 3 + }, + "paneVisibleMs": { + "count": 24, + "median": 2, + "p95": 2, + "max": 3 + }, + "paintSettleMs": { + "count": 24, + "median": 94, + "p95": 147, + "max": 167 + } + }, + "longtaskCount": 15, + "longtaskMaxMs": 134 + }, + { + "name": "workspace-switch", + "samples": 12, + "timedOut": 0, + "fields": { + "activationMs": { + "count": 12, + "median": 167, + "p95": 355, + "max": 423 + }, + "ptyBindMs": { + "count": 12, + "median": 192, + "p95": 441, + "max": 573 + }, + "paintSettleMs": { + "count": 12, + "median": 200, + "p95": 464, + "max": 584 + } + }, + "longtaskCount": 0, + "longtaskMaxMs": 0 + } + ], + "finalDiagnostics": { + "hasStore": true, + "workspaceSessionReady": true, + "hydrationSucceeded": true, + "activeView": "terminal", + "activeRepoId": "62e7f2fd-6fb6-446e-bded-2866c26a968e", + "activeWorktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two", + "activeTabType": "terminal", + "activeTabId": "5cf37be5-4f5e-4e6f-a00c-5116e3f6127e", + "terminalGpuAcceleration": "auto", + "repoCount": 1, + "worktreeCountsByRepo": { + "62e7f2fd-6fb6-446e-bded-2866c26a968e": 3 + }, + "ptyIdsByTabId": [ + "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two@@5d681b07" + ], + "paneManagerCount": 3, + "activePane": { + "id": 1, + "leafId": "74365402-0e1f-4d28-b14b-212e48cb4584", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two@@5d681b07", + "cols": 175, + "rows": 73, + "buffer": { + "baseY": 0, + "viewportY": 0, + "cursorY": 0, + "length": 73 + } + }, + "renderingDiagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": false, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ], + "allPaneManagersDiagnostics": [ + { + "tabId": "5ae16ac3-2ecb-4d5b-bdc2-3751e9b5c6a8", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": true, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ] + }, + { + "tabId": "212a99f7-8de7-4b20-9a0b-c8caba7656e4", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": true, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ] + }, + { + "tabId": "5cf37be5-4f5e-4e6f-a00c-5116e3f6127e", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": false, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ] + } + ], + "webglContextCounts": { + "attachedWebglCount": 3, + "deferredWebglCount": 2, + "managerCount": 3, + "paneCount": 3 + }, + "webglIdentity": { + "available": true, + "vendor": "Google Inc. (Intel)", + "renderer": "ANGLE (Intel, Intel(R) Arc(TM) Graphics (0x00007D55) Direct3D11 vs_5_0 ps_5_0, D3D11)" + }, + "rendererProbe": { + "intervalMs": 50, + "last": 36550, + "maxDriftMs": 436.4000000357628, + "samples": 329, + "startedAt": 12199.699999988079, + "lastTickAt": 36550 + }, + "ptySessions": { + "value": [ + { + "id": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@588e69ce", + "cwd": "", + "title": "shell" + }, + { + "id": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-one@@407494c8", + "cwd": "", + "title": "shell" + }, + { + "id": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two@@5d681b07", + "cwd": "", + "title": "shell" + } + ] + }, + "rendererDeliveryDebug": { + "value": { + "pendingPtyCount": 0, + "pendingChars": 0, + "maxPendingCharsByPty": 0, + "rendererInFlightPtyCount": 0, + "rendererInFlightChars": 0, + "maxRendererInFlightCharsByPty": 0, + "activeRendererPtyCount": 1, + "flushScheduled": false, + "peakPendingChars": 82, + "peakMaxPendingCharsByPty": 82, + "peakRendererInFlightChars": 82, + "peakMaxRendererInFlightCharsByPty": 82, + "ackGatedFlushSkipCount": 0 + } + } + }, + "cleanupErrors": [], + "elapsedMs": 50260, + "appLogsTail": [ + { + "source": "stderr", + "line": "[startup] renderer-fetch-folder-workspaces-done t=13843 rendererT=11282 durationMs=40", + "at": 1782978443148 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktrees-done t=13844 rendererT=11282 durationMs=0", + "at": 1782978443148 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktree-lineage-done t=13858 rendererT=11297 durationMs=16", + "at": 1782978443163 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktree-lineage-done t=13860 rendererT=11299 durationMs=17", + "at": 1782978443165 + }, + { + "source": "stderr", + "line": "[startup] renderer-ui-get-done t=13873 rendererT=11312 durationMs=15", + "at": 1782978443178 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-persisted-ui-done t=13873 rendererT=11312 durationMs=0", + "at": 1782978443178 + }, + { + "source": "stderr", + "line": "[startup] renderer-ui-get-done t=13875 rendererT=11314 durationMs=15", + "at": 1782978443180 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-persisted-ui-done t=13875 rendererT=11314 durationMs=0", + "at": 1782978443180 + }, + { + "source": "stderr", + "line": "[startup] renderer-session-get-done t=13891 rendererT=11330 durationMs=18", + "at": 1782978443196 + }, + { + "source": "stderr", + "line": "[startup] renderer-session-get-done t=13893 rendererT=11332 durationMs=17", + "at": 1782978443198 + }, + { + "source": "stderr", + "line": "[55788:0702/034723.201:INFO:CONSOLE:1080] \"[React Grab] v0.1.34 is outdated (latest: v0.1.47). Run `npx grab@latest upgrade` to upgrade.\", source: http://localhost:5173/@fs/C:/Users/jinwo/orca/workspaces/orca/windows-performance-improvement/node_modules/.vite/deps/react-grab.js?v=43681c89 (1080)", + "at": 1782978443201 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-keybindings-done t=14296 rendererT=11390 durationMs=59", + "at": 1782978443601 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-keybindings-done t=14297 rendererT=11429 durationMs=97", + "at": 1782978443602 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-session-stores-done t=14297 rendererT=11434 durationMs=5", + "at": 1782978443602 + }, + { + "source": "stderr", + "line": "[startup] renderer-visit-timestamp-prune-done t=14297 rendererT=11436 durationMs=1", + "at": 1782978443602 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-browser-session-profiles-done t=14305 rendererT=11744 durationMs=308", + "at": 1782978443610 + }, + { + "source": "stderr", + "line": "[startup] renderer-onboarding-get-done t=14306 rendererT=11745 durationMs=1", + "at": 1782978443611 + }, + { + "source": "stderr", + "line": "[startup] renderer-ssh-reconnect-skipped t=14306 rendererT=11745 connectionIds=0", + "at": 1782978443611 + }, + { + "source": "stderr", + "line": "[startup] renderer-first-window-services-await-done t=14338 rendererT=11777 durationMs=32", + "at": 1782978443643 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=14736 maxGapMs=360", + "at": 1782978444041 + }, + { + "source": "stderr", + "line": "[startup] renderer-reconnect-terminals-done t=14736 rendererT=11817 durationMs=41", + "at": 1782978444041 + }, + { + "source": "stderr", + "line": "[startup] renderer-startup-hydration-done t=14737 rendererT=11818 durationMs=1577", + "at": 1782978444042 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=16763 maxGapMs=352", + "at": 1782978446068 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=18790 maxGapMs=8", + "at": 1782978448094 + }, + { + "source": "stderr", + "line": "[55788:0702/034728.941:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=5ae16ac3-2ecb-4d5b-bdc2-3751e9b5c6a8 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=5ae16ac3-2ecb-4d5b-bdc2-3751e9b5c6a8:36d54ebb-a041-4133-b5da-c7d36f570603\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978448941 + }, + { + "source": "stderr", + "line": "[55788:0702/034728.941:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978448941 + }, + { + "source": "stderr", + "line": "[55788:0702/034728.994:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=d13ff0ea-ef9b-4cc0-bc75-de82d29e4c7e restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=d13ff0ea-ef9b-4cc0-bc75-de82d29e4c7e:375f8b17-83a7-4b05-9794-8c7e6daaad79\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978448994 + }, + { + "source": "stderr", + "line": "[55788:0702/034728.994:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978448994 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=20813 maxGapMs=95", + "at": 1782978450118 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@588e69ce total=1879ms preflight=0ms auth=0ms host_env=50ms options=0ms provider_spawn=1829ms daemon=true reattach=false", + "at": 1782978450823 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@15f1dbf7 total=1880ms preflight=0ms auth=0ms host_env=55ms options=0ms provider_spawn=1825ms daemon=true reattach=false", + "at": 1782978450875 + }, + { + "source": "stderr", + "line": "[55788:0702/034731.745:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=2b742216-def2-45d8-a554-afa8b63a7efc restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=2b742216-def2-45d8-a554-afa8b63a7efc:6328b254-57e8-479e-bf53-220dd8fbefed\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978451746 + }, + { + "source": "stderr", + "line": "[55788:0702/034731.745:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978451746 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@1ee97184 total=118ms preflight=0ms auth=0ms host_env=53ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "at": 1782978451864 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=22815 maxGapMs=57", + "at": 1782978452120 + }, + { + "source": "stderr", + "line": "[55788:0702/034732.691:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=a669eb6f-24a3-4e93-9b9d-230b1734818a restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=a669eb6f-24a3-4e93-9b9d-230b1734818a:97eecd6f-f5df-41f6-8d16-0052bd0dd085\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978452692 + }, + { + "source": "stderr", + "line": "[55788:0702/034732.691:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978452692 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@04171bdd total=113ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=65ms daemon=true reattach=false", + "at": 1782978452806 + }, + { + "source": "stderr", + "line": "[55788:0702/034733.549:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=a7f4f0da-28f3-4938-b412-0b5c7d389a1a restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=a7f4f0da-28f3-4938-b412-0b5c7d389a1a:0e97486f-93f7-4de1-a921-d7883bf6742d\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978453549 + }, + { + "source": "stderr", + "line": "[55788:0702/034733.550:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978453549 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@46952210 total=97ms preflight=0ms auth=0ms host_env=46ms options=0ms provider_spawn=51ms daemon=true reattach=false", + "at": 1782978453647 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=24819 maxGapMs=45", + "at": 1782978454124 + }, + { + "source": "stderr", + "line": "[55788:0702/034734.418:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=33f0654c-45be-4686-9f91-934dbd838341 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=33f0654c-45be-4686-9f91-934dbd838341:aa40fd80-39ca-4647-833f-081ff4315faf\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978454418 + }, + { + "source": "stderr", + "line": "[55788:0702/034734.418:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978454419 + }, + { + "source": "stderr", + "line": "[55788:0702/034734.488:INFO:CONSOLE:397] \"Tooltip is changing from controlled to uncontrolled. Components should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled value for the lifetime of the component.\", source: http://localhost:5173/@fs/C:/Users/jinwo/orca/workspaces/orca/windows-performance-improvement/node_modules/.vite/deps/chunk-22L5D3A7.js?v=43681c89 (397)", + "at": 1782978454488 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@c7cc0a5b total=98ms preflight=0ms auth=0ms host_env=45ms options=0ms provider_spawn=53ms daemon=true reattach=false", + "at": 1782978454518 + }, + { + "source": "stderr", + "line": "[55788:0702/034735.279:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=acf961b2-58e1-4700-8246-8fbd13aec1e9 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=acf961b2-58e1-4700-8246-8fbd13aec1e9:d19e1bbf-4575-4b3c-b09e-b674244b0126\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978455279 + }, + { + "source": "stderr", + "line": "[55788:0702/034735.279:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978455279 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@07c16de2 total=110ms preflight=0ms auth=0ms host_env=46ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "at": 1782978455389 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=26824 maxGapMs=40", + "at": 1782978456129 + }, + { + "source": "stderr", + "line": "[55788:0702/034736.131:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=6c82b2e6-a836-47cc-9450-d81aab89a3ca restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=6c82b2e6-a836-47cc-9450-d81aab89a3ca:fa45ccbe-b10e-4260-850c-350edc40a8f5\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978456131 + }, + { + "source": "stderr", + "line": "[55788:0702/034736.131:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978456131 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@316fcc20 total=96ms preflight=0ms auth=0ms host_env=44ms options=0ms provider_spawn=52ms daemon=true reattach=false", + "at": 1782978456228 + }, + { + "source": "stderr", + "line": "[55788:0702/034736.986:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=cc574a5e-9ee8-416a-acb3-9042ca35caf1 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=cc574a5e-9ee8-416a-acb3-9042ca35caf1:5e19c1dc-6360-4032-99f6-cb0345e5d7e5\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978456986 + }, + { + "source": "stderr", + "line": "[55788:0702/034736.987:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978456987 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@6993e5e7 total=95ms preflight=0ms auth=0ms host_env=39ms options=0ms provider_spawn=56ms daemon=true reattach=false", + "at": 1782978457082 + }, + { + "source": "stderr", + "line": "[55788:0702/034737.848:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=8d57a052-276e-4882-ade4-dcc5278b822c restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=8d57a052-276e-4882-ade4-dcc5278b822c:e39bef7b-f078-40f5-b29e-83468097b694\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978457848 + }, + { + "source": "stderr", + "line": "[55788:0702/034737.848:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978457848 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@56d1407f total=114ms preflight=0ms auth=0ms host_env=47ms options=1ms provider_spawn=66ms daemon=true reattach=false", + "at": 1782978457962 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=28852 maxGapMs=58", + "at": 1782978458157 + }, + { + "source": "stderr", + "line": "[55788:0702/034738.252:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=d0231601-75d6-4b5e-b6f9-edae135f3beb restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=d0231601-75d6-4b5e-b6f9-edae135f3beb:0ce6efc9-3d20-49df-a9cc-8ee9dec16cc3\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978458252 + }, + { + "source": "stderr", + "line": "[55788:0702/034738.252:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978458252 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@d077bcb8 total=106ms preflight=0ms auth=0ms host_env=43ms options=0ms provider_spawn=63ms daemon=true reattach=false", + "at": 1782978458358 + }, + { + "source": "stderr", + "line": "[55788:0702/034738.803:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=37b9b3da-9361-47ea-b14e-eb9bc33fd17a restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=37b9b3da-9361-47ea-b14e-eb9bc33fd17a:ea9a8021-01d2-440f-a587-809680c60baf\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978458803 + }, + { + "source": "stderr", + "line": "[55788:0702/034738.803:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978458804 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@f465a9a7 total=118ms preflight=1ms auth=0ms host_env=48ms options=0ms provider_spawn=69ms daemon=true reattach=false", + "at": 1782978458921 + }, + { + "source": "stderr", + "line": "[55788:0702/034739.203:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=ac3abf8a-ed89-4c51-83ae-5fca5e395ed5 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=ac3abf8a-ed89-4c51-83ae-5fca5e395ed5:d46a8d0d-69a8-4f14-8186-6f094f152d36\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978459203 + }, + { + "source": "stderr", + "line": "[55788:0702/034739.203:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978459203 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@2223d5a6 total=113ms preflight=0ms auth=0ms host_env=54ms options=0ms provider_spawn=59ms daemon=true reattach=false", + "at": 1782978459316 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=30878 maxGapMs=43", + "at": 1782978460183 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=32902 maxGapMs=12", + "at": 1782978462207 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=34905 maxGapMs=14", + "at": 1782978464210 + }, + { + "source": "stderr", + "line": "[55788:0702/034745.917:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=212a99f7-8de7-4b20-9a0b-c8caba7656e4 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=212a99f7-8de7-4b20-9a0b-c8caba7656e4:dcbaade8-8868-4626-b685-37a662d40aa0\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978465917 + }, + { + "source": "stderr", + "line": "[55788:0702/034745.917:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978465917 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-one@@407494c8 total=108ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=60ms daemon=true reattach=false", + "at": 1782978466025 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=36929 maxGapMs=46", + "at": 1782978466234 + }, + { + "source": "stderr", + "line": "[55788:0702/034746.426:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=5cf37be5-4f5e-4e6f-a00c-5116e3f6127e restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=5cf37be5-4f5e-4e6f-a00c-5116e3f6127e:74365402-0e1f-4d28-b14b-212e48cb4584\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978466425 + }, + { + "source": "stderr", + "line": "[55788:0702/034746.426:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978466426 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two@@5d681b07 total=109ms preflight=0ms auth=0ms host_env=52ms options=0ms provider_spawn=57ms daemon=true reattach=false", + "at": 1782978466535 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=38934 maxGapMs=40", + "at": 1782978468239 + } + ], + "ptySpawnTimings": [ + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@588e69ce total=1879ms preflight=0ms auth=0ms host_env=50ms options=0ms provider_spawn=1829ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@15f1dbf7 total=1880ms preflight=0ms auth=0ms host_env=55ms options=0ms provider_spawn=1825ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@1ee97184 total=118ms preflight=0ms auth=0ms host_env=53ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@04171bdd total=113ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=65ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@46952210 total=97ms preflight=0ms auth=0ms host_env=46ms options=0ms provider_spawn=51ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@c7cc0a5b total=98ms preflight=0ms auth=0ms host_env=45ms options=0ms provider_spawn=53ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@07c16de2 total=110ms preflight=0ms auth=0ms host_env=46ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@316fcc20 total=96ms preflight=0ms auth=0ms host_env=44ms options=0ms provider_spawn=52ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@6993e5e7 total=95ms preflight=0ms auth=0ms host_env=39ms options=0ms provider_spawn=56ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@56d1407f total=114ms preflight=0ms auth=0ms host_env=47ms options=1ms provider_spawn=66ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@d077bcb8 total=106ms preflight=0ms auth=0ms host_env=43ms options=0ms provider_spawn=63ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@f465a9a7 total=118ms preflight=1ms auth=0ms host_env=48ms options=0ms provider_spawn=69ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@2223d5a6 total=113ms preflight=0ms auth=0ms host_env=54ms options=0ms provider_spawn=59ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-one@@407494c8 total=108ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=60ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two@@5d681b07 total=109ms preflight=0ms auth=0ms host_env=52ms options=0ms provider_spawn=57ms daemon=true reattach=false" + ] +} diff --git a/tools/benchmarks/results/terminal-perf-fix-abd-2026-07-02T07-51-00-936Z.json b/tools/benchmarks/results/terminal-perf-fix-abd-2026-07-02T07-51-00-936Z.json new file mode 100644 index 00000000000..ae913a7ecac --- /dev/null +++ b/tools/benchmarks/results/terminal-perf-fix-abd-2026-07-02T07-51-00-936Z.json @@ -0,0 +1,1061 @@ +{ + "label": "fix-abd", + "startedAt": "2026-07-02T07:51:00.936Z", + "platform": "win32 10.0.26200", + "shell": "default", + "args": { + "iterations": 8, + "switches": 24, + "cycles": 12 + }, + "scenarios": { + "tab-create": { + "samples": [ + { + "index": 0, + "storeCreateMs": 4.199999988079071, + "ptyBindMs": 6850.600000023842, + "firstOutputMs": 6893.800000011921, + "paintSettleMs": 6897.199999988079, + "timedOut": false + }, + { + "index": 1, + "storeCreateMs": 2.299999952316284, + "ptyBindMs": 553.2999999523163, + "firstOutputMs": 576.5999999642372, + "paintSettleMs": 578.0999999642372, + "timedOut": false + }, + { + "index": 2, + "storeCreateMs": 2.699999988079071, + "ptyBindMs": 565.6000000238419, + "firstOutputMs": 584.5, + "paintSettleMs": 585.4000000357628, + "timedOut": false + }, + { + "index": 3, + "storeCreateMs": 2.899999976158142, + "ptyBindMs": 542.8999999761581, + "firstOutputMs": 571.1999999880791, + "paintSettleMs": 573.8000000119209, + "timedOut": false + }, + { + "index": 4, + "storeCreateMs": 2.300000011920929, + "ptyBindMs": 423, + "firstOutputMs": 457.19999998807907, + "paintSettleMs": 464.80000001192093, + "timedOut": false + }, + { + "index": 5, + "storeCreateMs": 2.399999976158142, + "ptyBindMs": 478.5999999642372, + "firstOutputMs": 497, + "paintSettleMs": 498.19999998807907, + "timedOut": false + }, + { + "index": 6, + "storeCreateMs": 2.099999964237213, + "ptyBindMs": 531, + "firstOutputMs": 548.3999999761581, + "paintSettleMs": 549.3999999761581, + "timedOut": false + }, + { + "index": 7, + "storeCreateMs": 3.199999988079071, + "ptyBindMs": 612, + "firstOutputMs": 624.0999999642372, + "paintSettleMs": 629.0999999642372, + "timedOut": false + } + ], + "longtasks": [ + 478, 78, 204, 149, 218, 203, 145, 218, 202, 165, 223, 189, 163, 210, 86, 162, 280, 68, 110, + 323, 66, 76, 345, 130, 114 + ] + }, + "tab-switch": { + "samples": [ + { + "index": 0, + "targetTabId": "87a752ed-2a66-411e-8c00-b7b0b24b5d10", + "storeMs": 2.599999964237213, + "paneVisibleMs": 2.599999964237213, + "paintSettleMs": 158.5999999642372, + "timedOut": false + }, + { + "index": 1, + "targetTabId": "03cc8628-e72d-478c-90f7-3ed264097afb", + "storeMs": 2.399999976158142, + "paneVisibleMs": 2.399999976158142, + "paintSettleMs": 155.5, + "timedOut": false + }, + { + "index": 2, + "targetTabId": "993f77d3-4e18-4b17-b386-adead86c80c8", + "storeMs": 2.699999988079071, + "paneVisibleMs": 2.699999988079071, + "paintSettleMs": 99.39999997615814, + "timedOut": false + }, + { + "index": 3, + "targetTabId": "2e38aeb9-4c66-440e-adbf-de538b4ddcbb", + "storeMs": 2.199999988079071, + "paneVisibleMs": 2.199999988079071, + "paintSettleMs": 66.30000001192093, + "timedOut": false + }, + { + "index": 4, + "targetTabId": "87a752ed-2a66-411e-8c00-b7b0b24b5d10", + "storeMs": 2.5, + "paneVisibleMs": 2.5, + "paintSettleMs": 87.89999997615814, + "timedOut": false + }, + { + "index": 5, + "targetTabId": "03cc8628-e72d-478c-90f7-3ed264097afb", + "storeMs": 2.599999964237213, + "paneVisibleMs": 2.599999964237213, + "paintSettleMs": 158.5, + "timedOut": false + }, + { + "index": 6, + "targetTabId": "993f77d3-4e18-4b17-b386-adead86c80c8", + "storeMs": 2.299999952316284, + "paneVisibleMs": 2.299999952316284, + "paintSettleMs": 150, + "timedOut": false + }, + { + "index": 7, + "targetTabId": "2e38aeb9-4c66-440e-adbf-de538b4ddcbb", + "storeMs": 2.5, + "paneVisibleMs": 2.5, + "paintSettleMs": 70.19999998807907, + "timedOut": false + }, + { + "index": 8, + "targetTabId": "87a752ed-2a66-411e-8c00-b7b0b24b5d10", + "storeMs": 2.300000011920929, + "paneVisibleMs": 2.300000011920929, + "paintSettleMs": 60.19999998807907, + "timedOut": false + }, + { + "index": 9, + "targetTabId": "03cc8628-e72d-478c-90f7-3ed264097afb", + "storeMs": 2.5, + "paneVisibleMs": 2.5, + "paintSettleMs": 155.0999999642372, + "timedOut": false + }, + { + "index": 10, + "targetTabId": "993f77d3-4e18-4b17-b386-adead86c80c8", + "storeMs": 2.099999964237213, + "paneVisibleMs": 2.099999964237213, + "paintSettleMs": 159.5, + "timedOut": false + }, + { + "index": 11, + "targetTabId": "2e38aeb9-4c66-440e-adbf-de538b4ddcbb", + "storeMs": 2.300000011920929, + "paneVisibleMs": 2.300000011920929, + "paintSettleMs": 68.70000004768372, + "timedOut": false + }, + { + "index": 12, + "targetTabId": "87a752ed-2a66-411e-8c00-b7b0b24b5d10", + "storeMs": 1.699999988079071, + "paneVisibleMs": 1.699999988079071, + "paintSettleMs": 66.39999997615814, + "timedOut": false + }, + { + "index": 13, + "targetTabId": "03cc8628-e72d-478c-90f7-3ed264097afb", + "storeMs": 2.5, + "paneVisibleMs": 2.5, + "paintSettleMs": 54.90000003576279, + "timedOut": false + }, + { + "index": 14, + "targetTabId": "993f77d3-4e18-4b17-b386-adead86c80c8", + "storeMs": 2.199999988079071, + "paneVisibleMs": 2.199999988079071, + "paintSettleMs": 149.19999998807907, + "timedOut": false + }, + { + "index": 15, + "targetTabId": "2e38aeb9-4c66-440e-adbf-de538b4ddcbb", + "storeMs": 2.300000011920929, + "paneVisibleMs": 2.400000035762787, + "paintSettleMs": 151.9000000357628, + "timedOut": false + }, + { + "index": 16, + "targetTabId": "87a752ed-2a66-411e-8c00-b7b0b24b5d10", + "storeMs": 2, + "paneVisibleMs": 2, + "paintSettleMs": 90.59999996423721, + "timedOut": false + }, + { + "index": 17, + "targetTabId": "03cc8628-e72d-478c-90f7-3ed264097afb", + "storeMs": 2.300000011920929, + "paneVisibleMs": 2.300000011920929, + "paintSettleMs": 57.30000001192093, + "timedOut": false + }, + { + "index": 18, + "targetTabId": "993f77d3-4e18-4b17-b386-adead86c80c8", + "storeMs": 2.100000023841858, + "paneVisibleMs": 2.100000023841858, + "paintSettleMs": 59.30000001192093, + "timedOut": false + }, + { + "index": 19, + "targetTabId": "2e38aeb9-4c66-440e-adbf-de538b4ddcbb", + "storeMs": 2.099999964237213, + "paneVisibleMs": 2.099999964237213, + "paintSettleMs": 143.19999998807907, + "timedOut": false + }, + { + "index": 20, + "targetTabId": "87a752ed-2a66-411e-8c00-b7b0b24b5d10", + "storeMs": 1, + "paneVisibleMs": 1, + "paintSettleMs": 117.80000001192093, + "timedOut": false + }, + { + "index": 21, + "targetTabId": "03cc8628-e72d-478c-90f7-3ed264097afb", + "storeMs": 2.100000023841858, + "paneVisibleMs": 2.100000023841858, + "paintSettleMs": 94, + "timedOut": false + }, + { + "index": 22, + "targetTabId": "993f77d3-4e18-4b17-b386-adead86c80c8", + "storeMs": 1.5999999642372131, + "paneVisibleMs": 1.5999999642372131, + "paintSettleMs": 56.299999952316284, + "timedOut": false + }, + { + "index": 23, + "targetTabId": "2e38aeb9-4c66-440e-adbf-de538b4ddcbb", + "storeMs": 1.7000000476837158, + "paneVisibleMs": 1.7000000476837158, + "paintSettleMs": 134.10000002384186, + "timedOut": false + } + ], + "longtasks": [ + 145, 146, 89, 54, 80, 151, 138, 62, 53, 143, 151, 60, 59, 53, 142, 141, 83, 50, 129, 112, + 83, 126 + ] + }, + "workspace-switch": { + "samples": [ + { + "index": 0, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@14096c3d", + "activationMs": 281, + "ptyBindMs": 283, + "paintSettleMs": 293 + }, + { + "index": 1, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-one", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-one@@0b21e66f", + "activationMs": 344, + "ptyBindMs": 524, + "paintSettleMs": 538 + }, + { + "index": 2, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two@@e63f448f", + "activationMs": 428, + "ptyBindMs": 545, + "paintSettleMs": 559 + }, + { + "index": 3, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@14096c3d", + "activationMs": 147, + "ptyBindMs": 167, + "paintSettleMs": 180 + }, + { + "index": 4, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-one", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-one@@0b21e66f", + "activationMs": 320, + "ptyBindMs": 348, + "paintSettleMs": 356 + }, + { + "index": 5, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two@@e63f448f", + "activationMs": 125, + "ptyBindMs": 146, + "paintSettleMs": 155 + }, + { + "index": 6, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@14096c3d", + "activationMs": 135, + "ptyBindMs": 168, + "paintSettleMs": 175 + }, + { + "index": 7, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-one", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-one@@0b21e66f", + "activationMs": 129, + "ptyBindMs": 155, + "paintSettleMs": 166 + }, + { + "index": 8, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two@@e63f448f", + "activationMs": 121, + "ptyBindMs": 147, + "paintSettleMs": 154 + }, + { + "index": 9, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@14096c3d", + "activationMs": 298, + "ptyBindMs": 318, + "paintSettleMs": 328 + }, + { + "index": 10, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-one", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-one@@0b21e66f", + "activationMs": 113, + "ptyBindMs": 131, + "paintSettleMs": 139 + }, + { + "index": 11, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two@@e63f448f", + "activationMs": 117, + "ptyBindMs": 140, + "paintSettleMs": 150 + } + ] + } + }, + "summaries": [ + { + "name": "tab-create", + "samples": 8, + "timedOut": 0, + "fields": { + "storeCreateMs": { + "count": 8, + "median": 3, + "p95": 4, + "max": 4 + }, + "ptyBindMs": { + "count": 8, + "median": 553, + "p95": 6851, + "max": 6851 + }, + "firstOutputMs": { + "count": 8, + "median": 577, + "p95": 6894, + "max": 6894 + }, + "paintSettleMs": { + "count": 8, + "median": 578, + "p95": 6897, + "max": 6897 + } + }, + "longtaskCount": 25, + "longtaskMaxMs": 478 + }, + { + "name": "tab-switch", + "samples": 24, + "timedOut": 0, + "fields": { + "storeMs": { + "count": 24, + "median": 2, + "p95": 3, + "max": 3 + }, + "paneVisibleMs": { + "count": 24, + "median": 2, + "p95": 3, + "max": 3 + }, + "paintSettleMs": { + "count": 24, + "median": 99, + "p95": 159, + "max": 160 + } + }, + "longtaskCount": 22, + "longtaskMaxMs": 151 + }, + { + "name": "workspace-switch", + "samples": 12, + "timedOut": 0, + "fields": { + "activationMs": { + "count": 12, + "median": 147, + "p95": 344, + "max": 428 + }, + "ptyBindMs": { + "count": 12, + "median": 168, + "p95": 524, + "max": 545 + }, + "paintSettleMs": { + "count": 12, + "median": 180, + "p95": 538, + "max": 559 + } + }, + "longtaskCount": 0, + "longtaskMaxMs": 0 + } + ], + "finalDiagnostics": { + "hasStore": true, + "workspaceSessionReady": true, + "hydrationSucceeded": true, + "activeView": "terminal", + "activeRepoId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d", + "activeWorktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two", + "activeTabType": "terminal", + "activeTabId": "e1284ad8-dc0b-4bb7-bcd0-f2b24b8f4083", + "terminalGpuAcceleration": "auto", + "repoCount": 1, + "worktreeCountsByRepo": { + "f5b2567a-69b4-41a3-ba29-8c4bd357042d": 3 + }, + "ptyIdsByTabId": [ + "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two@@e63f448f" + ], + "paneManagerCount": 3, + "activePane": { + "id": 1, + "leafId": "6aaaf90a-6cb7-48f9-b7c9-6869278038a7", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two@@e63f448f", + "cols": 175, + "rows": 73, + "buffer": { + "baseY": 0, + "viewportY": 0, + "cursorY": 0, + "length": 73 + } + }, + "renderingDiagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": false, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ], + "allPaneManagersDiagnostics": [ + { + "tabId": "93e4a957-3325-4987-a180-5dac10bebbc0", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": true, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ] + }, + { + "tabId": "6f6ad43f-ecf2-47c1-b6dd-816bfc4638d7", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": true, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ] + }, + { + "tabId": "e1284ad8-dc0b-4bb7-bcd0-f2b24b8f4083", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": false, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ] + } + ], + "webglContextCounts": { + "attachedWebglCount": 3, + "deferredWebglCount": 2, + "managerCount": 3, + "paneCount": 3 + }, + "webglIdentity": { + "available": true, + "vendor": "Google Inc. (Intel)", + "renderer": "ANGLE (Intel, Intel(R) Arc(TM) Graphics (0x00007D55) Direct3D11 vs_5_0 ps_5_0, D3D11)" + }, + "rendererProbe": { + "intervalMs": 50, + "last": 39444.30000001192, + "maxDriftMs": 504, + "samples": 351, + "startedAt": 11431.800000011921, + "lastTickAt": 39444.30000001192 + }, + "ptySessions": { + "value": [ + { + "id": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@14096c3d", + "cwd": "", + "title": "shell" + }, + { + "id": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-one@@0b21e66f", + "cwd": "", + "title": "shell" + }, + { + "id": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two@@e63f448f", + "cwd": "", + "title": "shell" + } + ] + }, + "rendererDeliveryDebug": { + "value": { + "pendingPtyCount": 0, + "pendingChars": 0, + "maxPendingCharsByPty": 0, + "rendererInFlightPtyCount": 0, + "rendererInFlightChars": 0, + "maxRendererInFlightCharsByPty": 0, + "activeRendererPtyCount": 1, + "flushScheduled": false, + "peakPendingChars": 82, + "peakMaxPendingCharsByPty": 82, + "peakRendererInFlightChars": 82, + "peakMaxRendererInFlightCharsByPty": 82, + "ackGatedFlushSkipCount": 0 + } + } + }, + "cleanupErrors": [], + "elapsedMs": 53646, + "appLogsTail": [ + { + "source": "stderr", + "line": "[startup] renderer-fetch-folder-workspaces-done t=13271 rendererT=10691 durationMs=32", + "at": 1782978685796 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktrees-done t=13272 rendererT=10691 durationMs=0", + "at": 1782978685797 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktree-lineage-done t=13288 rendererT=10708 durationMs=17", + "at": 1782978685813 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktree-lineage-done t=13290 rendererT=10709 durationMs=19", + "at": 1782978685815 + }, + { + "source": "stderr", + "line": "[startup] renderer-ui-get-done t=13303 rendererT=10723 durationMs=14", + "at": 1782978685828 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-persisted-ui-done t=13303 rendererT=10723 durationMs=0", + "at": 1782978685828 + }, + { + "source": "stderr", + "line": "[startup] renderer-ui-get-done t=13305 rendererT=10725 durationMs=15", + "at": 1782978685830 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-persisted-ui-done t=13305 rendererT=10725 durationMs=0", + "at": 1782978685830 + }, + { + "source": "stderr", + "line": "[startup] renderer-session-get-done t=13313 rendererT=10733 durationMs=10", + "at": 1782978685838 + }, + { + "source": "stderr", + "line": "[startup] renderer-session-get-done t=13314 rendererT=10734 durationMs=8", + "at": 1782978685839 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-keybindings-done t=13828 rendererT=10799 durationMs=66", + "at": 1782978686354 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-keybindings-done t=13829 rendererT=10833 durationMs=99", + "at": 1782978686354 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-session-stores-done t=13829 rendererT=10837 durationMs=4", + "at": 1782978686354 + }, + { + "source": "stderr", + "line": "[startup] renderer-visit-timestamp-prune-done t=13829 rendererT=10838 durationMs=1", + "at": 1782978686354 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-browser-session-profiles-done t=13840 rendererT=11257 durationMs=418", + "at": 1782978686365 + }, + { + "source": "stderr", + "line": "[startup] renderer-onboarding-get-done t=13842 rendererT=11260 durationMs=4", + "at": 1782978686367 + }, + { + "source": "stderr", + "line": "[startup] renderer-ssh-reconnect-skipped t=13842 rendererT=11261 connectionIds=0", + "at": 1782978686367 + }, + { + "source": "stderr", + "line": "[startup] renderer-first-window-services-await-done t=13880 rendererT=11295 durationMs=34", + "at": 1782978686405 + }, + { + "source": "stderr", + "line": "[startup] renderer-reconnect-terminals-done t=13914 rendererT=11334 durationMs=39", + "at": 1782978686439 + }, + { + "source": "stderr", + "line": "[startup] renderer-startup-hydration-done t=13916 rendererT=11335 durationMs=1709", + "at": 1782978686441 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=14429 maxGapMs=446", + "at": 1782978686954 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=16441 maxGapMs=409", + "at": 1782978688966 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=18453 maxGapMs=7", + "at": 1782978690978 + }, + { + "source": "stderr", + "line": "[48724:0702/035132.336:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=93e4a957-3325-4987-a180-5dac10bebbc0 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=93e4a957-3325-4987-a180-5dac10bebbc0:d285da80-e34b-422f-a0a4-14a68a6c5cb5\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978692336 + }, + { + "source": "stderr", + "line": "[48724:0702/035132.336:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978692336 + }, + { + "source": "stderr", + "line": "[48724:0702/035132.387:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=766de809-5863-49f7-a603-3faa5fdf4693 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=766de809-5863-49f7-a603-3faa5fdf4693:3747de82-0192-46c3-a130-e114e78bb00d\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978692387 + }, + { + "source": "stderr", + "line": "[48724:0702/035132.387:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978692387 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=20469 maxGapMs=30", + "at": 1782978692994 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=22492 maxGapMs=7", + "at": 1782978695018 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@14096c3d total=2796ms preflight=0ms auth=0ms host_env=47ms options=0ms provider_spawn=2749ms daemon=true reattach=false", + "at": 1782978695134 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@ccc87346 total=2805ms preflight=0ms auth=0ms host_env=52ms options=0ms provider_spawn=2753ms daemon=true reattach=false", + "at": 1782978695192 + }, + { + "source": "stderr", + "line": "[48724:0702/035136.133:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=9efaa7fd-442b-4efe-bf23-52d7143a0219 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=9efaa7fd-442b-4efe-bf23-52d7143a0219:df4d3047-f992-48ce-b5f1-6d0637523a9e\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978696133 + }, + { + "source": "stderr", + "line": "[48724:0702/035136.133:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978696133 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@ec755bef total=116ms preflight=0ms auth=0ms host_env=51ms options=0ms provider_spawn=65ms daemon=true reattach=false", + "at": 1782978696250 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=24506 maxGapMs=36", + "at": 1782978697031 + }, + { + "source": "stderr", + "line": "[48724:0702/035137.119:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=694f725c-29d1-4107-8648-81d285f6ab4b restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=694f725c-29d1-4107-8648-81d285f6ab4b:90413477-6d54-4dda-8c37-59e718741517\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978697120 + }, + { + "source": "stderr", + "line": "[48724:0702/035137.120:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978697120 + }, + { + "source": "stderr", + "line": "[48724:0702/035137.171:INFO:CONSOLE:397] \"Tooltip is changing from controlled to uncontrolled. Components should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled value for the lifetime of the component.\", source: http://localhost:5173/@fs/C:/Users/jinwo/orca/workspaces/orca/windows-performance-improvement/node_modules/.vite/deps/chunk-22L5D3A7.js?v=43681c89 (397)", + "at": 1782978697172 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@90e4e78e total=113ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=65ms daemon=true reattach=false", + "at": 1782978697234 + }, + { + "source": "stderr", + "line": "[48724:0702/035138.128:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=adc6529c-4b75-45a8-8ea2-1bcf637da544 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=adc6529c-4b75-45a8-8ea2-1bcf637da544:91dc16c6-f567-4e05-994c-94ae19376ee7\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978698128 + }, + { + "source": "stderr", + "line": "[48724:0702/035138.128:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978698128 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@b172fe7e total=118ms preflight=0ms auth=0ms host_env=54ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "at": 1782978698247 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=26510 maxGapMs=51", + "at": 1782978699035 + }, + { + "source": "stderr", + "line": "[48724:0702/035139.097:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=f891d153-855c-43cb-9fdb-fd8682070724 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=f891d153-855c-43cb-9fdb-fd8682070724:5d0971c2-c663-4e44-ae1c-825dc948489a\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978699097 + }, + { + "source": "stderr", + "line": "[48724:0702/035139.097:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978699097 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@2ff2b79f total=113ms preflight=0ms auth=0ms host_env=49ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "at": 1782978699211 + }, + { + "source": "stderr", + "line": "[48724:0702/035140.045:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=939ae529-7ad7-4d46-b4c0-4b3997ab6b98 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=939ae529-7ad7-4d46-b4c0-4b3997ab6b98:b3224a5c-19ae-42b8-b756-965f04806e49\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978700046 + }, + { + "source": "stderr", + "line": "[48724:0702/035140.046:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978700046 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@0965fd8c total=114ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=66ms daemon=true reattach=false", + "at": 1782978700161 + }, + { + "source": "stderr", + "line": "[48724:0702/035141.002:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=5749668e-dbd3-41ff-bbe5-c93c12925763 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=5749668e-dbd3-41ff-bbe5-c93c12925763:e3a00c11-d53b-4747-b215-ce9f19bb0da0\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978701002 + }, + { + "source": "stderr", + "line": "[48724:0702/035141.002:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978701002 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=28532 maxGapMs=50", + "at": 1782978701057 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@9c46ce32 total=124ms preflight=0ms auth=0ms host_env=54ms options=0ms provider_spawn=70ms daemon=true reattach=false", + "at": 1782978701126 + }, + { + "source": "stderr", + "line": "[48724:0702/035141.986:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=2546b0b6-5198-4067-b59b-698e914160d0 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=2546b0b6-5198-4067-b59b-698e914160d0:fecf7693-17a1-4cdc-8d35-1ba415e0760d\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978701986 + }, + { + "source": "stderr", + "line": "[48724:0702/035141.986:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978701986 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@ea5607d8 total=116ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=68ms daemon=true reattach=false", + "at": 1782978702102 + }, + { + "source": "stderr", + "line": "[48724:0702/035143.024:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=87a752ed-2a66-411e-8c00-b7b0b24b5d10 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=87a752ed-2a66-411e-8c00-b7b0b24b5d10:9258e63f-4dee-455c-8f8f-543569e4ce24\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978703024 + }, + { + "source": "stderr", + "line": "[48724:0702/035143.024:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978703025 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=30557 maxGapMs=47", + "at": 1782978703082 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@10666ccc total=116ms preflight=0ms auth=0ms host_env=53ms options=0ms provider_spawn=63ms daemon=true reattach=false", + "at": 1782978703141 + }, + { + "source": "stderr", + "line": "[48724:0702/035143.510:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=03cc8628-e72d-478c-90f7-3ed264097afb restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=03cc8628-e72d-478c-90f7-3ed264097afb:b30207cc-752f-4836-8ea5-6f095e97f32d\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978703510 + }, + { + "source": "stderr", + "line": "[48724:0702/035143.510:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978703510 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@566d348e total=120ms preflight=0ms auth=0ms host_env=52ms options=0ms provider_spawn=68ms daemon=true reattach=false", + "at": 1782978703630 + }, + { + "source": "stderr", + "line": "[48724:0702/035144.065:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=993f77d3-4e18-4b17-b386-adead86c80c8 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=993f77d3-4e18-4b17-b386-adead86c80c8:ee549cc0-14c4-4211-922b-918f0810da85\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978704066 + }, + { + "source": "stderr", + "line": "[48724:0702/035144.066:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978704066 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@aedde862 total=122ms preflight=0ms auth=0ms host_env=51ms options=0ms provider_spawn=71ms daemon=true reattach=false", + "at": 1782978704188 + }, + { + "source": "stderr", + "line": "[48724:0702/035144.838:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=2e38aeb9-4c66-440e-adbf-de538b4ddcbb restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=2e38aeb9-4c66-440e-adbf-de538b4ddcbb:26a39262-f10c-4e3a-a246-8646617d0b05\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978704838 + }, + { + "source": "stderr", + "line": "[48724:0702/035144.838:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978704838 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@dd291490 total=147ms preflight=0ms auth=0ms host_env=54ms options=0ms provider_spawn=93ms daemon=true reattach=false", + "at": 1782978704985 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=32571 maxGapMs=116", + "at": 1782978705096 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=34594 maxGapMs=18", + "at": 1782978707119 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=36594 maxGapMs=13", + "at": 1782978709119 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=38594 maxGapMs=11", + "at": 1782978711119 + }, + { + "source": "stderr", + "line": "[48724:0702/035151.953:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=6f6ad43f-ecf2-47c1-b6dd-816bfc4638d7 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=6f6ad43f-ecf2-47c1-b6dd-816bfc4638d7:9002711a-d8da-4d46-a938-d1ab106322eb\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978711953 + }, + { + "source": "stderr", + "line": "[48724:0702/035151.953:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978711953 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-one@@0b21e66f total=115ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=67ms daemon=true reattach=false", + "at": 1782978712070 + }, + { + "source": "stderr", + "line": "[48724:0702/035152.571:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=e1284ad8-dc0b-4bb7-bcd0-f2b24b8f4083 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=e1284ad8-dc0b-4bb7-bcd0-f2b24b8f4083:6aaaf90a-6cb7-48f9-b7c9-6869278038a7\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978712571 + }, + { + "source": "stderr", + "line": "[48724:0702/035152.571:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978712571 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two@@e63f448f total=118ms preflight=0ms auth=1ms host_env=54ms options=0ms provider_spawn=63ms daemon=true reattach=false", + "at": 1782978712689 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=40618 maxGapMs=40", + "at": 1782978713143 + } + ], + "ptySpawnTimings": [ + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@14096c3d total=2796ms preflight=0ms auth=0ms host_env=47ms options=0ms provider_spawn=2749ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@ccc87346 total=2805ms preflight=0ms auth=0ms host_env=52ms options=0ms provider_spawn=2753ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@ec755bef total=116ms preflight=0ms auth=0ms host_env=51ms options=0ms provider_spawn=65ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@90e4e78e total=113ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=65ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@b172fe7e total=118ms preflight=0ms auth=0ms host_env=54ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@2ff2b79f total=113ms preflight=0ms auth=0ms host_env=49ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@0965fd8c total=114ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=66ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@9c46ce32 total=124ms preflight=0ms auth=0ms host_env=54ms options=0ms provider_spawn=70ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@ea5607d8 total=116ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=68ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@10666ccc total=116ms preflight=0ms auth=0ms host_env=53ms options=0ms provider_spawn=63ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@566d348e total=120ms preflight=0ms auth=0ms host_env=52ms options=0ms provider_spawn=68ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@aedde862 total=122ms preflight=0ms auth=0ms host_env=51ms options=0ms provider_spawn=71ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@dd291490 total=147ms preflight=0ms auth=0ms host_env=54ms options=0ms provider_spawn=93ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-one@@0b21e66f total=115ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=67ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two@@e63f448f total=118ms preflight=0ms auth=1ms host_env=54ms options=0ms provider_spawn=63ms daemon=true reattach=false" + ] +} diff --git a/tools/benchmarks/results/terminal-perf-fix-abdf-2026-07-02T07-59-11-820Z.json b/tools/benchmarks/results/terminal-perf-fix-abdf-2026-07-02T07-59-11-820Z.json new file mode 100644 index 00000000000..c4e2c07b18f --- /dev/null +++ b/tools/benchmarks/results/terminal-perf-fix-abdf-2026-07-02T07-59-11-820Z.json @@ -0,0 +1,1058 @@ +{ + "label": "fix-abdf", + "startedAt": "2026-07-02T07:59:11.820Z", + "platform": "win32 10.0.26200", + "shell": "default", + "args": { + "iterations": 8, + "switches": 24, + "cycles": 12 + }, + "scenarios": { + "tab-create": { + "samples": [ + { + "index": 0, + "storeCreateMs": 3.299999952316284, + "ptyBindMs": 4816.899999976158, + "firstOutputMs": 4856.899999976158, + "paintSettleMs": 4864, + "timedOut": false + }, + { + "index": 1, + "storeCreateMs": 2.800000011920929, + "ptyBindMs": 529, + "firstOutputMs": 554.5, + "paintSettleMs": 556.8000000119209, + "timedOut": false + }, + { + "index": 2, + "storeCreateMs": 2.400000035762787, + "ptyBindMs": 423.10000002384186, + "firstOutputMs": 464.80000001192093, + "paintSettleMs": 470, + "timedOut": false + }, + { + "index": 3, + "storeCreateMs": 1.800000011920929, + "ptyBindMs": 438.4000000357628, + "firstOutputMs": 453.69999998807907, + "paintSettleMs": 455.80000001192093, + "timedOut": false + }, + { + "index": 4, + "storeCreateMs": 1.600000023841858, + "ptyBindMs": 497.10000002384186, + "firstOutputMs": 508.80000001192093, + "paintSettleMs": 515.8999999761581, + "timedOut": false + }, + { + "index": 5, + "storeCreateMs": 1.9000000357627869, + "ptyBindMs": 471.80000001192093, + "firstOutputMs": 488.10000002384186, + "paintSettleMs": 491.30000001192093, + "timedOut": false + }, + { + "index": 6, + "storeCreateMs": 1.199999988079071, + "ptyBindMs": 512.6999999880791, + "firstOutputMs": 524.2999999523163, + "paintSettleMs": 531.6999999880791, + "timedOut": false + }, + { + "index": 7, + "storeCreateMs": 1.5, + "ptyBindMs": 468.10000002384186, + "firstOutputMs": 483.19999998807907, + "paintSettleMs": 484.19999998807907, + "timedOut": false + } + ], + "longtasks": [ + 52, 397, 67, 187, 133, 214, 192, 152, 196, 90, 154, 255, 61, 71, 296, 76, 68, 258, 92, 68, + 244, 161, 63, 176, 178, 137 + ] + }, + "tab-switch": { + "samples": [ + { + "index": 0, + "targetTabId": "aa62c8e8-2ef4-4aa3-ac4d-b9063cfa527e", + "storeMs": 1.9000000357627869, + "paneVisibleMs": 1.9000000357627869, + "paintSettleMs": 76.20000004768372, + "timedOut": false + }, + { + "index": 1, + "targetTabId": "66083463-228e-4fb0-90c5-231dc75e8da6", + "storeMs": 1.0999999642372131, + "paneVisibleMs": 1.0999999642372131, + "paintSettleMs": 113.19999998807907, + "timedOut": false + }, + { + "index": 2, + "targetTabId": "b99bb441-7027-40a9-beb2-a558ece6f979", + "storeMs": 2.5, + "paneVisibleMs": 2.5, + "paintSettleMs": 130.9000000357628, + "timedOut": false + }, + { + "index": 3, + "targetTabId": "490084ae-1bdb-4359-bb86-097e26902f98", + "storeMs": 2.099999964237213, + "paneVisibleMs": 2.099999964237213, + "paintSettleMs": 70.09999996423721, + "timedOut": false + }, + { + "index": 4, + "targetTabId": "aa62c8e8-2ef4-4aa3-ac4d-b9063cfa527e", + "storeMs": 1.899999976158142, + "paneVisibleMs": 1.899999976158142, + "paintSettleMs": 54.19999998807907, + "timedOut": false + }, + { + "index": 5, + "targetTabId": "66083463-228e-4fb0-90c5-231dc75e8da6", + "storeMs": 2.5, + "paneVisibleMs": 2.5, + "paintSettleMs": 52.69999998807907, + "timedOut": false + }, + { + "index": 6, + "targetTabId": "b99bb441-7027-40a9-beb2-a558ece6f979", + "storeMs": 1.699999988079071, + "paneVisibleMs": 1.699999988079071, + "paintSettleMs": 119.30000001192093, + "timedOut": false + }, + { + "index": 7, + "targetTabId": "490084ae-1bdb-4359-bb86-097e26902f98", + "storeMs": 1.399999976158142, + "paneVisibleMs": 1.399999976158142, + "paintSettleMs": 104.29999995231628, + "timedOut": false + }, + { + "index": 8, + "targetTabId": "aa62c8e8-2ef4-4aa3-ac4d-b9063cfa527e", + "storeMs": 2.399999976158142, + "paneVisibleMs": 2.5, + "paintSettleMs": 78.39999997615814, + "timedOut": false + }, + { + "index": 9, + "targetTabId": "66083463-228e-4fb0-90c5-231dc75e8da6", + "storeMs": 1.699999988079071, + "paneVisibleMs": 1.699999988079071, + "paintSettleMs": 49.59999996423721, + "timedOut": false + }, + { + "index": 10, + "targetTabId": "b99bb441-7027-40a9-beb2-a558ece6f979", + "storeMs": 1.300000011920929, + "paneVisibleMs": 1.300000011920929, + "paintSettleMs": 47.10000002384186, + "timedOut": false + }, + { + "index": 11, + "targetTabId": "490084ae-1bdb-4359-bb86-097e26902f98", + "storeMs": 3.100000023841858, + "paneVisibleMs": 3.100000023841858, + "paintSettleMs": 120.30000001192093, + "timedOut": false + }, + { + "index": 12, + "targetTabId": "aa62c8e8-2ef4-4aa3-ac4d-b9063cfa527e", + "storeMs": 1.600000023841858, + "paneVisibleMs": 1.699999988079071, + "paintSettleMs": 108.19999998807907, + "timedOut": false + }, + { + "index": 13, + "targetTabId": "66083463-228e-4fb0-90c5-231dc75e8da6", + "storeMs": 1.699999988079071, + "paneVisibleMs": 1.699999988079071, + "paintSettleMs": 51.69999998807907, + "timedOut": false + }, + { + "index": 14, + "targetTabId": "b99bb441-7027-40a9-beb2-a558ece6f979", + "storeMs": 1.600000023841858, + "paneVisibleMs": 1.600000023841858, + "paintSettleMs": 45.30000001192093, + "timedOut": false + }, + { + "index": 15, + "targetTabId": "490084ae-1bdb-4359-bb86-097e26902f98", + "storeMs": 1.699999988079071, + "paneVisibleMs": 1.699999988079071, + "paintSettleMs": 44.69999998807907, + "timedOut": false + }, + { + "index": 16, + "targetTabId": "aa62c8e8-2ef4-4aa3-ac4d-b9063cfa527e", + "storeMs": 1.399999976158142, + "paneVisibleMs": 1.399999976158142, + "paintSettleMs": 58.69999998807907, + "timedOut": false + }, + { + "index": 17, + "targetTabId": "66083463-228e-4fb0-90c5-231dc75e8da6", + "storeMs": 1.899999976158142, + "paneVisibleMs": 1.899999976158142, + "paintSettleMs": 136.5999999642372, + "timedOut": false + }, + { + "index": 18, + "targetTabId": "b99bb441-7027-40a9-beb2-a558ece6f979", + "storeMs": 2, + "paneVisibleMs": 2, + "paintSettleMs": 136.0999999642372, + "timedOut": false + }, + { + "index": 19, + "targetTabId": "490084ae-1bdb-4359-bb86-097e26902f98", + "storeMs": 1.600000023841858, + "paneVisibleMs": 1.600000023841858, + "paintSettleMs": 53.80000001192093, + "timedOut": false + }, + { + "index": 20, + "targetTabId": "aa62c8e8-2ef4-4aa3-ac4d-b9063cfa527e", + "storeMs": 2.100000023841858, + "paneVisibleMs": 2.100000023841858, + "paintSettleMs": 51.5, + "timedOut": false + }, + { + "index": 21, + "targetTabId": "66083463-228e-4fb0-90c5-231dc75e8da6", + "storeMs": 1.800000011920929, + "paneVisibleMs": 1.800000011920929, + "paintSettleMs": 122.40000003576279, + "timedOut": false + }, + { + "index": 22, + "targetTabId": "b99bb441-7027-40a9-beb2-a558ece6f979", + "storeMs": 1.699999988079071, + "paneVisibleMs": 1.699999988079071, + "paintSettleMs": 118.5, + "timedOut": false + }, + { + "index": 23, + "targetTabId": "490084ae-1bdb-4359-bb86-097e26902f98", + "storeMs": 2.300000011920929, + "paneVisibleMs": 2.300000011920929, + "paintSettleMs": 62.69999998807907, + "timedOut": false + } + ], + "longtasks": [66, 102, 123, 64, 115, 96, 74, 113, 102, 52, 125, 130, 117, 110, 53] + }, + "workspace-switch": { + "samples": [ + { + "index": 0, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@5810ae88", + "activationMs": 217, + "ptyBindMs": 219, + "paintSettleMs": 233 + }, + { + "index": 1, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-one", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-one@@c450cf26", + "activationMs": 480, + "ptyBindMs": 575, + "paintSettleMs": 601 + }, + { + "index": 2, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two@@ae6fdb06", + "activationMs": 382, + "ptyBindMs": 557, + "paintSettleMs": 573 + }, + { + "index": 3, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@5810ae88", + "activationMs": 268, + "ptyBindMs": 295, + "paintSettleMs": 299 + }, + { + "index": 4, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-one", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-one@@c450cf26", + "activationMs": 154, + "ptyBindMs": 178, + "paintSettleMs": 184 + }, + { + "index": 5, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two@@ae6fdb06", + "activationMs": 145, + "ptyBindMs": 166, + "paintSettleMs": 178 + }, + { + "index": 6, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@5810ae88", + "activationMs": 159, + "ptyBindMs": 184, + "paintSettleMs": 192 + }, + { + "index": 7, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-one", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-one@@c450cf26", + "activationMs": 328, + "ptyBindMs": 349, + "paintSettleMs": 354 + }, + { + "index": 8, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two@@ae6fdb06", + "activationMs": 139, + "ptyBindMs": 158, + "paintSettleMs": 167 + }, + { + "index": 9, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@5810ae88", + "activationMs": 150, + "ptyBindMs": 177, + "paintSettleMs": 183 + }, + { + "index": 10, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-one", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-one@@c450cf26", + "activationMs": 150, + "ptyBindMs": 169, + "paintSettleMs": 179 + }, + { + "index": 11, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two@@ae6fdb06", + "activationMs": 134, + "ptyBindMs": 166, + "paintSettleMs": 182 + } + ] + } + }, + "summaries": [ + { + "name": "tab-create", + "samples": 8, + "timedOut": 0, + "fields": { + "storeCreateMs": { + "count": 8, + "median": 2, + "p95": 3, + "max": 3 + }, + "ptyBindMs": { + "count": 8, + "median": 497, + "p95": 4817, + "max": 4817 + }, + "firstOutputMs": { + "count": 8, + "median": 509, + "p95": 4857, + "max": 4857 + }, + "paintSettleMs": { + "count": 8, + "median": 516, + "p95": 4864, + "max": 4864 + } + }, + "longtaskCount": 26, + "longtaskMaxMs": 397 + }, + { + "name": "tab-switch", + "samples": 24, + "timedOut": 0, + "fields": { + "storeMs": { + "count": 24, + "median": 2, + "p95": 3, + "max": 3 + }, + "paneVisibleMs": { + "count": 24, + "median": 2, + "p95": 3, + "max": 3 + }, + "paintSettleMs": { + "count": 24, + "median": 76, + "p95": 136, + "max": 137 + } + }, + "longtaskCount": 15, + "longtaskMaxMs": 130 + }, + { + "name": "workspace-switch", + "samples": 12, + "timedOut": 0, + "fields": { + "activationMs": { + "count": 12, + "median": 159, + "p95": 382, + "max": 480 + }, + "ptyBindMs": { + "count": 12, + "median": 184, + "p95": 557, + "max": 575 + }, + "paintSettleMs": { + "count": 12, + "median": 192, + "p95": 573, + "max": 601 + } + }, + "longtaskCount": 0, + "longtaskMaxMs": 0 + } + ], + "finalDiagnostics": { + "hasStore": true, + "workspaceSessionReady": true, + "hydrationSucceeded": true, + "activeView": "terminal", + "activeRepoId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b", + "activeWorktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two", + "activeTabType": "terminal", + "activeTabId": "15055907-3232-4ca1-8814-e5484ba78a5b", + "terminalGpuAcceleration": "auto", + "repoCount": 1, + "worktreeCountsByRepo": { + "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b": 3 + }, + "ptyIdsByTabId": [ + "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two@@ae6fdb06" + ], + "paneManagerCount": 3, + "activePane": { + "id": 1, + "leafId": "78b7478c-39a9-4111-aed8-1e2ddf568c24", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two@@ae6fdb06", + "cols": 175, + "rows": 73, + "buffer": { + "baseY": 0, + "viewportY": 0, + "cursorY": 0, + "length": 73 + } + }, + "renderingDiagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": false, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ], + "allPaneManagersDiagnostics": [ + { + "tabId": "c46e04e4-af5f-4674-9a38-72253480baef", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": true, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ] + }, + { + "tabId": "769013c6-b5b8-4be9-b188-4444a6094567", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": true, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ] + }, + { + "tabId": "15055907-3232-4ca1-8814-e5484ba78a5b", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": false, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ] + } + ], + "webglContextCounts": { + "attachedWebglCount": 3, + "deferredWebglCount": 2, + "managerCount": 3, + "paneCount": 3 + }, + "webglIdentity": { + "available": true, + "vendor": "Google Inc. (Intel)", + "renderer": "ANGLE (Intel, Intel(R) Arc(TM) Graphics (0x00007D55) Direct3D11 vs_5_0 ps_5_0, D3D11)" + }, + "rendererProbe": { + "intervalMs": 50, + "last": 35644.5, + "maxDriftMs": 413.89999997615814, + "samples": 309, + "startedAt": 11829.5, + "lastTickAt": 35644.5 + }, + "ptySessions": { + "value": [ + { + "id": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@5810ae88", + "cwd": "", + "title": "shell" + }, + { + "id": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-one@@c450cf26", + "cwd": "", + "title": "shell" + }, + { + "id": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two@@ae6fdb06", + "cwd": "", + "title": "shell" + } + ] + }, + "rendererDeliveryDebug": { + "value": { + "pendingPtyCount": 0, + "pendingChars": 0, + "maxPendingCharsByPty": 0, + "rendererInFlightPtyCount": 0, + "rendererInFlightChars": 0, + "maxRendererInFlightCharsByPty": 0, + "activeRendererPtyCount": 1, + "flushScheduled": false, + "peakPendingChars": 82, + "peakMaxPendingCharsByPty": 82, + "peakRendererInFlightChars": 82, + "peakMaxRendererInFlightCharsByPty": 82, + "ackGatedFlushSkipCount": 0 + } + } + }, + "cleanupErrors": [], + "elapsedMs": 50234, + "appLogsTail": [ + { + "source": "stderr", + "line": "[startup] renderer-fetch-folder-workspaces-done t=13833 rendererT=11239 durationMs=53", + "at": 1782979177594 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktrees-done t=13833 rendererT=11240 durationMs=1", + "at": 1782979177594 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-folder-workspaces-done t=13835 rendererT=11241 durationMs=55", + "at": 1782979177596 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktrees-done t=13835 rendererT=11242 durationMs=0", + "at": 1782979177596 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktree-lineage-done t=13838 rendererT=11246 durationMs=6", + "at": 1782979177599 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktree-lineage-done t=13841 rendererT=11249 durationMs=8", + "at": 1782979177602 + }, + { + "source": "stderr", + "line": "[startup] renderer-ui-get-done t=13842 rendererT=11250 durationMs=4", + "at": 1782979177603 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-persisted-ui-done t=13842 rendererT=11250 durationMs=0", + "at": 1782979177603 + }, + { + "source": "stderr", + "line": "[startup] renderer-ui-get-done t=13844 rendererT=11251 durationMs=2", + "at": 1782979177605 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-persisted-ui-done t=13844 rendererT=11251 durationMs=0", + "at": 1782979177605 + }, + { + "source": "stderr", + "line": "[startup] renderer-session-get-done t=13844 rendererT=11252 durationMs=2", + "at": 1782979177605 + }, + { + "source": "stderr", + "line": "[startup] renderer-session-get-done t=13846 rendererT=11254 durationMs=3", + "at": 1782979177607 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-keybindings-done t=14263 rendererT=11346 durationMs=94", + "at": 1782979178024 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-keybindings-done t=14263 rendererT=11426 durationMs=172", + "at": 1782979178024 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-session-stores-done t=14263 rendererT=11430 durationMs=4", + "at": 1782979178024 + }, + { + "source": "stderr", + "line": "[startup] renderer-visit-timestamp-prune-done t=14263 rendererT=11431 durationMs=1", + "at": 1782979178024 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-browser-session-profiles-done t=14277 rendererT=11685 durationMs=255", + "at": 1782979178038 + }, + { + "source": "stderr", + "line": "[startup] renderer-onboarding-get-done t=14282 rendererT=11691 durationMs=5", + "at": 1782979178043 + }, + { + "source": "stderr", + "line": "[startup] renderer-ssh-reconnect-skipped t=14283 rendererT=11691 connectionIds=0", + "at": 1782979178044 + }, + { + "source": "stderr", + "line": "[startup] renderer-first-window-services-await-done t=14324 rendererT=11732 durationMs=41", + "at": 1782979178085 + }, + { + "source": "stderr", + "line": "[startup] renderer-reconnect-terminals-done t=14366 rendererT=11774 durationMs=42", + "at": 1782979178127 + }, + { + "source": "stderr", + "line": "[startup] renderer-startup-hydration-done t=14367 rendererT=11775 durationMs=1992", + "at": 1782979178128 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=14580 maxGapMs=453", + "at": 1782979178342 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=16597 maxGapMs=336", + "at": 1782979180358 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=18612 maxGapMs=15", + "at": 1782979182373 + }, + { + "source": "stderr", + "line": "[47624:0702/035942.829:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=c46e04e4-af5f-4674-9a38-72253480baef restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=c46e04e4-af5f-4674-9a38-72253480baef:8089e7c5-e739-4785-b3b4-6844f99c0a86\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979182829 + }, + { + "source": "stderr", + "line": "[47624:0702/035942.829:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979182829 + }, + { + "source": "stderr", + "line": "[47624:0702/035942.876:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=e62346f1-a808-4397-8be7-422efa0aaebb restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=e62346f1-a808-4397-8be7-422efa0aaebb:e9b9d9ce-241c-458b-be93-01a657de7fd7\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979182877 + }, + { + "source": "stderr", + "line": "[47624:0702/035942.876:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979182877 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@5810ae88 total=1300ms preflight=0ms auth=1ms host_env=44ms options=0ms provider_spawn=1255ms daemon=true reattach=false", + "at": 1782979184130 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@2c485ebc total=1308ms preflight=0ms auth=0ms host_env=49ms options=0ms provider_spawn=1259ms daemon=true reattach=false", + "at": 1782979184185 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=20628 maxGapMs=48", + "at": 1782979184389 + }, + { + "source": "stderr", + "line": "[47624:0702/035945.089:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=894d9793-e2ae-40bf-864e-eef357f271c1 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=894d9793-e2ae-40bf-864e-eef357f271c1:1b683a77-2230-4238-96eb-5118ea595604\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979185089 + }, + { + "source": "stderr", + "line": "[47624:0702/035945.089:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979185089 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@c3018b1f total=108ms preflight=0ms auth=0ms host_env=47ms options=0ms provider_spawn=61ms daemon=true reattach=false", + "at": 1782979185197 + }, + { + "source": "stderr", + "line": "[47624:0702/035946.036:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=8fd459b0-a0a8-416d-b698-521da990ace3 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=8fd459b0-a0a8-416d-b698-521da990ace3:74040bad-c0d6-42c6-af48-21150176be1c\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979186035 + }, + { + "source": "stderr", + "line": "[47624:0702/035946.036:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979186035 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@59074e83 total=123ms preflight=0ms auth=0ms host_env=50ms options=0ms provider_spawn=73ms daemon=true reattach=false", + "at": 1782979186159 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=22635 maxGapMs=34", + "at": 1782979186396 + }, + { + "source": "stderr", + "line": "[47624:0702/035946.971:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=6559b19f-a2e8-4691-8057-2efd9ba67d0e restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=6559b19f-a2e8-4691-8057-2efd9ba67d0e:8b41e717-29fe-49fb-a710-c96e41a1d112\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979186971 + }, + { + "source": "stderr", + "line": "[47624:0702/035946.971:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979186971 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@562113d2 total=111ms preflight=0ms auth=0ms host_env=51ms options=0ms provider_spawn=60ms daemon=true reattach=false", + "at": 1782979187082 + }, + { + "source": "stderr", + "line": "[47624:0702/035947.872:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=04620d6d-4629-40b3-8150-c7eec447d262 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=04620d6d-4629-40b3-8150-c7eec447d262:5992fb07-187a-43c3-9434-d424f1312155\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979187872 + }, + { + "source": "stderr", + "line": "[47624:0702/035947.873:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979187873 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@b5230dba total=105ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=57ms daemon=true reattach=false", + "at": 1782979187978 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=24659 maxGapMs=46", + "at": 1782979188420 + }, + { + "source": "stderr", + "line": "[47624:0702/035948.434:INFO:CONSOLE:397] \"Tooltip is changing from controlled to uncontrolled. Components should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled value for the lifetime of the component.\", source: http://localhost:5173/@fs/C:/Users/jinwo/orca/workspaces/orca/windows-performance-improvement/node_modules/.vite/deps/chunk-22L5D3A7.js?v=43681c89 (397)", + "at": 1782979188434 + }, + { + "source": "stderr", + "line": "[47624:0702/035948.761:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=bc4db6bc-b408-472a-9c15-77a428f98d99 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=bc4db6bc-b408-472a-9c15-77a428f98d99:11ff82bf-57d3-4e85-9293-77c0935f7d08\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979188761 + }, + { + "source": "stderr", + "line": "[47624:0702/035948.762:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979188761 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@95bb2f74 total=101ms preflight=0ms auth=0ms host_env=47ms options=0ms provider_spawn=54ms daemon=true reattach=false", + "at": 1782979188862 + }, + { + "source": "stderr", + "line": "[47624:0702/035949.641:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=57b2a6d7-7fd2-41b2-9772-44e39bc9cee6 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=57b2a6d7-7fd2-41b2-9772-44e39bc9cee6:d186da0c-c1c4-45ca-90f1-e1f3b8fa4505\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979189640 + }, + { + "source": "stderr", + "line": "[47624:0702/035949.641:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979189640 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@09fc3731 total=94ms preflight=0ms auth=0ms host_env=41ms options=0ms provider_spawn=53ms daemon=true reattach=false", + "at": 1782979189734 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=26688 maxGapMs=39", + "at": 1782979190449 + }, + { + "source": "stderr", + "line": "[47624:0702/035950.510:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=6883a556-63dd-4c0a-87fa-9682db6f4cf0 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=6883a556-63dd-4c0a-87fa-9682db6f4cf0:e0e844db-da79-459b-b791-52ea58bab4da\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979190509 + }, + { + "source": "stderr", + "line": "[47624:0702/035950.510:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979190510 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@fceb8b39 total=91ms preflight=0ms auth=0ms host_env=39ms options=1ms provider_spawn=51ms daemon=true reattach=false", + "at": 1782979190601 + }, + { + "source": "stderr", + "line": "[47624:0702/035951.407:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=aa62c8e8-2ef4-4aa3-ac4d-b9063cfa527e restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=aa62c8e8-2ef4-4aa3-ac4d-b9063cfa527e:c2c02ac2-e421-4c29-a8b6-4c97c11bf1dc\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979191407 + }, + { + "source": "stderr", + "line": "[47624:0702/035951.407:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979191407 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@badd8d38 total=104ms preflight=0ms auth=0ms host_env=47ms options=0ms provider_spawn=56ms daemon=true reattach=false", + "at": 1782979191511 + }, + { + "source": "stderr", + "line": "[47624:0702/035951.824:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=66083463-228e-4fb0-90c5-231dc75e8da6 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=66083463-228e-4fb0-90c5-231dc75e8da6:e4fc0e83-57a8-44f0-9732-936e2914292b\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979191824 + }, + { + "source": "stderr", + "line": "[47624:0702/035951.824:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979191824 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@ad09f3fe total=102ms preflight=0ms auth=0ms host_env=38ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "at": 1782979191926 + }, + { + "source": "stderr", + "line": "[47624:0702/035952.310:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=b99bb441-7027-40a9-beb2-a558ece6f979 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=b99bb441-7027-40a9-beb2-a558ece6f979:f5f1c1e2-72ac-4152-958b-b0b80e1213cc\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979192310 + }, + { + "source": "stderr", + "line": "[47624:0702/035952.311:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979192310 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@0bb8c245 total=112ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "at": 1782979192422 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=28705 maxGapMs=52", + "at": 1782979192466 + }, + { + "source": "stderr", + "line": "[47624:0702/035952.723:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=490084ae-1bdb-4359-bb86-097e26902f98 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=490084ae-1bdb-4359-bb86-097e26902f98:c3647cbf-2f6e-4b4b-89c1-8bea8b949d66\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979192723 + }, + { + "source": "stderr", + "line": "[47624:0702/035952.723:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979192723 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@3ef141e6 total=152ms preflight=0ms auth=0ms host_env=41ms options=0ms provider_spawn=111ms daemon=true reattach=false", + "at": 1782979192875 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=30719 maxGapMs=43", + "at": 1782979194480 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=32744 maxGapMs=12", + "at": 1782979196505 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=34767 maxGapMs=14", + "at": 1782979198528 + }, + { + "source": "stderr", + "line": "[47624:0702/035959.350:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=769013c6-b5b8-4be9-b188-4444a6094567 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=769013c6-b5b8-4be9-b188-4444a6094567:2a72038c-911c-4095-9fe8-0cd8c7f4978b\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979199349 + }, + { + "source": "stderr", + "line": "[47624:0702/035959.350:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979199349 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-one@@c450cf26 total=113ms preflight=0ms auth=0ms host_env=47ms options=0ms provider_spawn=66ms daemon=true reattach=false", + "at": 1782979199463 + }, + { + "source": "stderr", + "line": "[47624:0702/035959.852:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=15055907-3232-4ca1-8814-e5484ba78a5b restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=15055907-3232-4ca1-8814-e5484ba78a5b:78b7478c-39a9-4111-aed8-1e2ddf568c24\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979199852 + }, + { + "source": "stderr", + "line": "[47624:0702/035959.853:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979199853 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two@@ae6fdb06 total=104ms preflight=0ms auth=0ms host_env=43ms options=0ms provider_spawn=61ms daemon=true reattach=false", + "at": 1782979199957 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=36789 maxGapMs=49", + "at": 1782979200550 + } + ], + "ptySpawnTimings": [ + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@5810ae88 total=1300ms preflight=0ms auth=1ms host_env=44ms options=0ms provider_spawn=1255ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@2c485ebc total=1308ms preflight=0ms auth=0ms host_env=49ms options=0ms provider_spawn=1259ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@c3018b1f total=108ms preflight=0ms auth=0ms host_env=47ms options=0ms provider_spawn=61ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@59074e83 total=123ms preflight=0ms auth=0ms host_env=50ms options=0ms provider_spawn=73ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@562113d2 total=111ms preflight=0ms auth=0ms host_env=51ms options=0ms provider_spawn=60ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@b5230dba total=105ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=57ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@95bb2f74 total=101ms preflight=0ms auth=0ms host_env=47ms options=0ms provider_spawn=54ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@09fc3731 total=94ms preflight=0ms auth=0ms host_env=41ms options=0ms provider_spawn=53ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@fceb8b39 total=91ms preflight=0ms auth=0ms host_env=39ms options=1ms provider_spawn=51ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@badd8d38 total=104ms preflight=0ms auth=0ms host_env=47ms options=0ms provider_spawn=56ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@ad09f3fe total=102ms preflight=0ms auth=0ms host_env=38ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@0bb8c245 total=112ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@3ef141e6 total=152ms preflight=0ms auth=0ms host_env=41ms options=0ms provider_spawn=111ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-one@@c450cf26 total=113ms preflight=0ms auth=0ms host_env=47ms options=0ms provider_spawn=66ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two@@ae6fdb06 total=104ms preflight=0ms auth=0ms host_env=43ms options=0ms provider_spawn=61ms daemon=true reattach=false" + ] +} diff --git a/tools/benchmarks/terminal-perf-bench.mjs b/tools/benchmarks/terminal-perf-bench.mjs new file mode 100644 index 00000000000..f5c8646b696 --- /dev/null +++ b/tools/benchmarks/terminal-perf-bench.mjs @@ -0,0 +1,621 @@ +#!/usr/bin/env node +// Terminal interaction latency benchmark: tab creation, tab switching, and +// workspace switching against the real Electron dev app over CDP. Produces +// median/p95 phase timings so Windows terminal slowness can be attributed to +// store work, PTY spawn, shell startup, or renderer paint — not guessed at. + +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import { + collectRendererDiagnostics, + connectToApp, + createGpuUserDataDirectory, + installRendererProbe, + launchDevApp, + pickFreePort, + stopDevApp, + waitForStoreReady +} from '../../config/scripts/windows-apphang-repro/electron-dev-session.mjs' +import { + pollUntil, + rendererActionTimeoutMs, + runWithTimeout, + setupTimeoutMs +} from '../../config/scripts/windows-apphang-repro/repro-timing.mjs' +import { safeRemoveLocalDirectory } from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' + +const rootDir = path.resolve(fileURLToPath(new URL('../..', import.meta.url))) +const scenarioTimeoutMs = 300_000 +const defaultIterations = 8 +const defaultSwitches = 24 +const defaultCycles = 12 + +function parseArgs() { + const args = { + label: 'run', + iterations: defaultIterations, + switches: defaultSwitches, + cycles: defaultCycles, + shell: null, + scenarios: ['tab-create', 'tab-switch', 'workspace-switch'], + reportPath: null, + keep: false + } + for (const arg of process.argv.slice(2)) { + if (arg === '--help' || arg === '-h') { + printHelp() + process.exit(0) + } + if (arg === '--keep') { + args.keep = true + continue + } + const [name, value] = arg.split('=', 2) + if (name === '--label') { + args.label = value?.trim() || 'run' + continue + } + if (name === '--iterations') { + args.iterations = parsePositiveInt(name, value) + continue + } + if (name === '--switches') { + args.switches = parsePositiveInt(name, value) + continue + } + if (name === '--cycles') { + args.cycles = parsePositiveInt(name, value) + continue + } + if (name === '--shell') { + args.shell = value?.trim() || null + continue + } + if (name === '--scenarios') { + const scenarios = (value ?? '') + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + const known = new Set(['tab-create', 'tab-switch', 'workspace-switch']) + if (scenarios.length === 0 || scenarios.some((scenario) => !known.has(scenario))) { + throw new Error( + `Unsupported --scenarios=${value}. Use tab-create,tab-switch,workspace-switch.` + ) + } + args.scenarios = scenarios + continue + } + if (name === '--report') { + args.reportPath = value?.trim() || null + continue + } + throw new Error(`Unknown argument: ${arg}`) + } + return args +} + +function printHelp() { + console.log(`Usage: + node tools/benchmarks/terminal-perf-bench.mjs [options] + +Options: + --label=NAME Label recorded in the report filename/JSON. Default: run. + --iterations=N Terminal tab create/close iterations. Default: ${defaultIterations}. + --switches=N Tab switch alternations. Default: ${defaultSwitches}. + --cycles=N Workspace switch cycles. Default: ${defaultCycles}. + --shell=PATH Shell override for created tabs (e.g. cmd.exe). Default: app default. + --scenarios=a,b Subset of tab-create,tab-switch,workspace-switch. + --report=PATH Report JSON path. Default: tools/benchmarks/results/terminal-perf-